幂等,这个词来源自数学领域。幂等性衍生到软件工程中,它的语义是指:函数/接口可以使用相同的参数重复执行, 不应该影响系统状态,也不会对系统造成改变。
举一个简单的例子:正常设计的查询接口,不管调用多少次,都不会破坏当前的系统或数据,这就是一个幂等操作。
幂等的业务场景
在分布式系统中, 由于分布式天然特性的时序问题以及网络的不可靠性(机器、机架、机房故障、电缆被挖断等等), 重复请求很常见,接口幂等性设计就显得尤为重要。
幂等需要考虑的场景有很多,例如系统A是处理用户客户端发送过来的请求,无论是前端bug、脚本恶意发包、用户重复点击又或是网络超时导致的网络重发,都会造成系统A收到相同参数的网络请求。
对于处理消息队列请求的系统B和处理服务上游发送请求的系统C,也都存在网络超时导致的网络重发,所以要考虑接口的幂等性。
保障幂等性的原理
对于分布式系统来说,在JVM层面的锁已经失去作用,所以保证系统幂等性需要满足3个条件:
- 请求唯一标识:每一个请求必须有一个唯一标识。
- 处理唯一标识:每次处理完请求之后,必须有一个记录标识这个请求处理过了。
- 逻辑判断处理:每次接收请求需要进行判断之前是否处理过的逻辑处理。根据请求唯一标识查询是否存在处理唯一标识。
实际执行中要结合自身业务。
幂等性实现方案
1. token机制
针对客户端重复连续多次点击的情况,例如用户购物提交订单,提交订单的接口就可以通过token机制实现防止重复提交。
主要流程就是:
- 服务端提供生成请求token的接口。在存在幂等问题的业务执行前,向服务器获取请求token,服务器会把token保存到Redis中。
- 然后调用业务接口请求时,把请求token携带过去,一般放在请求头部。
- 服务器判断请求token是否存在redis中:存在则表示第一次请求,这时把Redis中的token删除,继续执行业务;如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不被重复执行。
这里要结合业务考虑这种场景:如果请求处理失败,前端是否需要重新申请token进行重试(因为此时token在服务端已经被删除)。
2. 数据库唯一索引
往数据库表里插入数据的时候,利用数据库的唯一索引特性,保证唯一的逻辑。唯一序列号可以是一个字段,例如订单的订单号,也可以是多字段的唯一性组合。
事务中包含多表数据的更新,业务要考虑处理事务回滚的问题。
3. Redis实现
Redis实现的方式就是将唯一序列号作为Key存入Redis,在请求处理之前,先查看Key是否存在。唯一序列号可以是一个字段,例如订单的订单号,也可以是多字段的唯一性组合。当然这里需要设置一个key的过期时间,否则Redis中会存在过多的key。具体校验流程如下图所示:
如果想要基于Redis实现幂等性防重框架,需要考虑如下两个问题:
- 如果第一次请求失败了,客户端重试,是否需要放行?
- 网络请求可能是get或者post(内部rpc协议除外),唯一序列号参数可能在url或是在body体里。则使用防重框架的新接口以及之前老业务接口能否做到版本兼容性?
建议业务使用方最好针对指定业务进行Redis的幂等方案。
Zookeeper同样也能实现上述功能,但由于Zookeeper是CP模型,性能不如Redis,另外针对防重场景,也并不需要Zookeeper高可靠性,所以优先推荐Redis。
4. ON DUPLICATE KEY UPDATE
有些业务场景是先根据索引从表中查询数据是否存在,如果存在则更新状态,不存在才插入数据。
这种情况下在并发量不大的时候没有问题,但是在高并发场景,可能会出现同时插入两条相同索引的情况,导致"Duplicate entry for key 'PRIMARY'"问题。
解决方法首先想到的当然是分布式锁。但分布式锁降低了吞吐量而且分布式锁依赖的组件,如Zookeeper或Redis如果出现网络超时,同样会影响在线服务。
所以一个简单的解决方法是使用mysql的INSERT INTO ...ON DUPLICATE KEY UPDATE语法,从而保证了接口的幂等性。
5. 状态机
对于很多业务,都存在业务流转状态的,每个状态都有前置状态以及最后的结束状态。
以订单为例,已支付的状态的前置状态只能是待支付,而取消状态的前置状态只能是待支付,通过这种状态机的流转就可以控制请求的幂等。假设当前状态是已支付,这时候如果支付接口又接收到了支付请求,则会抛异常或拒绝此次请求。
代码语言:javascript复制
public enum OrderStatusEnum {
UN_SUBMIT(0, 0, "待提交"),
UN_PADING(0, 1, "待支付"),
PAYED(1, 2, "已支付待发货"),
DELIVERING(2, 3, "已发货"),
COMPLETE(3, 4, "已完成"),
CANCEL(0, 5, "已取消"),
;
//前置状态
private int preStatus;
//状态值
private int status;
//状态描述
private String desc;
OrderStatusEnum(int preStatus, int status, String desc) {
this.preStatus = preStatus;
this.status = status;
this.desc = desc;
}
//...
}
6. MVCC方案
这个方案严格上并不是解决幂等问题,更确切来说是解决并发问题。但高并发场景下,也是一种必须的保障措施。
多版本并发控制,该策略主要使用update with condition
来保证多次外部请求调用对系统的影响是一致的。在系统设计的过程中,合理的使用乐观锁,通过version或者updateTime(timestamp)等其他条件,来做乐观锁的判断条件,这样保证更新操作即使在并发的情况下,也不会有太大的问题。例如
select * from tablename where condition=@condition //取出要更新的对象,带有版本versoin
update tableName set name=#name#,version=version 1 where version=@version
在更新的过程中利用version来防止其他操作对对象的并发更新。如果直接拒绝是不理想的操作,则服务端需要一定的事务回滚与重试机制。
7. 分布式锁
有关分布式锁的讲解,可以查看博客《一文理解分布式锁的实现方式》
分布式锁同样可以实现接口的幂等性,但由于分布式锁对系统负担来说相对要重一些,可以结合业务场景进行技术选型。
参考文档:
- https://zh.wikipedia.org/wiki/冪等