【学习笔记】:分布式系统中业务的幂等性处理
一、什么是幂等性
幂等是一个数学与计算机学概念。数学中,幂等函数,是指可以使用相同参数重复执行,并能获得相同结果的函数 f(x)=f(f(x))。计算机编程中,幂等操作是指对于同一个操作,用户多次执行所产生的结果均与第一次执行的结果相同,也即同一操作的多次执行并不会对输出结果产生改变。业务中有很多天然幂等的逻辑,如:数据库select查询操作,根据唯一主键进行update、delete操作等。
二、需要做幂等性处理的业务场景
2.1、表单的重复提交
用户订单信息、贷款业务申请信息等表单创建完成后,点击提交,可能会因网络波动没有及时对用户做出提交成功的响应,致使用户认为没有成功提交,然后重复点击提交按钮,这时就会发生表单信息的重复提交。
2.2、接口的重复请求
和表单重复提交相似,如订单支付功能、余额转账功能等业务,可能因为后端涉及的逻辑较多,导致业务执行时间比较长,然后用户看不到成功结果误以为支付失败,页面回退后重新支付或转账,出现连续重复支付的情况。
2.3、消息的重复消费
当使用RabbitMQ等消息中间件时候,如果因为网络原因导致消费者接收消息后没有回执中间件,或者中间件没有接收到消费者发送的回执信息,就可能会导致消息的重复消费。
三、幂等性处理解决方案
3.1、前端防重复提交处理
在表单提交或者订单支付等操作点击确认后,设置按钮为不可点击状态,并在页面显示一个正在处理的加载动画,当业务执行完成后,重定向到操作成功界面,若业务执行失败,提示错误原因,并初始化表单数据。
3.2、防重唯一令牌机制
3.2.1、简要描述
以下单功能为例:
①、在商品界面点击“立即购买”或者在购物车界面点击“立即结算”,这时会调用接口,根据用户选择的商品信息,生成订单信息。在生成订单信息的过程中,我们可以在后端代码中,生成一个UUID作为当前订单的唯一令牌一同返回至前端,并将该UUID以用户id或订单编号id为key保存到redis缓存中。
②、前端收到订单令牌后,将它写入表单的隐藏字段中,在用户确认订单无误后,点击提交,后端会收到提交请求中携带的令牌信息。
③、将请求中的令牌信息和缓存中的获取到令牌信息进行比较,如果一致,代表该请求为首次请求,删除缓存中的令牌后,继续执行后续的业务代码。
④、业务执行完成后,给前端返回操作成功的响应。业务流程执行完毕。
3.2.2、流程图
需要注意的几点:
1、业务逻辑执行前删除 token 还是后删除 token?
(1) 、先删除可能导致,比对成功后删除了token,但是业务由于某种原因没执行完,重试过程仍带上之前的token,因为防重设计导致后续比对不通过,提交失败。
(2)、 后删除可能导致,业务处理成功后,由于某种原因服务闪断,没有删除token,并且浏览器响应超时,用户继续重试,导致提交业务被重复执行多遍。
(3)、 我们最好设计为先删除 token,如果业务调用失败,就重新获取token再次请求。
2、Token获取、比较和删除必须是原子性
在上述流程中第8步令牌校验的过程中,令牌的获取、比对和删除三个步骤是在同一步操作中完成的,也即是要保存这三个步骤整体的原子性。如果分开操作,在并发环境下,会存在并发安全问题,所以代码中使用脚本来完成整个令牌校验过程:“if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end”。
3.2.3、代码
/**
* 生成订单页
*/
@Override
public OrderConfirmVo confirmOrder() {
OrderConfirmVo confirmVo = new OrderConfirmVo();
// 执行订单逻辑
......
// 生成防重令牌
String orderToken = UUID.randomUUID().toString().replace("-", "");
confirmVo.setOrderToken(orderToken);
String key = OrderConstant.USER_ORDER_TOKEN + memberVo.getId();
redisTemplate.opsForValue().set(key, orderToken, 30, TimeUnit.MINUTES);
return confirmVo;
}
/**
* 订单提交
*/
@Transactional
@Override
public SubmitOrderRespVo submitOrder(OrderSubmitVo vo) {
SubmitOrderRespVo respVo = new SubmitOrderRespVo();
MemberVo memberVo = LoginUserInterceptor.loginUser.get();
submitVoThreadLocal.set(vo);
// 验证防重令牌(原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN + memberVo.getId()),
vo.getOrderToken());
if (result != 0l) {
// 校验通过,保存订单信息
......
} else {
// 校验不通过,返回失败状态码
respVo.setCode(OrderConstant.ORDER_TOKEN_CHECK_FAIL);
}
return respVo;
}
3.3、数据库层面实现幂等性
3.3.1、数据库唯一约束
在数据库执行insert操作时,可以通过指定唯一索引进行插入,比如订单号、业务流水号等。不能有两条订单号相同的订单同时保存到数据库。
使用数据库唯一约束实现幂等性处理时需要注意的是,该主键一般来说并不是使用数据库中自增主键,因为分布式或者大数据的场景下,可能需要进行分库分表操作,不同的数据库和表主键不相关。
此时可以考虑使用 UUID 或者雪花算法等方式生成一个唯一的编号作为订单号,这样才能保证在分布式环境下 ID 的全局唯一性。
3.3.2、数据库悲观锁
select * from xxxx where id = 1 for update;
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会
非常麻烦。
3.3.3、数据库乐观锁
这种方法适合在更新的场景中:
update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1
根据 version 版本,也就是在操作库存前先获取当前商品的 version 版本号,然后操作的时候
带上此 version 号。我们梳理下,我们第一次操作库存时,得到 version 为 1,调用库存服务
version 变成了 2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订
单服务传如的 version 还是 1,再执行上面的 sql 语句时,就不会执行;因为 version 已经变
为 2 了,where 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。
乐观锁主要使用于处理读多写少的问题