幂等性设计详细介绍
本文介绍了幂等性设计的概念和重要性。幂等性指同一接口多次调用产生的结果与单次调用一致,是支付、发货等关键接口的必要特性。文章分析了非幂等设计可能导致的问题,如重复扣款、数据不一致等,并指出前端防护的局限性。详细阐述了幂等设计的四个原则:服务端保证、时效管理、结果一致性和可追溯性。重点介绍了四种主流实现方案:数据库唯一索引(含雪花算法详解)、乐观锁、天然幂等操作和分布式锁,分析了各方案的适用场景、实现要点及注意事项,为系统设计提供了全面的幂等性解决方案参考。
何为幂等性?
使用相同参数来调用同一接口,调用多次的结果跟单次产生的结果是一致的。
一些关键接口都需要幂等设计,比如支付扣款、发货等等。否则可能导致:
- 重复扣款
- 重复发货
- 数据不一致
- 用户体验差,甚至导致用户流失
还有可能是用户误触的,比如多次点击按钮导致多次提交等。虽然前端可以通过将按钮置灰防止重复点击,但是纯前端无法完美实现幂等性。比如前端调用后端接口超时,有可能后端已经存储了数据,此时前端的按钮已经可点击,用户再次点击就会生成两条数据。
设想一下,可能由于网络问题,我们调用扣款接口超时了,并且没有进行重试,这样有可能给用户发货了,但是实际没扣款,因此这种情况下通常要重试扣款。但是如果重试了,假设之前超时的那次调用实际是成功了,只是响应结果的时候接口超时了,这样就重复扣款两次,肯定是不行的。
//物流服务 { //调用订单服务 doOrder }catch{ //超时了,那么再调一次 do0rder } // 订单服务 doorder { // 执行扣款 money-- // 记录日志1og时程序超时了 }所以幂等设计在一些必须要保证业务一致性的情况下,非常关键,因为这种场景往往需要重试,重试就需要幂等。
为每个可能重复的操作分配全局唯一的标识符,通过该标识符识别重复请求。这是幂等设计的基础理念,如同为每笔交易赋予独一无二的"身份证号"。
幂等设计原则
原则一:客户端无关性
幂等性必须由服务端保证,不能依赖客户端的任何行为(如按钮置灰、页面跳转)。客户端可能崩溃、被用户绕过、或存在多个入口。
原则二:时效性考虑
幂等标识需要合理的生命周期管理。过短会导致合法重试被拒绝,过长则占用过多存储资源。通常根据业务特征设置数分钟到数天的有效期。
原则三:错误处理一致性
对于已处理的重复请求,应返回与首次成功相同的结果,而不是抛出"重复操作"错误。这对用户体验至关重要。
原则四:可追溯性
必须记录幂等处理日志,包含请求标识、处理状态、时间戳等信息,便于问题排查和数据审计。
幂等设计---4种主流方案
一、数据库唯一索引
实现原理:利用数据库的唯一索引特性,在数据层阻止重复记录的插入。
适用场景:
- 核心业务数据的创建操作
- 能够生成全局唯一标识的业务
- 对性能要求较高的高频操作
注意点:
- 插入冲突时数据库会抛出异常,需在应用层优雅处理
- 在高并发场景下,大量冲突可能导致数据库连接池压力
- 分布式数据库环境下需要确保唯一索引的全局性
比较常见的一种是使用UUID来生成唯一id,一种是使用雪花算法。
生成随机 UUID 字符串,并且在数据库中新增一列唯一索引存储 UUID。但其实没必要新增一列,因为表里面的主键本身就是唯一的,所以可以复用主键来进行唯一性判断。因为主键的类型是 bigint,所以只需要更换唯一 id 生成的策略,使用雪花算法来生成分布式全局唯一的自增 id 即可。
雪花算法可以使用 Hutool 工具类提供的工具类来基于雪花算法生成 id:
IdUtil.getSnowflakeNextId()雪花算法原理
当需要生成一个新ID时,算法按以下步骤进行:
首先获取当前时间戳(毫秒级),减去预设的纪元时间,得到时间差值。
检查这个时间差值与上次生成ID的时间戳的关系。如果当前时间戳小于上次时间戳,说明发生了时钟回拨,需要特殊处理(比如抛出异常或等待)。
如果当前时间戳等于上次时间戳,说明是在同一毫秒内,那么序列号自增1。如果序列号达到最大值(4095),则等待到下一毫秒再生成。
如果当前时间戳大于上次时间戳,说明进入了新的毫秒,序列号重置为0。
然后将这四个部分通过位运算组合起来:将时间戳部分左移到对应位置,机器标识部分左移到对应位置,序列号放在最低位,最后进行或运算,得到最终的64位ID。
雪花算法特性
雪花算法生成的ID具有全局唯一性,因为不同机器有不同的机器标识,相同机器在不同时间有不同时间戳,同一机器同一时间有不同序列号。
ID是趋势递增的,因为时间戳在高位,新生成的ID比旧生成的ID数值大,这对数据库索引友好。
算法在本地生成ID,不需要网络通信,性能很高。
雪花算法的核心就是通过时间戳(毫秒)保证递增,通过机器id、服务 id 和递增序号(同一毫秒内递增),保证唯一性。
时钟回拨问题
解决时钟回拨问题的6种常见方法:
- 等待时钟追上:当检测到时钟回拨时,让线程等待直到时间追赶上最后一次生成ID的时间。这种方法适用于回拨时间很短的场景(比如毫秒级或秒级)。但是,如果回拨时间较长,等待时间也会很长,影响系统可用性。
- 使用扩展位记录回拨:在ID中预留几位(比如1-2位)作为回拨计数。当发生时钟回拨时,将回拨计数加1,并仍然使用旧的时间戳(或回拨后的时间戳)生成ID,通过回拨计数来区分。这样,即使时间戳相同,回拨计数不同,ID也不同。但是,这种方法会减少时间戳或序列号的位数,影响ID的生成时长或并发能力。
- 异常报警,人工处理:当检测到时钟回拨时,抛出异常并记录日志,通知运维人员处理。这种方法适用于对时钟回拨非常敏感且不可容忍的场景,但依赖人工介入,实时性差。
- 使用备用时间源:不使用本地机器时钟,而是使用独立的、可靠的时间服务(如GPS时钟、原子钟等)。但这会增加系统复杂性和成本。
- 缓存历史时间戳:在内存或外部存储中保存最近一段时间使用过的时间戳,当时钟回拨时,从缓存中取出一个尚未使用过的时间戳(或者使用回拨后的时间戳,但通过其他方式保证唯一性)。不过,这种方法实现复杂,且可能带来性能问题。
- 调整雪花算法结构:例如,美团的Leaf算法对雪花算法进行了改进,使用Zookeeper或数据库来分配workerId,并且在发生时钟回拨时,使用预留的位来记录回拨次数,从而保证ID的唯一性。
二、数据库乐观锁
实现原理:通过版本号或时间戳字段,确保数据更新操作的原子性和一致性。
适用场景:
- 资源数量有限的更新操作(如库存扣减)
- 需要记录完整变更历史的业务
- 并发冲突概率较低的场景
1. 读取数据时获取当前版本号 2. 更新时附带版本号条件:WHERE id = ? AND version = ? 3. 检查更新影响行数:0行表示版本已变更,操作需重试或放弃优势:
- 避免悲观锁的性能开销
- 天然支持重试机制
- 提供数据变更的追溯能力
局限:
- 不适合极高并发的争抢场景
- 需要应用层处理更新失败逻辑
- 可能增加数据库查询次数
三、天然幂等操作
比如一些 delete 操作,这种是天然幂等的,因为删除一次和多次都是一样的。还有一些更新操作,例如:
update sys config set config="a" where id = 1;这样的 SQL不论执行几遍,结果都是一样的。
如果接口里面仅包含上述的这些天然幂等的行为,那么对外就可以标记当前接口为幂等接口,不需要任何其他操
作。
四、分布式锁
实现原理:通过分布式锁确保同一业务键的操作在任意时刻只有一个执行线程。
适用场景:
- 涉及多个数据源更新的复杂事务
- 无法仅通过数据库约束保证一致的业务
- 需要执行额外业务逻辑校验的场景
实现要点:
- 锁粒度要适中:太粗影响并发,太细增加复杂度
- 必须有锁超时机制,防止死锁
- 建议采用Redisson等成熟框架,避免自研坑点
1. 根据业务特征生成锁键(如"order:pay:{orderId}") 2. 尝试获取分布式锁(设置合理超时时间) 3. 在锁保护下执行幂等检查和业务操作 4. 无论成功失败,最终必须释放锁