1. 参考资料
2. 内容设问
- 什么是幂等性?
- 常见幂等性场景(HTTP资源请求方式和支付、订单创建等)
- 幂等性类型(请求幂等和业务幂等)
- 没有实现幂等性可能存在的问题
- 实现幂等性可能问题:并发场景下发生幂等击穿
- 实现幂等性通用方案(一锁二判三更新)
- 实现幂等性具体方案(分为基于缓存和基于数据库两大类)
3. 正文
1. 🚀 什么是幂等性?
幂等性(Idempotency) 是一个操作的特性,指的是无论该操作执行多少次,其结果始终是相同的,不会因重复执行而产生不同的副作用或不一致的状态。
2. 🚀 常见的幂等性场景
① HTTP请求方式
- GET:用于获取资源的请求。幂等且安全,即无论执行多少次相同的
GET
请求,都会返回相同的资源内容。 - PUT:用于更新资源的请求。如果请求内容相同,无论执行多少次,系统状态都保持一致,因此
PUT
请求是幂等的。 - POST:通常用于创建资源的请求,且非幂等。每次发送相同的
POST
请求可能会导致不同的结果,比如每次都会创建新的资源或引发新的操作。因此,POST
请求通常不是幂等的。
② 支付系统应用:
支付业务
如果用户因网络问题点击多次支付按钮,只有第一次会成功扣款,后续的重复请求不会再重复扣款。
订单创建
创建订单时,用户因为重试点击导致多次请求,但系统只会创建一次订单,避免重复创建。
3. 🚀 幂等性的类型
一般分为请求幂等和业务幂等。
① 请求幂等:每次请求,如果参数一样,结果也要一样。
② 业务幂等:要求对业务流程的不同阶段,对重复请求的处理方式有所区分。
同一次业务请求:
在拿到最终状态之后的每次请求,系统的响应结果应相同,业务状态不可变。
在没拿到最终状态之前,每次请求会继续执行相关的业务逻辑,直到业务最终达到预期状态。
4. 🚀 如果没有幂等可能带来的问题举例
① 前端重复提交表单:post请求,点击两次时会出现两条重复数据,只有id不同;
② 接口超时重试: 主要是第三方调用接口因网络原因调用失败时设计的失败重试机制,当第一次调用执行一半时发生网络异常,再次调用时就会因为脏数据的存在而出现调用异常;
③ 重复消费消息:在分布式系统中,消息队列(如 Kafka、RabbitMQ)用于解耦服务和提高系统的可靠性。但如果消费者在处理消息时宕机、断开连接或因超时未正确确认(ACK),消息队列会将该消息重新投递。如果业务逻辑没有幂等性保障,可能会导致同一条消息被消费多次,进而引发数据重复处理的问题。
5. 🚀 实现幂等性可能存在的问题:幂等击穿
- 🎯 什么是幂等击穿?
实现幂等性时系统无法处理并发请求导致幂等性失效(幂等方案设计问题), 从而引发业务异常或者错误。重复操作仍然会产生副作用或者错误的结果。
- 🔄 产生原因
① 并发处理问题: 没有正确控制共享资源,导致多个请求并行执行产生不同结果;
② 业务状态同步问题:系统无法在多个操作或服务之间正确同步状态,导致不同的服务或系统对于请求的处理状态出现不一致。
③ 缺乏正确的幂等性标识:请求的唯一标识(如幂等性键)没有正确传递或识别,导致服务无法判断重复请求。
6. 🚀 解决并发场景下幂等问题的通用设计方案
💡 口诀: ”一锁、二判、三更新”
任何一个并发请求过来时,
一锁: 第一步,先加锁(互斥锁:分布式锁或者悲观锁);
二判: 第二步,进行幂等性判断,可以基于状态机、流水表、唯一性索引等,判断是否已经更新过对应状态/是否存在;
三更新: 第三步,如果之前数据没有更新,本次请求可以更新;如果之前数据有更新,本次请求不能更新。
7. 🚀 解决并发下的幂等问题的具体方案
💡 可以分成两大类,基于数据库和基于缓存的幂等性方案。
一类是基于MySQL实现(适合数据强一致性场景) :insert前先select、加锁(乐观锁、悲观锁),建立唯一索引、建立防重表,状态机(大部分依赖数据库)。
一类是基于Redis实现(适用于高并发和低延迟场景): Token机制,分布式锁。
- ✅ insert前先select
插入前 先查看这个数据是否存在, 存在则改用update,不存在则insert。
示例:
-- 先查询数据
SELECT * FROM table_name WHERE id = ?;
-- 如果存在则更新数据
UPDATE table_name SET column1 = ?, column2 = ? WHERE id = ?;
-- 如果不存在则插入数据
INSERT INTO table_name (id, column1, column2) VALUES (?, ?, ?);
类比:乐观锁(根据version/timestamp来检查数据是否被其它事务修改)
-- 假设表中有 version 字段
SELECT * FROM table_name WHERE id = ? AND version = ?;
-- 更新时检查 version 是否匹配
UPDATE table_name SET column1 = ?, version = version + 1 WHERE id = ? AND version = ?;
- ✅ 用悲观锁
悲观锁是一种确保在同一时刻只有一个请求能够修改数据的锁机制。
加锁语法:通过 SELECT ... FOR UPDATE
语句可以为查询的记录加上悲观锁
如:使得只有获取到锁的事务能够修改数据,其他事务必须等待当前事务释放锁。
select * from user id=123 for update;
注意:
如果此时用的是MySQL,需要确保其存储引擎是innodb,因为只有innodb才支持行级锁和事务;
且锁定的记录需要具备唯一标识(如主键或者唯一索引) ,如果不存在唯一索引,行锁会降级为表锁。
- ✅ 用乐观锁
因为悲观锁会存在性能问题,乐观锁只在提交时检查数据是否被其它事务修改过,可以提升接口性能。适用于并发高、数据更新不频繁的场景。
方式:通过在表中加上version或者timestamp字段。在update操作时,在条件上对version或者timestamp检查。
举例: 订单创建实现幂等性流程
① 检查订单是否创建,不存在则插入新的订单
-- 查询是否已存在相同的订单
SELECT * FROM orders WHERE order_id = ?;
-- 插入订单记录
INSERT INTO orders (order_id, user_id, product_id, quantity, status, version)
VALUES (?, ?, ?, ?, 'CREATED', 1);
② 订单存在,用乐观锁更新
检查version是否匹配,并且更新version。
-- 使用乐观锁机制,检查 version 是否一致
UPDATE orders
SET status = 'CREATED', version = version + 1
WHERE order_id = ?
AND version = ?;
- ✅ 加唯一索引
添加一个仿重业务id或者业务唯一标识符,具备唯一索引。
加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求会有异常。
① 在数据库:插入数据时会报唯一索引冲突。
② 在Java程序:需要捕获DuplicateKeyException
异常;
③ 在Spring框架:还需要捕获MySQLIntegrityConstraintViolationException
异常
- ✅ 建防重表
① 为什么需要防重表?
因为不是所有场景都不允许产生重复数据,只有特定场景不允许,此时直接表里加唯一索引不太合适,因此用防重表来解决,提高灵活性。
② 什么是防重表?
通过在数据库中创建一个专门的表(防重表/幂等性表) 来存储业务请求的唯一标识符,可以实现幂等性,确保同一个请求不会被重复处理。
③ 防重表设计
id + 请求的唯一标识符,比如订单系统,id+order_id
④ 防重表应用举例:
系统接收业务处理请求时 ,如果此时要求不能重复处理(要求幂等性),
则需要在防重表先检查其order_id是否已存在,存在则捕获唯一索引异常跳过执行,不存在插入数据并继续处理业务逻辑。
- ✅ 状态机
① 为什么可以使用状态机?
业务数据存在不同状态: 因为业务表大多数情况下存在状态,比如订单的下单/已支付/完成;
业务数据状态用数值表示:在数据库表设计时,我们通常会将这些固定字符修改为数值表示,比如下单-1,已支付-2,完成-3,存在从小到大的数值规律。
② 应用场景
仅限于要更新的表有状态字段
,并且刚好要更新状态字段
的这种特殊情况,
③ 应用方式
类似如下:查询到已支付-2的状态,然后更新到完成-3的状态。
如果此时在其它事务中已经更新了已支付订单的状态,那么下面的语句执行结果影响行数为0,实现幂等性。
update `order` set status=3 where id=123 and status=2;
- ✅ 分布式锁
唯一索引或者加防重表,本质是使用了数据库的分布式锁,也是分布式锁的一种。
① 为什么需要分布式锁?
上面数据库分布式锁性能不太好,改用redis或者zookeeper来实现分布式锁。
② redis分布式锁实现
setNX+Lua脚本和redisson框架(如Rlock,实现自动续期)。
注意误删别人的锁、锁过期、锁的可靠性问题,具体说明如下链接:
- ✅ token机制
① 什么是token机制?
Token 机制是一种通过唯一标识符(Token) 来确保接口请求不会被重复处理的方法。
② token的作用是什么?
Token 作为该次请求的唯一标识,确保相同 Token 只会被处理一次。
② 实现步骤是什么?
- 请求前生成Token
客户端在发起请求前,先向服务端请求一个唯一的 Token(通常存储在 Redis 中,设置过期时间)。
- 请求时携带Token
客户端在调用接口时,需要携带该 Token。
在请求头中添加,类似如下:
POST /api/createOrder
Content-Type: application/json
{
"token": "123e4567-e89b-12d3-a456-426614174000",
"userId": 1001,
"productId": 5001,
"quantity": 1
}
- 接口处理Token幂等
服务器端收到请求后,先检查该 Token 是否已经使用过(比如创建时存入redis,使用后从redis中删除,在redis中查找不到,说明被使用过):
未使用:先保存 Token 并执行业务逻辑。
已使用:直接返回成功,避免重复执行业务逻辑。
- 请求完成后删除 Token
业务处理成功后,可以删除 Token,防止 Redis 过载。