分布式系列---幂等性实现方案解析
什么是幂等
幂等(idempotence)一词原为数学上的概念,用一个最直观的数学式子表达为:
f(f(x)) = f(x)
对应到软件开发领域,即为同样的请求被执行一次与连续执行多次的效果是一样的,服务器的状态也是一样的,实际上就是接口的可重复调用
(包括时间和空间上两个维度)。
不是要求返回值完全相同,而且是指后续多余的调用对系统的数据一致性不造成破坏。对于写入类操作,如果第一次写入是成功的,后续的写入应该抛出异常或者空操作,或者执行了写入但是未对数据造成变化。对于读取类操作,需要保证其实现上是真正的读取,不能在读操作中夹带写操作。
实现方案
简单场景
查询select
查询数据时,无论是查询单条数据,还是查询多条数据,数据返回结果都不会变,select是天然的幂等操作。
删除delete
删除操作也是幂等的,无论删除一条,还是删除多条数据,目的是将数据删除,当被删除当数据再次执行,结果也是一样的。
唯一索引/主键
防止新增脏数据
当设置要新增的字段为唯一索引,或这个字段与另外的字段是组合索引时,当请求参数,新增字段一致时,数据库会通过索引机制视为失效。
例如:数据来源方调用API进行同步数据,必须传递sourceId(来源)、seq(来源***),API提供方,在接收数据之后用这两个字段作为联合索引插入到数据库,每次调用API的时候,先用这两个字段进行校验,保证不重复,这里来源***的生成方式可参考雪花算法或者其它唯一主键生成算法。
token机制
相同请求只允许提交一次。
实现思路
集群环境:
采用token加Redis(redis单线程,处理需要排队)。
单JVM进程:
采用token加redis或token加jvm内存。
实现流程:
主要使用RedisLock —— redis.setnx函数,所以我们要让请求生成统一的redisKey来存储,在这里,我们认为token即是redisKey。
首次请求服务器时,服务器根据请求参数生成唯一的token(redisKey)
将这个唯一的key保存在redis或者jvm内存中(在使用JVM内存情况下),并设置key有效时间(根据业务设计)
服务器再次接收请求时,生成规则不变(根据请求参数生成唯一的redisKey),然后先根据redisKey删除redis中的对象,如果删除成功代表请求内容相同。
上面的例子适用两次请求,当发生3次并发,删除会失败,然后又创建一个相同的线程进行数据持久化处理。
select + insert/update
并发不高的后台系统,或一些任务调度系统(job),可以先查询,在更新操作,以此完成业务操作。
注意:高并发场景这种方式并不适用。
复杂场景
悲观锁
select - for update
示例:
select column_1,column_2....column_N from table where id = 'xxx' for update;
注意:id一定要是主键或者唯一索引,否则锁表会发生错误!!!!
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,实际根据业务设定
乐观锁MVCC(多版本并发控制)
乐观锁相对于悲观锁效率更高,因为乐观锁只有在更新表的时候会锁表,其他时候不会锁表,所以效率更高。
乐观锁实现方式
1. 通过version
UPDATE TABLE_NAME SET NAME=#name#, VERSION=VERSION+1 WHERE VERSION =#version#
2. 通过条件限制
UPDATE TABLE_NAME SET AVAI_AMOUNT=AVAI_AMOUNT-#SUBAMOUNT# WHERE AVAI_AMOUNT-#SUBAMOUNT# >= 0
条件AVAI_AMOUNT-#SUBAMOUNT# >= 0 ,这个情景,适用于不用版本号,只更新做数据安全校验,适合校验库存模型(因为库存不会小于等于0)、扣分额、回滚份额等,这个效率更高。
注意 : 乐观锁更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表
根据“注意”中的提示,上面的两条sql,可优化成下面的样子
-- 添加where id=
UPDATE TABLE_NAME SET NAME=#name#, VERSION=VERSION+1 WHERE ID=#id# AND VERSION =#version#
-- 添加where id=
UPDATE TABLE_NAME SET AVAI_AMOUNT=AVAI_AMOUNT-#subAmount# WHERE ID=#id# AND AVAI_AMOUNT-#subAmount# >= 0
分布式锁
可参考分布式事务所的实现和设计思想,分布式锁在分布式多进程环境下时,会生成一个唯一“key”(redis和zookeeper都可实现),后续相同操作这个锁是不一致的,在比较长业务扭转中(处理业务较多的场景时),分布式锁可以锁住一整套业务对数据的更新操作,然后提交,失败回滚。
分布式锁的具体实现方案,参见如下:
框架精粹系列3---分布式锁:ZooKeeper实现原理刨析
框架精粹系列9---分布式锁:Mysql实现分布式锁原理分析
去重表
这是利用数据库表单的特性来实现幂等。以订单请求支付场景为例:
将订单号orderId设为去重表的唯一索引,每次请求支付都根据订单号向去重表中插入一条数据,只有插入成功才继续执行支付操作,相当于在事务的开始阶段加锁。
考虑两种失败的情况:
- Insert去重表失败,事务回滚,无任何影响;
- Insert去重表成功,支付业务操作失败,事务回滚,删除之前插入去重表的记录,无任何影响;
以上两种失败的情况下,事务的幂等性是可以保持的,避免了单个订单同时多次进行支付的情况。
下图为该支付场景下的时序图: