幂等性
1.1幂等性定义:
在计算机领域中,幂等(Idempotence)是指任意一个操作的多次执行总是能获得相同的结果,不会对系统状态产生额外影响。在Java后端开发中,幂等性的实现通常通过确保方法或服务调用的结果具有确定性,无论调用次数如何,结果都是可预期的。
1.2注意:
在实际的互联网服务开发中,幂等性的理论定义与业务逻辑间的冲突是常见的。
例如,考虑查询操作,当A系统调用B系统的查询接口时,如果首次调用由于B系统中的程序错误而导致业务逻辑失败,即使在程序修复后系统A重新使用相同参数进行重试,B系统可能仍然返回相同的失败响应。尽管这符合幂等性的定义,却与实际业务逻辑不符。同样,以订单支付为例,首次调用由于账户余额不足而返回“余额不足”提示,用户充值后再次使用相同参数发起支付请求,服务仍然返回“余额不足”响应,也符合幂等性的定义,但同样不符合业务逻辑。
因此,在实现幂等性方案时,应该遵循幂等性方案的目标,而不仅仅是严格遵循幂等性的定义。尤其是涉及写操作的服务,应当更关注防止重复请求带来的不良副作用,例如重复扣款或退款。
1.3 什么情况下会出现幂等?
在微服务和分布式架构中,一个请求可能需要多个服务协作才能完成。在这个过程中,网络抖动、系统运行异常等不确定因素使得请求的成功率不可能达到100%,一旦发生失败或未知异常,最常见的处理方式就是重试,而重试必然会导致重复请求问题。
幂等设计主要是为了处理重复请求而生的,好的幂等方案可以保证重复请求获得预期结果,而不产生副作用。
用户不可靠: 用户通过客户端发起请求,由于手抖或有意重复点击,很容易造成导致极短时间内发起多次重复请求。
网络不可靠:网络抖动、网关内部抖动有可能触发重试机制,这个在使用消息队列投递消息时经常会遇到。MQ 消息中间件,消息重复消费;
服务不可靠: 在需要保证数据一致性的场景中,如果调用下游服务超时,在无法确认执行结果的情况下,常用的处理方法是重试。比如:前端调用后端接口发起支付超时,然后再次发起重试,可能会导致多次支付。
1.3 幂等与并发的关系
在具有并发写操作的场景下,通常需要考虑幂等问题。例如,当用户在极短时间内多次提交表单或者使用特殊手段同时提交多个表单时,这就是典型的并发场景,需要进行幂等性处理。为了防止重复请求被执行,服务端需要实施幂等性控制,以避免产生不符合预期的结果。
虽然并发场景大都存在幂等问题,但幂等问题却并非并发场景所特有。幂等设计是为了识别并处理重复请求,而并发仅仅是重复请求的一种特殊情况。 事实上,只要重复请求涉及写操作,无论是否并发,都需要做好幂等处理。举个例子,用户在pc端同时开了两个窗口,间隔10分钟分别提交表单,所有参数完全相同,这显然不属于并发,但仍需要进行幂等处理。
二、幂等性解决方案
这些方案的技术路线可以总结成三条:唯一索引、唯一数据、状态机约束。
- 唯一索引是指数据库主键、唯一索引,唯一索引大部分是基于业务流水表建立,也可单独建表实现;
- 唯一数据是指悲观锁、乐观锁、分布式锁等机制;
- 状态机约束,对于存在状态流转的业务,通过状态机的流转约束,可以实现有限状态机的幂等。
在实际开发中,单独使用这些方法往往效果有限,需要根据具体的业务场景灵活选择、合理的运用上述实现方法。
- 数据库:乐观锁、悲观锁、唯一主键、唯一索引
- 业务层:分布式锁、下游传递唯一序列号
- Token令牌
- 状态机
2.1 方案一:数据库唯一主键实现幂等性
缺点:无法使用change buffer,InnoDB为了进行唯一性检查,必须有一次磁盘IO读页
2.1.1 方案一延伸:唯一索引方案机制
唯一索引方案依赖于数据库表中不允许存在具有相同索引值的重复行。这种策略在关系型数据库中广泛支持,并且能有效利用唯一性约束来确保幂等性。 在高并发场景中,唯一索引能保证当多个线程尝试同时插入相同记录时,只有一个线程能成功执行,而其他线程将会因违反唯一性约束而抛出异常。
通常,业务流水表的建立是基于以下核心字段:
id(bigint 类型):作为主键,唯一标识每条记录。
gmt_create(datetime 类型):记录的创建时间。
gmt_modified(datetime 类型):记录的最后修改时间。
user_id(varchar(32) 类型):用户ID,这个字段也可以作为分表的依据。
out_biz_no(varchar(64) 类型):外部业务流水号,即调用方的幂等号。
biz_no(varchar(64) 类型):内部业务流水号,用于系统内部追踪。
status(char(1) 类型):记录执行状态。
在这种设计中,user_id和out_biz_no通常会组合成一个联合索引,这样做能有效避免在并发情况下的数据重复插入问题,从而保障了业务操作的幂等性。
2.2 方案二:数据库乐观锁实现幂等性
乐观锁主要依靠带条件更新 来确保多次外部请求的一致性。在系统设计中,可以在数据表中添加版本号字段,用于标识当前数据的版本。每次对该数据表的记录进行更新时,都需要提供上一次更新的版本号,示例操作如下:
//1. 取出要更新的对象,带有版本versoin
select * from tablename where id = xxx//2. 更新数据
update tableName set sq = sq-#{quantity},version = #{version}+1 where id = xxx and version=#{version}
特点:乐观锁主要适用于更新场景,确保多次更新不会影响结果的一致性。
缺点:操作业务前,需要先查询出当前的version版本。会增加操作
2.3方案三:数据库悲观锁机制
悲观锁依赖数据库提供的锁机制来实现,整个数据处理过程中,数据处于锁定状态,并与事务机制配合,能够有效实现业务幂等性。操作示例如下:
// 1. 开启事务
begin;
// 2. 基于幂等号查询
record = select * from tbl_xxx where out_biz_no = 'xxx' for update;
// 3. 根据状态进行决策
if(record.getStatus() != 预期状态){return;
}
// 4. 更新记录
update tbl_xxx set status = '目标状态' where out_biz_no = 'xxx';
// 5. 提交事务
commit;
特点:
select for update,整个执行过程中锁定该条记录
缺点:
在DB读大于写的情况下尽量少用。悲观锁主要适用于更新场景,通过串行化请求处理来确保幂等性,但需要小心使用,因为在并发场景下,重复请求可能会导致线程长时间处于等待状态,浪费资源且降低性能。
2.4 方案四:业务层采用分布式锁机制
分布式锁与悲观锁本质上相似,都通过串行化请求处理来实现幂等性。与悲观锁不同的是,分布式锁更轻量。在系统接收请求后,首先尝试获取分布式锁。如果成功获取锁,则执行业务逻辑;如果获取失败,则立即拒绝请求。
分布式锁的核心是识别重复请求,实现串行化处理。但要注意,获取锁成功后,业务逻辑的执行并没有可靠保证。因此,在实际应用中,分布式锁需要结合事务机制和重试机制,以形成完整的幂等性解决方案。
2.5方案五:防重 Token 令牌实现幂等性
2.5.1 流程:
1)当用户访问表单页面时,客户端请求服务端接口以获取唯一的Token(可以是UUID或全局ID),服务端生成的Token会被存储在Redis或数据库中。
2)用户首次提交表单时,将Token与表单一起发送至服务端,服务端会验证Token的存在性,如果Token存在,则执行业务逻辑,并在完成后销毁Token。
3)用户再次提交表单时,同样携带Token一起发送至服务端。但由于Token已被销毁,服务端无法找到对应的Token,从而拒绝重复提交请求。
2.5.2实现:
(1)集群环境:token+redis
(2)单jvm环境:token+redis 或者token+jvm内存
2.5.3 Token特点
== 要申请,一次有效性,可以限流 ==
2.5.4缺点:
(1)产生过多额外请求
(2)先删除token,如果业务处理出现异常但token已经删除掉了,再来请求会被认定为重复请求
后删除token,如果删除redis中的token失败了,再来请求不会拦截,发生了重复请求
无论是先删除token还是后删除token,都会导致每次业务请求都产生一个额外的请求去获取token。然而,在生产环境中,业务失败或超时的情况并不多见,大多数请求都能成功完成。因此,为了处理这少数失败的请求,让绝大多数请求都产生额外的请求也算是一种资源的浪费。
2.5.5 存在问题:删除token时,是先完成业务操作后删除token,还是先删除token后执行业务操作呢?
答案:要先删除 token ,再执行业务代码 。『后删除 token』的缺陷太致命
(1)先执行业务操作再删除token
情况:在高并发下,可能出现第一次访问时token存在,完成具体业务操作,但在还没有删除token时,客户端又携带token发起请求。此时,因为token还存在,第二次请求也会验证通过,执行具体业务操作。
对于这个问题有如下两种解决方案:
第一种方案: 对于业务代码执行和删除token整体加线程锁,使得后续线程阻塞排队,但可能造成一定性能损耗与吞吐量降低。
第二种方案: 借助Redis单线程和INCR原子性特性,在获取token时对其进行自增操作。当客户端携带token访问执行业务代码时,继续对其进行自增,如果自增后的返回值为2,则是一个合法请求允许执行,否则认为是非法请求,直接返回。
(2)先删除token再执行业务
如果业务执行超时或失败,没有向客户端返回明确结果,客户端就会进行重试,但此时之前的token已经被删除,导致被认为是重复请求,不再进行业务处理。
这种方案无需额外处理,一个token只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。
先删除token,再执行业务逻辑,中间如果出现宕机,可能会导致业务调用失败,对于这种情况,大不了就重新获取token再次请求
2.6 方案六:状态机机制
在许多业务单据中,存在有限数量的状态,并且这些状态之间的流转顺序是固定的。如果状态已经处于下一个状态,那么再次应用上一个状态的变更逻辑是不会产生任何效果的,这就确保了有限状态机的幂等性。
例如,库存状态通常包括"预扣中"、“扣减中”、“占用中"和"已释放"等状态。如果系统重复调用扣减接口,而库存状态已经是"扣减中”,则可以直接返回结果。
状态机可以与乐观锁机制结合使用,示例操作如下:
update tableName set sq=sq-#{quantity},status=#{udpate_status} where id =#{id} and status=#{status}
特点:和任务、状态相关的业务,肯定会涉及状态机,业务的一个属性状态,可以作为幂等的一个根据
2.7方案七:下游传递唯一序列号实现幂等性
缺点:无法控制下游唯一序列号的生成规则,如果序列号由时间戳生成,那么无法拦截类似重复点击这种情况下的重复请求
3.1 补充 redis幂等性
3.1.1 redis幂等性
在Redis中,幂等性是指相同的操作可以被多次执行而不会产生额外的影响或副作用。简而言之,就是无论执行多少次相同的操作,结果都是一样的。 在Redis中,可以通过以下几种方式来实现redis的幂等性:
(1)使用Redis的原子性操作:Redis提供了一些原子性操作,如SETNX、INCR、SADD等。 这些操作在执行时是原子性的,即是一个操作的结果要么成功执行,要么没有执行。通过使用这些原子性操作,可以保证相同的操作在执行时只会生效一次。
(2)使用Redis的事务:Redis的事务可以将一系列的操作包装在一个事务中,然后一起执行。在事务执行期间,其他客户端的请求不会干扰到事务的执行。通过将幂等操作放在一个事务中执行,可以保证这些操作只会被执行一次。
(3)使用Redis的分布式锁:通过使用Redis的分布式锁,可以保证同一时间只有一个客户端可以执行特定的操作。当一个客户端获取到锁后,其他客户端尝试获取锁的操作会被阻塞,直到锁被释放。通过使用分布式锁,可以保证相同的操作只会被执行一次。
总结起来,Redis中可以通过原子性操作、事务和分布式锁等方式来实现redis的幂等性。 这样可以保证相同的操作在执行时不会产生额外的影响或副作用。
3.1.2 redis SETNX分布锁详解
1.SETNX:向Redis中添加一个key,只用当key不存在的时候才添加并返回1,存在则不添加返回0。并且这个命令是原子性的。
2. 使用SETNX作为分布式锁时,添加成功表示获取到锁,添加失败表示未获取到锁。至于添加的value值无所谓可以是任意值(根据业务需求),只要保证多个线程使用的是同一个key,所以多个线程添加时只会有一个线程添加成功,就只会有一个线程能够获取到锁。而释放锁锁只需要将锁删除即可。
3.设置过期时间防止死锁
在添加时存在则添加,不存在则不添加。同时设置过期时间,单位秒
示例:
/*** 计算结果写入到kafka幂等性的实现** @param json* @return*/public boolean idempotent(String json) {try {RedisPool redisPool = RedisPool.instance(properties);Jedis jedis = redisPool.getResource();String value = "1";if (jedis.setnx(json, value) > 0) {jedis.expire(json, 24 * 3600);redisPool.returnResource(jedis);return true;}redisPool.returnResource(jedis);} catch (Exception e) {System.err.println("redis pool get redis failed: " + json);}return false;
}
2.防止死锁
SET key value NX EX time//通过java代码实现SETNX同时设置过期时间 //key--键 value--值 time--过期时间 TimeUnit--时间单位枚举
stringRedisTemplate.opsForValue().setIfAbsent(key, value , time, TimeUnit);
优点:
1.程序可以分组,可以分布式,亦可以用于数据恢复程序
2.减少了对redis操作频度,提高了程序的并发性.
3.2 参考文章:
https://mp.weixin.qq.com/s/7YDtl8EfYvre49Al9yVZIw
https://blog.csdn.net/sinat_32023305/article/details/119610885
https://blog.csdn.net/q7w8e9r4/article/details/132533849