幂等设计 - 每日任务领奖

9/14/2022

幂等是分布式业务中极为核心的设计思想;

用幂等实现分布式事务的方案,适用场景应当满足如下两个条件:

  1. 业务最终必定成功,不需要回滚
  2. 业务自带重试的场景

# A.场景

王者荣耀完成每日任务,点击领奖按钮,会同时发放金币、钻石、经验。

金币、经验、钻石分属不同的系统,需要调用他们的HTTP接口进行发奖操作。

需求:设计一个领奖接口,保证奖励能发到位,并且不会重放。

# B.幂等

这道题一个关键点在于幂等设计,不是数学上的幂等,是分布式事务中的幂等设计。

网上有些言论称 ”form重复提交“ ”get是幂等“,”select是幂等/update不是幂等“,这些都只是表象。

分布式中的幂等设计是:任意多次执行所产生的影响均与一次执行的影响相同

这句话网上搜来的,可以有很多延伸意思:

  • 一个插入操作,无论执行多少次都只插入一条,这算幂等
  • 一个自增的更新操作,无论执行多少次都只自增一次,这算幂等

# 1.做到幂等

实现幂等需要思考2个问题:

  1. 如何确定某事务是重复的操作?
  2. 如何保证操作只进行一次?

问题的答案是幂等三原则

  1. 事务标识:想判断是不是重复操作,就要有一个能唯一确定某个事务的标识
  2. 执行标识:想保证只操作一次,那就要记录一个标识,标记这个事务执行过了
  3. 原子操作:标识的记录和事务的执行必须是原子的

第3点是极其关键的:要么都成功,要么都不成功!

此外,还可以延伸一下:如果A操作是幂等的,B操作也是幂等的,那么一个事务里如果只包含A操作和B操作,那它也是幂等的。

# 2.分布式锁是不是幂等

分布式锁一般是防并发的,最多算幂等设计的一部分。有时候甚至一部分都算不上。

我们可以预想一下操作步骤:

  1. 尝试加锁。没抢到锁退出;抢到后继续步骤2。
  2. 检查事务是否被处理
  3. 处理事务
  4. 修改事务处理状态
  5. 成功或出现异常后解锁

这里违背了幂等三原则第三条:原子操作

步骤3和4不是原子的:假如第3步执行后,程序故障,4执行不了。那么下次请求过来,就会造成重复处理。

如果缓过来先执行步骤4呢?

假如先修改状态,再处理事务,中间程序故障,就会出现状态修改了,但是事务没处理的情况。

但是,如果步骤2,3,,4能合并成原子操作,甚至不加分布式锁也可以。

# C.错误方案

# 1.通过Redis记录发放状态

这个违背了幂等第3原则:原子性。

记录状态依赖Redis,而发放奖励是接口调用;Redis的操作和HTTP请求不可能是原子的,只要不是原子操作,就可能会出现不一致

无论是先发奖再记录状态,还是先记录状态再发奖,只要两个操作中间遇到网络等硬件问题,这就变糊涂账了:

  • 要么是标记发了,其实没发
  • 要么是标记没发,其实发成功了,发了两笔

# 2.异步队列

这个方案不一定不能用,但是如果用这个方案能设计出可行的,一般也能用到同步的方式上。

考虑消费队列大多出于两个出发点:

  1. 避免并发
  2. 支持失败重试

其实对于出发点1,队列的消费一般是并发进行的,很少一个队列只有一个消费端。

假如同样的事务入队两次,那并发的场景是避免不了的。

对于出发点2,则要思考如何界定失败?

以Redis增加计数为例:如果incr操作成功,但是因为网络原因没收到响应。此时应该算为消费失败,然后再次消费,那就会造成重复计数。

所以仅依赖异步队列,依然无法实现幂等。

而且消息队列反而要处理消息丢失的情况

# 3.错误的事务唯一标识

问:“如何生成事务的唯一标识”

答:“雪花算法生成一个唯一id来当做这个事务的id”

这种回答是并没有真正理解“唯一标识”的作用。此唯一并非彼唯一

假如用户点击按钮领奖时,每次请求过来都生成一个 uuid,的确是唯一了,但是并没有任何作用

这里的唯一,是指能唯一确定一个事务的几个元素,需要是是一个唯一且固定的标识。

在这个领奖的案例中,“用户3月8日领每日任务”,能唯一确定这个事务的元素应该是:

  • 日期:0308
  • 用户 uid
  • 每日任务:taskId

无论该用户今天点多少次按钮,每次请求都是这个唯一标识<date>:<uid><taskId>,这才是这个事务的唯一标识。

# D.正确的方案

解决这个问题核心点有两个:

  • 确定当前事务的唯一标识
  • 要求下游接口幂等

下游接口的幂等设计才是关键点。

# 1.唯一标识

无论用户今天点多少次按钮,每次请求都是这个唯一标识<date>:<uid><taskId>

假设 20220309 领奖,假设uid是 123,假设taskId是 456

那么这个事务的唯一标识 txId 就是 20220309:123:456。中间要用分隔符隔开,防止跟 uid为12,完成3456任务的事件冲突。

# 2.下游接口的幂等

我们在请求下游发奖接口的时候,发金币、经验、钻石的时候,都要求其必须是幂等的。这样哪怕中间断了,我们也可以让用户重试来解决。

领奖接口的唯一标识也是调用下游接口的唯一标识,充其量添加一些标识,比如

  • 金币:20220309:123:456:coin
  • 经验:20220309:123:456:exp

那么下游接口如何保证幂等?

答案只能是数据库事务,Redis事务和MySQL事务都可以。由此可以得出:

分布式事务离不开本地事务

# 3.流程

用户完成任务这条记录的状态机中应当包含两个状态:已完成未领奖 和 已领奖

  • 请求到来,为了减少数据库压力,可以加一个 redis 分布式锁(但不是必须的)
  • 查询数据库中的状态是否已完成,已领奖。
  • 已完成未领奖的依次调用接口,有一个失败或超时就直接报错返回。
  • 所有的都成功,将状态改为已领奖

# E.小结

  • 由于金币、经验、钻石都属于无限资源,因此最终必定能发成功,所以符合条件1;
  • 如果用户领奖失败,用户会自己点击按钮领奖,所以属于业务自带重试场景,符合条件2;