(十三)漫谈分布式之接口设计下篇:设计一个优秀写接口的13条黄金法则!

3,023 阅读23分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

引言

在前面《写好一个接口需要考虑什么?》这篇文章里,我们已经初步讲述了写好接口要考虑的几个要素,而本文则是上篇内容的延续,本文会对“如何写好一个写接口”做更加全面的补充,内容会涵盖资源利用、接口性能、接口安全、数据一致性、大数据处理……等多方面内容。

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、处理好写入的资源

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

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

好,发布商品可以简单理解成新增商品,这里需要保存图片、视频等数据,那么在定义接口入参时,自然而然会使用MultipartFile之类的文件类型,来接收用户上传的文件资源。当然,整体的数据交互也可以先由前端上传到OSS这类文件存储中心,接口入参里接收链接即可,不过这并不重要。

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

久而久之,系统里充斥着大量无效的文件资源,这带来了极大的资源浪费,所以,大家切记更新、删除数据时,附带性的将文件也一并删除。不过这会带来不小的开发量,很多人就算意识到了也懒得去做~

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

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

二、请求排队机制

对于普通的写接口,直接执行没有任何问题,但有些场景下,单次接口调用就需要消耗海量资源,这时就需要设计排队机制,而不是实时处理,否则会发生不可控的问题。这么说许多人或许不理解,那么结合例子一起来看:

之前有个项目用户体量不算小(5000W+),而品牌方时不时会做各种营销活动,新建营销活动时,可以根据用户标签去圈选目标群体,同时营销活动需要支持用户粒度的触达反馈。因此,每次新建一个活动,随意勾选几个用户标签,都会圈中数百万用户,而这些活动用户也需要一并存储。

上面这个场景,是一个典型的大数据场景,虽然新建活动只是简单勾了几个标签,可系统内部实际会出现数百万乃至上千万的数据处理,这样的动作极其耗费资源。最开始上线版本中,我们尽管考虑到了大数据量的场景,但未结合真实使用场景一起去思考,于是运营们就表演了一波骚操作,几个人同时新建了好几个活动,结果就是服务器没抗住,CPU、内存直接打满直到宕机……

这个例子的问题在哪儿?没有控制并发请求,新建一个活动就处理一个活动,几个活动一起处理,同时有几千万用户数据,需要添加进不同的营销活动,最终导致了服务器意外“身亡”。与该场景类似的还有数据跑批、大报表处理等,特点就是数据基础大、资源消耗高

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

三、接口防重与幂等设计

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

  • ①用户重复提交:一般是指用户填写好表单信息后,由于响应较慢,从而多次点击提交按钮。
  • ②非法调用:指第三方通过逆向手段调试到了接口地址,然后通过爬虫或接口工具多次调用。
  • ③失败重试:指分布式项目中,被调用方出现超时或异常时,触发了调用方的重试补偿机制。
  • ④重复消息:通常指引入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
      • 通过Hash函数来对所有参数进行哈希计算,得到一个Key
      • 非注册的场景,可以使用当前用户ID+目标方法名作为Key
      • .....(这里只要能得到一个与业务相关的唯一Key即可)。
    • 得到唯一Key之后,通过set nx px命令向Redis插入数据:
      • 成功:代表前面没有重复的请求,当前请求可以执行。
      • 失败:代表前面有相同请求已经插入过了,当前请求需要被丢弃。
  • ②防重表方案:使用业务的唯一ID,如订单号作为唯一索引,操作之前先插入防重表。
  • ③状态机方案:在表上多加一个状态字段,对于update操作加上状态判断,如订单表:
    • 将「待付款」改为「待发货」:update ...,status = 2 where status = 1;
    • 这样就算出现多个修改请求,因为第一个请求改成功后,状态变为2,其他请求都会失败。
  • Token方案:内容较多,后面聊。

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

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

所以,在不同层面都有多种解决重复请求的方案,不过有些方案只适用于特殊的场景,如状态机、防重表等方案,如果要设计一套解决重复请求的通用方案,又该怎么办呢?

3.3、通用防重机制设计

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

通过上述这两套组合方案,任选其一都能够打造出一套解决幂等问题的通用策略,但其中唯一没展开讲解的则是Token方案,这种方式到底是如何实现的呢?下面展开聊一聊,示意图如下:

Token方案

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

上述即是前面所说的Token方案,整个过程会出现两个请求,第一个请求是异步获取Token,第二个请求则是具体的业务请求,最后会基于业务请求上携带的Token值,以此作为重复请求的判断条件,从而避免同时处理多个重复的请求。

不过这种方案有个问题,还是以前面的问卷调查作为例子,如果我同时开两个问卷调查页面,并且填写完全相同的结果,这也是一种特殊的重复请求,而这时两个页面会获取两个Token,最终导致上述方案失效。

四、批处理思想

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

@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集合,等到最后一期批量更新,从而避免多次获取数据库连接池造成的资源开销。删除、新增亦是同理,批量处理永远比逐条处理性能更好,不过要注意,如果等待处理的数量量过大,比如有几万条数据等待插入,这时要记得分批处理而并非一次性插入~

五、多线程优化

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

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

异步和并发两个概念彼此兼容,所以许多人有点犯迷糊,结合生活来理解,比如现在我要搬一百块砖头,可是我一个人搬的太慢了,所以想着多喊几个人来帮忙,于是我找到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条的小批次,而后遍历拆分后的批次列表,将拆分的每批数据都丢给了线程池去执行。再来看外部的主线程,任务投递给线程池后并未立马返回,而是在等待所有批次的执行结果,只有当所有批次都完成写入后,才真正向调用方返回了写入成功。

通过上面的案例,我们演示了多线程异步和并发的用法,具体的场景中诸位要用哪种方式,可以结合业务场景来做抉择。

六、并发安全机制

并发安全问题,就是指线程安全问题,从我多次面试的反馈中能够深刻感受到,这块是很多人较为薄弱的点,对于大多数面试我都会问候选人一个问题:

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

这个问题的前半句,是在考察对网络模型的理解程度,在之前的《一个请求的网络之旅》中曾提到过,一个请求能走到Controller方法时,其实已经转换成为了一条具体的线程,Controller方法本身只是接口请求的入口,没有所谓的安全不安全之说,真正决定是否存在线程安全问题的,还是得看具体的实现及业务场景。

再看这个问题的后半句,什么情况下需要考虑线程安全问题?我听到过最多的回答就是下单扣库存,每当我让候选人换一个例子的时候,多数人就很难继续回答了,那么到底什么情况下会线程不安全呢?来看个例子:

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

这是一段极其简单的伪代码,首先会根据团ID去查询是否已满员,如果满员则返回拼团失败,反之则插入拼团记录返回拼团成功。请问各位小伙伴,这段代码是否存在线程安全问题呢?大家可以认真思考片刻……

答案是存在,Why?很简单,因为后面的写操作,依赖于前面的查操作,而之前并发编程专栏曾提到过一个定律:同一时刻多条线程对共享资源进行非原子性操作,则有可能产生线程安全问题,而这个例子恰恰满足条件,拆开分析下:

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

综上,这个场景完全符合线程安全问题的产生背景,比如目前”小竹搬砖团“还剩最后一个名额,两个用户同时申请加入该团,代表两个请求几乎是同时过来的,那么在执行”拼团人数是否已满查询“时,这时看到的结果都是false,因为此时最后一个名额还在,然后两个请求都会执行insertJoinGroupRecord()方法,最终导致最后一个名额被两人同时拿到。

那该如何解决这个问题呢?打破构成线程不安全的三个条件即可:

  • ①破坏多线程条件:同一时刻,只允许一条线程对共享资源进行非原子性操作;
  • ②破坏共享资源条件:同一时刻多条线程对局部资源进行非原子性操作;
  • ③破坏非原子性条件:同一时刻多条线程对共享资源进行原子性操作。

说简单一点就是方案一就是加锁,方案二在这个场景里实现不了,方案三可以理解成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加锁就好啦,不过里面有些细节坑,不了解的可参考前面给出的文章链接。

6.1、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飙升的局面。

七、未完待续

按原有规划,本篇还剩下数据安全与脱敏、事务机制、MQ解耦削峰、留痕机制、高增速数据处理、异步落库方案等多个话题未聊,这些会在短期内抽空补齐~