接口幂等性原理与方案总结
文章目录
- 接口幂等概念
- 典型场景
- 核心解决方案
- 一锁
- 二判
- 三更新
- 方案选型对比
接口幂等概念
定义:无论调用接口多少次,对系统的影响与单次调用一样
范畴:在后端开发中,通常更关注写接口的幂等,因为写接口才会对系统数据造成不良影响;读接口多次调用,我们作为下游通常不好控制,读取到的数据不一样也更多是数据实时性导致的问题
典型场景
需要关注幂等性主要由于现代分布式系统面临三大不可靠因素:
- 用户不可靠(手抖多点)
用户在表单页多次点击按钮,前端提交了多次创建订单的请求 - 网络不可靠(超时重传)
当接口超时的时候,由网关层或者业务层控制重试次数,比方说第三方支付接口超时后通过指数回退算法计算重试间隔再重试 - 系统不可靠(服务重试)
MQ 中间件消费消息的时候,由于 Rebalance 或者消费失败重试等原因,同样的消息在被旧的消费者处理过后,有可能被再次分配到新的消费者身上继续处理
核心解决方案
核心流程可以总结为:一锁二判三更新,这三步每一步涉及到的方案会有分布式锁、乐观锁;Token机制、唯一索引、状态机、请求序列号等。
伪代码如下:
// 1. 一锁: 加上悲观锁或者乐观锁
Lock.lock();
try {// 2. 二判:判断请求是否已经被执行过Order order = orderService.queryOrder();if (order.hasSucceeded) {return;}// 3. 三更新:执行更新业务逻辑order.update();return;
} finally {Lock.unlock();
}
很多文章会将分布式锁、乐观锁单独拿出来作为一种幂等的方案,我觉得这样理解有失偏颇,因为锁本质上只是为了保证程序原子化互斥执行的手段,本身就不是专门用来保证幂等的,在这三步里就是为了保证「二判」、「三更新」这两步的并发安全。举个极端的例子理解,Redis 锁确实在一定程度上保证了并发安全情况下的幂等,两个相同的资源请求同时进来的时候,只有一个请求能够竞争到锁,另一个请求直接获取不到锁就不阻塞返回了;但假设这两个请求错开进入系统,此时不存在锁的竞争,这个时候两个请求就都能执行了,如果是创建订单就创建了多笔订单,仍然没有达到幂等。所以将锁作为「高并发」场景下保证幂等的一种手段,但不是实现幂等的通用范式。
一锁
锁主要是为了保证并发场景下「二判」、「三更新」这两步的并发安全,因为不锁的话有可能多个线程都没有用最新值去判断,如果评估到业务场景下并发确实很低,其实这一步也可以省略。锁的话就有多种实现方案了,悲观锁或者乐观锁,但是一定要是互斥锁。
- 分布式悲观锁
分布式锁的实现方式也比较多 Redis、Zk、MySQL,但业界主流的实现方式还是 Redis, 主要还是考虑到 Redis 的高性能、AP 高可用,通过非阻塞的方式实现并发校验。
- 数据库悲观锁
开启事务并对数据库中的记录加上排他行锁,其他事务必须等待本次事务提交后才能执行,同时需要记得行锁都是基于索引的,如果不加索引可能会导致锁表的不良后果。伪代码如下:
// 1. 开启事务
begin;
// 2. 查询出商品信息并加行锁
select quantity from items where id = 1 for update;
// 3. 修改商品信息
update items set quantity = 2 where id = 1;
// 4. 提交事务
commit;
数据库悲观锁效率低,串行执行,更新失败概率低,适用于并发写入比较频繁的场景。
- 数据库乐观锁
并未显示加锁,通过版本号 version 实现 CAS 机制
// 1. 查询原版本号
select quantity, origin_version from item where id = 1;
// 2. CAS 更新
update item set quantity = 2 and version = origin_version + 1 where id = 1 and version = origin_version
数据库乐观锁在读多写少的场景效率高,一旦放到并发冲突比较多的场景下或者锁的粒度没有掌控好,更新失败的概率就会变高
二判
判断这一步主要是进行幂等判断,Token机制、业务字段、请求序列号(前三种其实都是生成业务幂等号的方式)、唯一索引、状态机、都能实现类似的功能
- 幂等号 - Token 机制
常用在防重复下单的场景,用户每次访问页面时都先向后端请求一个 token,之后在本页面的操作都需要将此 token 带过来,页面不刷新 token 也不变。
Token 生成:
String token = UUID.randomUUID().toString();
jedis.setex(token, 60 * 60, "1");
jedis.close();
Token 校验,del 会返回被删除 key 的数量,返回1代表删除了1条,一个操作来保证原子
Transaction tx = jedis.multi();
if (tx.del(token) == 1) {// 成功
} else {// 失败
}
jedis.close();
- 幂等号 - 业务字段
常用在重复消费的场景下,上下游约定好一个幂等字段的生成方式,通过特定业务字段的拼接传递给下游,供下游做判断
- 幂等号 - 请求序列号
通过操作流水来做幂等,常用在金融系统中,或者有流水记录的业务系统中
- 唯一索引
这一步是最终兜底方案,通过数据库的唯一索引充当最后的防线
CREATE TABLE orders (id BIGINT PRIMARY KEY,order_no VARCHAR(32) UNIQUE,...
);
异常处理示例:
try {orderDao.insert(order);
} catch (DuplicateKeyException e) {log.warn("重复订单:{}", order.getOrderNo());return Result.error("订单已存在");
}
- 状态机(业务流程控制)
碰到这种多状态的实体一定要设计好状态流转
Order order = orderService.queryOrder();
if (order.status != init) {return;
} else {order.setStatus(finished);
}
三更新
这没有什么好说的,就是在第二步判断为首次操作的情况下,更新数据库状态
方案选型对比
- 并发较低的情况下不必要上锁
- 请求序列号可靠性最高,但是实现复杂度也高;状态机流转适合多状态业务,实现复杂度适中。