分布式之接口设计

分布式之接口设计

作为一名后端开发,每天不是在写接口、就是在写接口的路上,那接口要怎么写?有人说你这不废话吗,定义好入参/出参结构、实现好业务需求不就得了。

诚然,事实也的确如此,但这只是写好一个接口的基本要求,但是想要写出一个优秀的接口设计远不止于此。它需要我们具备前瞻性思维,全面考量安全性、性能优化、可扩展性、易用性乃至未来维护的便捷性。每一次深思熟虑,都是向接口完美形态迈进的坚实步伐,真正的高手,更致力于在接口的每一个细节上做到尽善尽美

一:完善注释和接口文档

写代码最讨厌两件事,一是别人的代码不写注释,二是别人叫我写代码的时候写注释

写注释和接口文档,不一定要写的多细致,但是至少要求,至少实在逻辑复杂的地方详略描述下

二:合理的执行日志

1:日志合理性

包括输出日志的位置、级别、内容都要合理

位置要合理,日志不要在循环中打印,这是典型的位置不合理的造成的。

内容要合理,尽量少转成JSON,非必要场景打印核心的几个字段就好

级别要合理,级别是指info, error, debug这种的,在不同的环境下,应该选择不同的级别

2:日志链路追踪

分布式情况下,为了方便日常排查功能Bug、性能问题、系统故障……,必须想办法将日志串联起来,而这种技术则被称为链路追踪

所谓的链路追踪很容易理解,以调用接口举例,当客户端调用某个接口发出请求时,系统就会记为日志的开始,而请求在系统内经过的任意节点,产出的所有日志都会被串联起来,直至该请求出站为止

业界有一些开源组件可选 => Zipkin、SkyWalking、Jaeger、SpringCloud-Sleuth => APM

在这里插入图片描述
可如果你在一个单体项目中,也想要有一种链路追踪技术,将一个请求产生的所有日志串联到一起怎么办:

你只需要在请求入站的时候,自动生成一个全局唯一的值,并塞入MDC里,然后在日志配置文件里获取输出即可

package com.zhuzi.exception.interceptor;

import org.slf4j.MDC;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

/**
 * @author cuihaida
 */
public class LoggingInterceptor implements HandlerInterceptor {
    private static final String TRACE_ID = "traceId";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 生成一个全局唯一的值作为链路ID
        String traceId = generateTraceId();
        MDC.put(TRACE_ID, traceId);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 请求结束后清除MDC
        MDC.clear();
    }

    private String generateTraceId() {
        // 生成traceId的逻辑,这里直接使用了UUID
        return UUID.randomUUID().toString();
    }
}
package com.zhuzi.config;

import com.zhuzi.exception.interceptor.LoggingInterceptor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author cuihaida
 */
@Component
public class WebConfig implements WebMvcConfigurer {
    /*
     * 拦截器
     */
    @Override
    public void addInterceptors(org.springframework.web.servlet.config.annotation.InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor());
    }
}

配置下日志输出格式:

logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} - %X{traceId} - %msg%n

三:接口风格统一

新系统的都会遵循RESTful风格来定义接口,这也编写接口时的标准

对于单个系统来说,不管是什么业务板块的接口,调用后返回的结构都是统一的:

{
  "code": 200,
  "msg": "success",
  "success": true,
  "data": {},
  "traceId": "xxx"
}

对于综合性的平台来说,比如统一对外的技术开放平台,又或者XXX中台,因为内部聚合了多个子系统的API,而不同子系统返回的结构又不一样,这时就会给调用方带来很大困扰

如果你在负责技术平台建设时,就算聚合的接口来自于不同子系统,那也要保证对外返回的结构体一致,怎么实现呢?很简单,内部通过API网关,统一转换不同系统的出参结构即可。

四:跨域问题

跨域问题的产生背景是浏览器的同源策略(Same-Origin Policy),同源策略是浏览器的一种安全机制,它限制了从同一个源加载的文档或脚本,如何与来自不同源的资源进行交互。这里的源(origin)是指协议、域名、端口号组成的唯一标识,如果出现两个地址,这三者组合起来对比不一致,就代表着不同源。

如何解决不同源的跨域问题呢?也很简单,在SpringBoot里通过一个@CrossOrigin注解就能搞定,如果嫌挨个Controller加注解麻烦,也可以通过实现WebMvcConfigurer接口,并重写addCorsMappings()方法来全局配置跨域

package com.zhuzi.config;

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author cuihaida
 */
@Component
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(org.springframework.web.servlet.config.annotation.CorsRegistry registry) {
        registry.addMapping("/**") // 允许所有的路径跨域
                .allowedOrigins("https://xxx.com") // 允许指定的域名跨域,*表示所有的域名
                .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
                .allowCredentials(true) // 允许发送cookie
                .maxAge(3600) // 预检请求的有效期,单位为秒
                .allowedHeaders("*"); // 允许所有的请求头
    }
}

当然,解决跨域问题的本质,是往响应头里塞几个字段,即:

response.setHeader("Access-Control-Allow-Origin", "*");  
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");  
response.setHeader("Access-Control-Max-Age", "3600");  
response.setHeader("Access-Control-Allow-Headers", "x-requested-with, authorization"); 

因此,只要能够改写响应头的地方都可以解决跨域问题,所以市面上才会出现那么多解决跨域的方式:

  • 使用@CrossOrigin注解解决跨域问题;
  • 实现WebMvcConfigurer接口解决跨域问题;
  • 通过自定义Filter过滤器解决跨域问题;
  • 通过Response对象实现跨域问题;
  • 通过实现ResponseBodyAdvice接口解决跨域问题;
  • 通过设置Nginx配置解决跨域问题;

五:安全性设计

一个优秀的接口,自然得考虑安全性方面的设计,毕竟除开少数部署在内网的系统外,大多数接口都能通过公网访问,而每天都有无数程序在扫描公网资源,如果你的接口没有做好安全性保障,这将称为“不法分子”的绝佳攻击口

1:身份鉴权

对于接口安全方面,出现时间最早的方案就是身份鉴权机制,在现在的系统中也被称为权限管理

如果接口没有任何权限控制,我完全可以绕开前端界面,通过抓包工具抓住接口地址后,直接对系统接口发起调用,从而实现绕开表面的限制

现在有一个付费才能看的资源,如果只是前端做了校验,那完全可以直接调用后端接口来绕开付费校验

接口权限管理,即是指具备一定权限才能调用的接口,如果调用者本身不具备对应权限,系统则会拒绝访问

如何设计呢?权限的载体是具体的用户,最简单的例子,就是已登录/未登录,某些接口必须要登录后才能访问,这就是一种权限具象化的表现。

因此,身份鉴权机制实际上只需处理好系统用户和权限的关系,这也有开源的成熟框架,如Shiro、Spring Security等,为了便于管理所有用户的权限,通常都会加入“角色”的概念

用户与角色关联、角色与权限关联,从而形成一整套身份体系。

在这里插入图片描述
有了身份体系后,就可以基于该体系来实现接口粒度的权限控制,比如后台业务对应的接口,只有具备“运营”角色以上的用户才能访问。当然,身份鉴权的前提是用户已经登录,如果每调用一个接口就要登录一次,这会验证影响用户体验,所以又衍生出了Token令牌的概念,即用户登陆后给其颁发一个临时通行证,在这个通行证有效期间内可以无需登录

2:接口签名机制

签名的身份鉴权机制,尽管能够在一定程度上避免非法请求,但也提到了,咱们会给登陆用户颁发Token令牌,而这个令牌是有一定时效期的。所以,非法请求也完全可以伪装用户登录,然后拿着对应的Token调用接口,Token都有了调用接口不是很合理吗?

正常情况下是合理的,可是非正常情况呢?比如订单提交接口,正常流程是用户登录、系统颁发Token、用户提交订单,这没有任何问题,可如果我登录拿到Token后,本来正常提交订单需要支付888.88元,我抓到请求信息后,直接将金额改写成0.01元,岂不是就乱套了?

所以,为了防止参数被篡改,就出现了一种名为签名的安全机制,签名机制的核心是双方约定一个密钥,接口调用方会先对进行参数排序,然后结合时间戳、密钥,生成一个sign值,并将时间戳、sign值一起放在请求参数中携带。

后端收到请求后不会直接处理,而是会先取出sign、时间戳,再以相同的规则对参数进行排序,接着也以相同规则生成一个sign值,再与参数的sign值进行对比,如果一致说明参数未被篡改,这时再进行具体的业务处理。

将签名机制套入签名的例子中,这时就算我抓住了提交订单请求,并将支付金额改为0.01,由于参数值发生了变化,服务端生成的sign值,自然与请求参数里的sign值对不上,这时系统就会忽略这些被篡改的请求

3:出入数据加密

在有些业务场景中,难免会需要用户的一些隐私数据,如手机号、身份证号等,建立在前面的基础上,因为数据是以明文形式传输,完全可以通过伪站、劫持等手段,拦截所有通向后端域名的请求,从而低成本窃取用户数据。

为了避免数据在传输过程中泄漏隐私数据,这时就会对请求参数进行加密或混淆,比如常见的BASE64也能被称为一种混淆手段。但真正的加密传输过程中,会选择专业的加密算法,如:

  • 哈希散列加密算法:MD5、SHA1、SHA256等。
  • 对称加密算法:AES-CBC、AES-GCM、DES、3DES等。
  • 非对称加密算法:RSA、DSA、ECC、DH等。

一般与前端的数据交互过程,通常会选非对称加密算法

特点为:公钥加密的数据,只有用对应的私钥才能解密;如果用私钥加密的数据,那只有用对应的公钥才能解密。

在这里插入图片描述
这时会将公钥给到前端使用,就算公钥泄漏了也不影响,毕竟公钥加密的数据公钥解不开,第三者拿到了公钥和加密报文,也无法逆向解析出真正的数据

再来看看响应参数加密,为啥要加密呢?答案是防爬虫,不过因为数据会给到前端使用,使用非对称加密不太合适,私钥存在前端,第三者完全可以拿到去解析。

正因如此,出参加密通常会使用数据混淆的手段,这种手段称之为蜜獾机制,大家感兴趣可以去看看携程、同程、12306……这些网站的接口数据,比如一家酒店的价格在页面显示“888.88元/晚”,而接口返回的数据完全不是这么回事,这就是用到了混淆机制来反制爬虫程序

比如X对应1Y对应8DAS对应88Z对应.YDASZDAS这个值就对应着888.88

4:黑白名单机制

黑白名单是安全性领域的元老级方案,比如你的数据库为了防止被人攻击,但又要暴露在公网来本地连接调试,这该怎么办?答案就是使用IP白名单,除了内网环境外,公网上只允许名单内的IP来访问。黑名单则完全相反,只要在黑名单内的都不允许访问。

换到接口设计领域,白名单的应用范围通常是API开放平台,比如有个第三方需要接入你们平台,但你又担心安全性,这就可以让对方提供固定IP,你将其配置在白名单内,此时就只有指定IP可访问啦。

不过黑白名单只是一种思想,你可以根据实际情况来选择实现的粒度:

  • IP级别:最常见的粒度,配置后只对指定IP的机器生效;
  • 用户级别:配置后只对固定用户生效(证书、appSecret就是这个维度);
  • 地域级别:IP名单变种,可以基于IP来实现地域过滤,如上海可访问;
  • ……

白名单用于限制特定的群体访问,而黑名单则完全相反,在接口领域的运用,更多还是防爬虫,比如检测到sign被篡改的请求时,直接将发起请求的IP加入黑名单。又或者是请求频率过快、登录设备异常等各种不合理的情况,都可以将其加入黑名单禁止访问。

六:资源隔离

接口与接口之间并不是平等的,业务之间存在优先级关系

案例一:之前我封装了一个线程池,用来并行查询数据,从而提升接口性能、缩短响应时间,结果另一位同事也有个异步需求需要用到多线程,看到有个自定义的线程池后,直接往里面塞长IO型任务,平均每个任务就得跑五六分钟,最终导致另外一边使用多线程并行查数据的接口,反而比单线程的方式更慢了。

案例二:现在有一个发送短信的功能,在许多场景下都会用到,比如下单成功、订单发货、订单退款、用户注册……
结果小竹认为同步调用发送接口太慢了,而且发短信并非核心流程,完全可以使用异步处理,所以,就将调用发短信接口的动作,全交了给线程池去异步执行。
而有一天订单量较大,这时出现一个用户来注册,需要填写手机验证码,可当系统生成验证码后,调用发短信接口。在此之前线程池里已经堆积了十万八千个任务等待处理。
最终的结果就是,直到几分钟后,才轮到这个发短信验证码的任务,可这时早已超出注册的验证码有效期了……

上述两个例子,都是没有做资源隔离导致的问题

所以写接口时,一定要考虑要不要做资源隔离!并且不仅要考虑线程池资源,也包括各种连接池等有限的珍贵资源。

再以数据库连接池为例,如果某项业务执行时间非常久,该业务就会持续蚕食公共的连接池资源,直至其余业务无法获取到可用连接,引发系统瘫痪。

那么啥叫资源隔离呢?比如前面发短信的场景,用户注册的短信验证码,显然并订单通知的优先级要高很多,所以应该将这些场景区分开来,定义高优先级、低优先级的线程池,分别用来处理不同类型的异步任务

七:版本兼容性

对于常规系统来说,如果只是系统本身的前后端交互,那么接口改造没有任何问题,只需改完后跟前端说一声,前端配合着一起改造就好啦。但是针对于开发平台、中台这类系统来说,因为它们被不知道多少三方接入了,如果接口逻辑要优化改造,贸然在原接口上变更,就会导致外部接入方发生不可预知的错误

正因如此,如果你的接口被其他服务、三方所依赖,任何改造都要保证向前兼容,而为了实现版本兼容性,通常会有两个做法:

  • 每次改造都用if来区分不同版本的参数,确保每次改造不会影响已有的接入方;
  • 每次改造都独立出新的接口,通过在接口路径上添加版本号来区分,从而实现多版本并行。

究竟选择哪种方式呢?这得看原本的代码设计,如果提前考虑到了后续拓展性,那么在原接口上改造是最好的。否则每次最好独立出新接口,从而确保版本向前兼容注明出处。

八:动态配置

在分布式项目中,有一句话叫做“约定大于配置,配置大于编码”

后半句的含义是:尽可能的通过配置来代替硬编码

比如超时熔断时间,最开始是1000ms,直接以硬编码形式写死在代码里,一旦需要延长时,就只能修改代码重新发版了。而这种可能存在变化的信息项,就可以通过配置形式注入,支持运行期间动态改变配置,来举些例子:

  • 超时熔断时间:可以根据线上的实际运行情况,动态调整调用外部接口的熔断时间;
  • 线程池参数:可以根据业务的需求,动态调整最大线程池,来创建更多的线程处理任务;
  • 消费者数量:面对消息堆积场景时,可以动态调整消费者数量来增大消费能力;
  • 灰度开关与阈值:新老功能替换时,可以动态调整灰度开关及切流比例,实现无感发布;
  • 重试次数:依赖外部资源时,可以动态调整失败的重试次数,确保满足特殊场景的需求;
  • 锁的时间:运行期间允许动态调整分布式锁的等待时间与过期时间,满足突然状况;
  • ……

总之,如果某个配置项可能会对功能造成不同的影响,你就可以将其做成动态配置的形式。并且如今实现动态配置并不难,Nacos、Apollo等配置中心,都能实现运行期间的动态配置

九:面向失败设计

这一条是针对分布式接口来写的,在实际项目中,我们总会依赖一些外部资源,这个外部可能是其他服务,也有可能是其他系统。当你的接口依赖于这些外部资源时,一定要面向失败去设计,因为所有外部资源都是不可靠的!

比如你需要在订单状态变更时,给CRM、OMS之类的外部系统推送数据,直接以同步形式调用它们的接口,这时一旦出错就有可能造成整个订单回滚,但这时你本身的业务流程没有出错呀!所以,面向失败设计的第一点,就要通过异步去解耦,避免外部错误影响核心流程,造成不可逆转的错误数据出现。

再来看个例子,你现在需要调用某个外部接口来实现业务流转,比如需要查询OMS系统的库存数据来进行校验,如果毫无保留的全面信任外部接口,就有可能因为网络波动、对端抖动等各种意外,造成预料之外的问题发生。所以,依赖外部资源时,要适当考虑加上重试机制,以及兜底的补偿机制。

外部接口调用失败,这时可以重试几次,万一是网络之类的原因,重试说不定就可以啦。重试次数达到阈值后,可以视情况来决定要不要记录异常表,方便后续执行补偿措施。

最后再来看个场景,目前有三条支付通道P1、P2、P3,业务希望的是优先走P2通道,因为它的手续费更低,那这时,你的代码应该围绕着P2通道去展开。可是,当有一天P2通道发生未知错误,导致支付一直无法拉起,咋办?如果设计时没有充分考虑,就会导致P2故障期间无法付款,最终给业务带来惨重损失。

更好的做法是啥?设计降级方案,如果P2通道故障,那可以将P2熔断,降级走P1、P3通道,尽管它们的手续费更高,可是至少能保障业务正常运转。期间不断对P2通道进行检测,一旦恢复正常就切回P2即可。

所以,这里所谓的面向失败设计,就是指对依赖的外部资源做容错设计,从而使得你的接口更健壮,但容错设计的手段有很多,如前面提到的异步解耦、失败重试、熔断降级,也包括回滚机制、失败补偿等等

十:优秀接口的黄金法则

1:处理好写入的资源

任何一个系统都不止由纯文本组成,为了更美观的页面和更好的用户交互,图片、视频、音频等资源的身影分散在每项业务中,现在来看一个最基本的需求

现在需要实现一个发布商品的需求,商品允许上传图片、视频等信息项……

好,发布商品可以简单理解成新增商品,这里需要保存图片、视频等数据,那么在定义接口入参时,自然而然会使用MultipartFile之类的文件类型,来接收用户上传的文件资源。

当然,整体的数据交互也可以先由前端上传到OSS这类文件存储中心,接口入参里接收链接即可,不过这并不重要。

重要的是什么呢?资源管理!在新增数据时,如果需要存储文件数据,大家都会将相关文件保存下来,但是当数据被编辑、删除时,却很少有人去删除一开始保存的文件资源。比如当编辑原本的数据时,用户上传了一张新图,将原本的旧图替换掉了,许多人也只会继续保存新图,而不会去删除旧图。

久而久之,系统里充斥着大量无效的文件资源,这带来了极大的资源浪费

所以,大家切记更新、删除数据时,附带性的将文件也一并删除。不过这会带来不小的开发量,很多人就算意识到了也懒得去做~

如何让文件资源合理化存储呢?大型系统中,因为用户体量大,存储文件资源的成本也很高,这时一般都会设计“文件中心”,即所有文件类型的数据,都必须先上传到文件中心,其他业务中需要用到文件时,弹出文件选择器来使用文件中心的资源,这样就能做到文件资源集中化管理

这种方式无法解决文件失去引用后带来的资源浪费,但能够将资源最大化,即一个文件可以被多次复用

2:请求排队机制

对于普通的写接口,直接执行没有任何问题,但有些场景下,单次接口调用就需要消耗海量资源,这时就需要设计排队机制,而不是实时处理,否则会发生不可控的问题。

这类场景特点就是数据基础大、资源消耗高

面对这类场景就得限制并发数,比如我目前的资源顶多支撑同时处理两个活动,那么就限制并发数为2,怎么做呢?新建活动时只存活动本身的信息,然后返回一个“创建中”的状态,内部慢慢去处理每个营销活动,确保不会挤爆有限的资源,这就是用时间换空间的思想

3:接口防重与幂等设计

系统在线上运行,期间可能会因为各种各样的情况造成重复请求,而重复请求总体可分为五类:

  • 用户重复提交:一般是指用户填写好表单信息后,由于响应较慢,从而多次点击提交按钮。
  • 非法调用:指第三方通过逆向手段调试到了接口地址,然后通过爬虫或接口工具多次调用。
  • 失败重试:指分布式项目中,被调用方出现超时或异常时,触发了调用方的重试补偿机制。
  • 重复消息:通常指引入MQ的项目,对于同一个消息,生产者多次发送,或消费者重复消费。
  • 网络故障:由于网络抖动、分区、设备故障等原因,网络恢复后会可能会重发部分请求。

而想要设计好一个写接口,防重和幂等是必须要考虑的问题

目前有一个问卷调查的需求,用户可以扫码填写问卷答案,提交问卷后会获得一个随机奖励

这个场景是一个典型的写接口,用户提交问卷答案,然后接口内部先查一次用户是否提交过,未提交就将答题数据落库存储,否则抛出异常不允许多次提交薅羊毛。

在这个例子里,如果出现重复请求会造成什么影响呢?答案是重复数据,Why?不是会先查一次用户有没有提交吗?

想要理解为什么会产生重复数据,就得代入一个请求的视角去看问题,假设用户点击提交,出现两个重复的提交请求,因为两个请求是接近同时来到后端服务的,所以去查询是否提交过时,这里自然查到的结果都为null,两个请求就会同时往库里插入数据,最终产生重复的数据出现

3.1:防重和幂等

针对前面提出的重复请求,就需要设计防重和幂等机制,但这两种机制很容易令人混淆,这里重点说明一下:

  • 防重机制:可以通过忽略、抛出错误等方式拒绝重复请求;
  • 幂等机制:多次调用接口返回结果一致,对重复请求亦是如此。

因为幂等性在数学里的定义是f(x)=f(f(x)),换到接口设计上就是:同一个接口不管调用多少次,最终执行结果都是一致的,不会因为多次调用而产生副作用,并且多次调用得到的结果完全一致,比如咱们第一次调用接口得到的结果为:

{
    "code": 200,
    "msg": "ok",
    "success": true,
    "data": null
}

那后续每次调用都必须是这个结果

如果第二个重复请求调用后,返回的是:

{
    "code": 500,
    "msg": "不允许重复请求",
    "success": false,
    "data": null
}

因为多次调用得到了不同结果,这只能说接口实现了防重,而不能说接口保证了幂等

3.2:解决重复请求的方案

怎么解决重复请求呢?可以从多个层面出发,先来看看前端处理:

  • 按钮变灰/或变为Load状态:防止用户点击多次按钮,造成多个重复请求出现。
  • 重定向页面:防止用户通过刷新/回退的方式,造成多个重复请求出现。

前端做好防抖,能在一定程度上避免重复请求出现,但网络故障导致的请求重发、非法分子的恶意调用,这种场景造成的重复请求前端解决不了,最终还是得由后端来处理,方案如下:

  • 唯一Key方案:先根据业务参数,从中选出或计算出一个全局唯一Key
    • 计算唯一key => 用到业务的特殊数据 => 订单号 或者 手机号等等
    • 得到唯一Key之后,通过set nx px命令向Redis插入数据 => 成功说明之前没有,失败说明之前已经插入过了
  • 防重表方案:使用业务的唯一ID,如订单号作为唯一索引,操作之前先插入防重表
  • 状态机方案:在表上多加一个状态字段,对于update操作加上状态判断,如订单表
    • 将「待付款」改为「待发货」:update ...,status = 2 where status = 1;
    • 这样就算出现多个修改请求,因为第一个请求改成功后,状态变为2,其他请求都会失败
  • token方案

除开后端通过逻辑去控制外,还可以基于数据库兜底,例如:

  • 唯一索引:比如问卷调查就可以对问卷ID+用户ID设计唯一索引,避免重复数据多次插入。

所以,在不同层面都有多种解决重复请求的方案,不过有些方案只适用于特殊的场景,如状态机、防重表等方案

3.3:通用防重机制设计

如果要设计一套解决重复请求的通用方案:

  • 方案一:前端重定向页面防重 + 后端唯一Key去重 + 数据库唯一索引兜底。
  • 方案二:前端按钮变灰防重 + 后端Token去重 + 数据库唯一索引兜底。

在这里插入图片描述

  1. 当用户进入一个表单时,前端通过Ajax异步调用后端提供的Token获取接口。
  2. 后端生成一个全局唯一性的Token放入Redis中,可以是UUID、SnowflakeID....
  3. 后端将生成的Token返回给前端,前端先将其保存在一个变量或Cookie中。
  4. 用户填写好表单数据后,在Post请求的头部携带Token值,接着与表单数据一起发给后端。
  5. 后端先获取头部的Token值,并尝试去Redis中删除该Token,即del [token_value]
  6. 后端根据删除命令的执行结果,进行下一步判断:
    1. 如果成功删除:表示目前请求是第一次调用接口,允许执行具体的业务逻辑
    2. 如果删除失败:表示该Token之前已经删过了,当前请求属于重复请求,应当被丢弃

4:批处理思想

有时咱们的接口内部,可能会涉及到多条数据的写操作,比如现在有一张活动表,对应的状态枚举如下

@Getter
@AllArgsConstructor
public enum ActivityStatus {
    NOT_START(1 , "未开始"),
    IN_PROGRESS(2 , "进行中"),
    END(3 , "已结束");

    private final Integer code;
    private final String name;
}

当时间到达预设的开始时间后,系统需要自动将未开始的活动推进到进行中状态,当时间达到指定的结束时间,也要自动将其改为已结束状态,怎么实现呢?

我们可以先写一个定时任务,定期去扫描活动表里未开始、进行中的数据,如:

select id,startTime,endTime,status from activity where is_deleted = 0 and status in (1,2);

然后就可以通过循环遍历查询出的数据集合,然后与系统当前时间进行对比,如果达到了开始时间、结束时间,就将对应的活动推进到特定状态。

面对这种场景时,许多人喜欢偷懒,直接在循环内部逐条更新满足条件的数据,但更好的做法是批处理

List<Activity> activitys = activityService.getActivitys();
LocalDateTime now = LocalDateTime.now();

// 提前定义需要批量更新的集合
List<Activity> updates = new ArrayList<>();
for (Activity activity : activitys) {
    // 如果开始时间大于等于当前时间,则推进到进行中
    if (activity.startTime >= now) {
        activity.setStatus(ActivityStatus.IN_PROGRESS.getCode());
        updates.add(activity);
    }
    
    // 如果结束时间小于当前时间,则推进到已结束
    if (activity.endTime < now) {
        activity.setStatus(ActivityStatus.END.getCode());
        updates.add(activity);
    }
}

// 如果等待更新的集合不为空,则批量更新数据
if (updates.size() != 0) {
    activityService.batchUpdateByIds(updates);
}

我们可以先循环找出需要更新的数据,并将其添加到updates集合,等到最后一期批量更新,从而避免多次获取数据库连接池造成的资源开销。删除、新增亦是同理,批量处理永远比逐条处理性能更好,不过要注意,如果等待处理的数量量过大,比如有几万条数据等待插入,这时要记得分批处理而并非一次性插入

5:多线程优化

前面的批处理,是优化写接口性能的一种方式,而当接口出现性能问题时,多线程技术永远是解决问题的一大利器,不过许多人对多线程的适用并不熟练,这里先来说明异步和并发(并行)的区别。

  • 异步:将对应的任务递交给其他线程后,不需要等待结果返回,可以直接对外响应;
  • 并发:通过多条线程来提升效率,提交任务的主线程需要等待结果返回,只是优化性能

结合生活来理解

比如现在我要搬一百块砖头,可是我一个人搬的太慢了,所以想着多喊几个人来帮忙,于是我找到X、Y、Z,并叫它们一起来搬砖提升效率,这时我会等它们搬完,这就是并发的概念

如果我找到X、Y、Z把搬砖任务丢给了它们,不管它们有没有搬完,然后我就自己走了,这就是异步的概念

综上,当接口写入性能较差时,咱们确实可以通过多线程来优化性能,可到底要用多线程来并发处理,还是用它来异步处理呢?这就取决于你实际的业务场景

public String writeBigData(List<Panda> pandas) {
    pandaService.writePandas(pandas);
    return "写入成功";
}

这是一个写熊猫数据的接口,假设外部传入了10W条熊猫数据需要落库,单线程处理的效率过低,这时用多线程优化,可以这么写:

public String writeBigData(List<Panda> pandas) {
    threadPool.submit(() -> {
        pandaService.writePandas(pandas);
    });
    return "写入成功";
}

这段代码中,主线程将写熊猫数据的任务,丢给线程池后立马返回了,这是典型的异步写法,再来看例子

public String writeBigData(List<Panda> pandas) {
    // 先对数据进行切分,分割为1000一批的数据
    List<List<Panda>> partitions = ListUtils.partition(pandas, 1000);
    // 定义计数器
    AtomicInteger count = new AtomicInteger(0);
    // 循环所有批次,将任务提交给线程池
    for (List<Panda> partition : partitions) {
        threadPool.submit(() -> {
            pandaService.writePandas(partition);
            count.incrementAndGet();
        });
    }
    
    // 模拟阻塞(实际要通过Future来阻塞等待执行结果)
    
    // 如果写入成功的批数,等于划分出的批数,返回写入成功
    if (count.get() == partitions.size()) {
        return "写入成功";
    }
    return "写入失败";
}

再来看这种写法,首先对传入的熊猫集合进行了分批,将数据分为多个1000条的小批次,而后遍历拆分后的批次列表,将拆分的每批数据都丢给了线程池去执行。再来看外部的主线程,任务投递给线程池后并未立马返回,而是在等待所有批次的执行结果,只有当所有批次都完成写入后,才真正向调用方返回了写入成功

6:并发安全机制

并发安全问题,就是指线程安全问题

你认为一个Controller方法是线程安全的吗?什么情况下需要考虑线程安全性问题?

一个请求能走到Controller方法时,其实已经转换成为了一条具体的线程

Controller方法本身只是接口请求的入口,没有所谓的安全不安全之说,真正决定是否存在线程安全问题的,还是得看具体的实现及业务场景

那么到底什么情况下会线程不安全呢?来看个例子:

public void joinGroup(Long groupId) {
    // 查询拼团的人数是不是已经满了
    boolean flag = groupService.groupBuyingFull(groupId);
    if (flag) {
        throw new BusinessException("拼团人数已满员!");
    }
    // 如果拼团人数未满,则加入拼团
    groupService.insertJoinGroupRecord(UserHolder.getUserId(), groupId);
}

这段代码是否存在线程安全问题呢?答案是存在的,因为后面的写操作,依赖于前面的查操作

同一时刻多条线程对共享资源进行非原子性操作,则有可能产生线程安全问题

  • 多线程(条件1):两个用户同时请求这个拼团接口,就会转变为两条线程执行;
  • 共享资源(条件2):对于两个请求而言,数据库里的拼团记录是共享可见的;
  • 非原子性操作(条件3):这里先查询、再插入,分为两步执行,并非一起执行的。

说简单一点就是方案一就是加锁,方案二在这个场景里实现不了,方案三可以理解成CAS无锁自旋,即乐观锁方案。

不过最常用的还是加锁,如果你是单体应用,则可使用synchronized关键字、ReetrantLock可重入锁这种单机锁,这里就不做重复赘述。

如果是分布式集群部署的环境,则可以使用基于Redis、Zookeeper实现的分布式锁,用起来都不难

但不管是单机锁也好,分布式锁也罢,其实核心思想都是一样的,底层的本质就是一个对所有线程可见的锁标识,谁先将其改为1就代表先拿到锁,拿到锁的线程可以先执行,执行结束后再把锁放掉,其余线程也可以继续抢占锁资源了

// OK,再来看个问题,还是前面的代码,假设这里是单体应用,现在我对其加一把锁
public void joinGroup(Long groupId) {
    // 查询拼团人数是否已满
    boolean flag = groupService.groupBuyingFull(groupId);
    if(flag) {
        throw new BusinessException("拼团人数已满员!");
    }
    // 如果拼团人数未满,则加入拼团
    synchronized(this) {
        groupService.insertJoinGroupRecord(UserHolder.getUserId(), groupId);
    }
}

因为写数据存在线程安全问题,所以我用synchronized将其包裹,这段代码有没有问题?

答案是仍然有问题,因为这里锁的范围不够,还要将前面的查询一起放进synchronized才对。

最后,还有个细节就是锁的维度,这里是基于this加锁,这时就算不同的团购拼团,也会竞争同一把锁,最终导致性能低效,怎么办才好呢?直接基于团购ID加锁就好啦

CAS乐观锁

除了传统的锁机制外,咱们还可以基于数据来实现CAS乐观锁,也就是无锁方案,以经典的扣库存为例:

public void deductionStock(int decrStock) {
    // 查询库存,如果没有库存则直接返回
    int stock = getStock(skuId);
    if (stock <= 0) {
        throw new BusinessException("库存不足!");
    }
    // 根据SkuId扣减指定的库存
    deductionStockBySkuId(skuId, decrStock);
}

上面这段代码是经典的扣库存例子,首先查询了是否还有库存,如果有则根据skuId扣减指定数量的库存,对应的SQL如下

update sku set stock = stock - #{decrStock} where sku_id = #{skuId}

而这种情况又会出现与前面相同的问题,即一个库存被多次扣除,怎么解决呢?可以基于CAS自旋来解决,代码改造成这样即可:

public void deductionStock(int decrStock) {
    for (;;) {
        // 查询库存,如果没有库存则直接返回
        int stock = getStock(skuId);
        if (stock <= 0) {
            throw new BusinessException("库存不足!");
        }
        // 根据SkuId扣减指定的库存
        int rows = deductionStockBySkuId(skuId, decrStock, stock);
        // 如果返回的受影响行数大于0,说明扣减成功则返回
        if (rows > 0) {
            break;
        }
    }
}

这段代码整体没有太大改动,总共多了三个区别,首先是扣减库存的方法多了一个参数,对应的SQL如下:

update sku set stock = stock - #{decrStock} where sku_id = #{skuId} and stock = #{stock}

扣减库存的SQL改成这样后,想要扣减库存成功,必须满足两个条件

  • 一是skuId等于目标ID

  • 二是库存等于外面查询出来的库存

主要是第二个条件,这个条件不成立则永远不会扣减成功,那什么时候会不成立呢?就是当其他线程在当前线程查询库存之后、扣减库存之前,已经更新过库存了,这时就不会成立。

好,结合这条SQL再来看外部的死循环,这里开启死循环后,说明会不断执行扣库存的逻辑,什么时候会终止呢?两种情况:

  • 返回的受影响行数大于0,说明扣减库存的SQL执行成功,循环可以终止;
  • 查询skuId对应的库存小于等于0,说明已无库存可扣,这时会抛出异常终止循环。

通过这种CAS+自旋的写法,就能保证只要有库存,就一定能够扣减成功,并且不会存在超卖的问题。

当然,如果并发较高,这种写法可能会导致大规模的自旋出现,引发CPU飙升的局面

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值