目录
- 什么是幂等性
- 为什么需要幂等性
- [7 种实现方式](#7 种实现方式)
- [1. 数据库唯一约束](#1. 数据库唯一约束)
- [2. Token 令牌机制](#2. Token 令牌机制)
- [3. 状态机/版本号机制](#3. 状态机/版本号机制)
- [4. 分布式锁](#4. 分布式锁)
- [5. 缓存结果](#5. 缓存结果)
- [6. 幂等键](#6. 幂等键)
- [7. 消息去重表](#7. 消息去重表)
- 性能对比
- 原理分析
- 进阶:异常处理与全链路幂等
- 不同场景的选择建议
- 幂等性设计原则
- 总结
什么是幂等性
幂等性(Idempotency)是指:同一个操作执行一次和多次,产生的结果是一样的。
在分布式系统和网络通信中,幂等性尤为重要,因为网络请求可能超时、失败导致重试,如果没有幂等性保证,重试可能导致数据重复或状态混乱。
示例
| 操作类型 | 示例 | 执行 1 次 | 执行 10 次 | 幂等? |
|---|---|---|---|---|
| 幂等操作 | x = 5 |
x = 5 |
x = 5 |
✅ |
| 非幂等操作 | x = x + 1 |
x = 6 |
x = 15 |
❌ |
| 幂等操作 | DELETE FROM t WHERE id = 1 |
删除 1 条 | 删除 0 条(已删除) | ✅ |
| 非幂等操作 | DELETE FROM t LIMIT 1 |
删除 1 条 | 删除 10 条 | ❌ |
为什么需要幂等性
典型场景
| 业务 | 场景 |
|---|---|
| 🏦 支付系统 | 用户支付请求超时,重试不能导致重复扣款 |
| 📦 库存扣减 | 下单请求重试不能重复扣减库存 |
| 📬 消息消费 | 消息队列消息重投不能重复处理 |
| 🌐 接口调用 | 前端网络异常时自动重试接口 |
| 🔄 定时任务 | 任务重启不能重复执行 |
| 🔗 服务调用 | RPC 调用重试不能导致数据重复 |
没有幂等性的后果
- 重复扣款:用户支付 100 元,扣款 300 元
- 库存超卖:100 件库存,下单 150 件
- 数据不一致:订单状态混乱,对账失败
- 业务逻辑错误:优惠券被重复使用
7 种实现方式
1. 数据库唯一约束
原理:利用数据库的唯一索引,保证同一资源不会重复插入或更新。
适用场景:创建订单、注册用户等需要保证数据唯一性的操作。
数据库 服务端 客户端 数据库 服务端 客户端 首次请求 重试请求 发送请求 (order_no=ORD001) INSERT INTO orders (order_no, ...) 成功 返回成功 发送请求 (order_no=ORD001) INSERT INTO orders (order_no, ...) 报错 (唯一约束冲突) 返回"已存在/成功"
实现方式:
sql
-- 为订单表添加业务唯一索引
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号唯一',
user_id BIGINT NOT NULL COMMENT '用户ID',
amount DECIMAL(10, 2) COMMENT '订单金额',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '订单状态',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_order (user_id, order_no),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
优点:
- ✅ 简单可靠,数据库层面保证
- ✅ 不需要额外存储
- ✅ 可靠性最高,不会出现代码漏洞
缺点:
- ❌ 只适用于写操作
- ❌ 性能受限于数据库索引
- ❌ 唯一键设计需要谨慎,避免死锁
2. Token 令牌机制
原理:客户端先获取一个 token,执行操作时携带 token,服务端验证 token 后立即标记已使用,重复使用直接拒绝。
适用场景:防止表单重复提交、防止重复下单、验证码等一次性操作。
Redis 服务端 客户端 Redis 服务端 客户端 首次提交 重复提交 GET /token (请求令牌) SET token:uuid-xxx unused 返回 uuid-xxx POST /order (携带 token=uuid-xxx) GET token:uuid-xxx unused SET token:uuid-xxx used (原子操作) 执行业务逻辑 返回成功 POST /order (携带 token=uuid-xxx) GET token:uuid-xxx used 返回"请勿重复提交"
代码示例:
java
/**
* 创建订单(幂等)
* 使用原子操作避免并发问题
*/
@PostMapping
public Result<OrderVO> createOrder(
@RequestBody CreateOrderDTO order,
@RequestParam String token) {
String redisKey = "order:token:" + token;
// 原子操作:将 unused 标记为 used
// 只有当 key 存在且值为 "unused" 时才设置成功
Boolean marked = redisTemplate.opsForValue().compareAndSet(
redisKey,
"unused",
"used"
);
if (marked == null || !marked) {
return Result.fail("请勿重复提交订单或 token 已过期");
}
try {
OrderVO orderVO = orderService.create(order);
return Result.success(orderVO);
} catch (Exception e) {
// 业务失败,可根据逻辑选择是否释放 token
tokenService.releaseToken(token);
throw e;
}
}
优点:
- ✅ 适用于读操作和写操作
- ✅ 时间窗口可控
- ✅ 用户体验好
缺点:
- ❌ 需要额外的存储(Redis)
- ❌ 实现稍复杂
3. 状态机/版本号机制
原理:为资源添加版本号或状态字段,更新时检查版本号,只有版本号匹配才能更新成功。
适用场景:订单状态流转、账户余额更新等有状态变化的操作。
支付成功
发货
确认收货
取消
PENDING
PAID
SHIPPED
COMPLETED
CANCELED
并发更新演示(乐观锁):
数据库 请求 B 请求 A 数据库 请求 B 请求 A SELECT (version=0) SELECT (version=0) UPDATE SET status=PAID, version=1 WHERE version=0 影响行数: 1 (成功) UPDATE SET status=PAID, version=1 WHERE version=0 影响行数: 0 (失败)
优点:
- ✅ 天然防止并发问题(乐观锁)
- ✅ 不需要额外存储
- ✅ 适合分布式环境
缺点:
- ❌ 需要业务本身有状态流转
- ❌ 冲突需要业务处理
4. 分布式锁
原理:对资源加锁,同一时间只有一个请求能执行,其他请求阻塞或直接拒绝。
适用场景:防止并发重复操作、热点数据并发控制。
Redis 请求 B 请求 A Redis 请求 B 请求 A SETNX lock:product:1 (尝试加锁) OK (获取成功) SETNX lock:product:1 (尝试加锁) FAIL (已被占用) 返回"系统繁忙" 执行业务逻辑 (扣库存) DEL lock:product:1 (释放锁) OK
实现建议 :使用 Redisson 的 tryLock 并开启看门狗机制,确保锁的安全。
5. 缓存结果
原理:将操作结果缓存起来,相同请求直接返回缓存结果。
适用场景:读操作、耗时计算、不经常变化的查询。
数据库 Redis 客户端 数据库 Redis 客户端 首次查询 后续查询 GET product:1 null (Miss) SELECT * FROM product WHERE id=1 {id:1, name: "..."} SET product:1 {json} GET product:1 {json} (Hit)
优点:
- ✅ 提升性能,减少数据库压力
- ✅ 天然幂等
6. 幂等键(Idempotency Key)
原理:客户端为每个请求生成唯一的幂等键,服务端以幂等键为维度存储请求结果。
适用场景:支付系统、API 服务、外部接口调用。
Redis 服务端 客户端 Redis 服务端 客户端 Header: Idempotency-Key: k1 Header: Idempotency-Key: k1 POST /payment (首次) GET k1 null 执行支付业务 SET k1 {result} 返回支付结果 POST /payment (重试) GET k1 {result} 直接返回之前的结果
优点:
- ✅ 灵活控制幂等范围
- ✅ 业界通用标准 (如 Stripe API)
7. 消息去重表
原理:为消费者创建去重表,记录已处理的消息 ID。
适用场景:消息队列消费、异步任务。
去重表 消费者 消息队列 生产者 去重表 消费者 消息队列 生产者 消息重投 发送消息 (msgId: 001) 投递消息 (msgId: 001) INSERT INTO dedup (msgId) OK 执行业务逻辑 投递消息 (msgId: 001) INSERT INTO dedup (msgId) FAIL (Duplicate Key) 跳过处理
性能对比
| 方式 | 性能 | 可靠性 | 适用场景 |
|---|---|---|---|
| 缓存结果 | 最高 | ⭐⭐⭐ | 读操作 |
| 幂等键 | 高 | ⭐⭐⭐⭐ | API 服务 |
| Token 机制 | 高 | ⭐⭐⭐⭐ | 防重复提交 |
| 数据库唯一约束 | 中等 | ⭐⭐⭐⭐⭐ | 唯一性保证 |
| 状态机/版本号 | 高 | ⭐⭐⭐⭐ | 状态流转 |
| 分布式锁 | 低 | ⭐⭐⭐ | 热点并发 |
原理分析
1. 乐观锁 vs 悲观锁
悲观锁
读取数据时加锁
等待获取锁成功
执行业务操作
释放锁
乐观锁
读取数据时不加锁
检查版本号
更新时校验版本号
冲突则重试/报错
2. 原子操作的重要性
Redis 请求 B 请求 A Redis 请求 B 请求 A 原子操作 (CAS) SET token:1 used IF unused 1 (成功) SET token:1 used IF unused 0 (失败)
进阶:异常处理与全链路幂等
1. 幂等操作的事务一致性
在分布式系统中,幂等标记与业务操作不在同一事务中,需处理不一致性。
解决方案:
- 手动补偿 :在
catch块中,如果业务失败,回退/删除幂等标记。 - 两阶段标记 :
processing->completed/failed。
2. 全链路幂等传递
将幂等键(Idempotency Key)贯穿整个微服务链路。
第三方支付 支付中心 订单中心 客户端 第三方支付 支付中心 订单中心 客户端 createOrder (idKey: k1) pay(orderNo) (idKey: k1) payRequest (out_trade_no: k1)
3. Fencing Token (击剑令牌)
针对分布式锁过期的极端场景,在数据库更新时带上令牌版本。
sql
UPDATE account SET balance = balance - 100, last_token = 101
WHERE id = 1 AND last_token < 101;
不同场景的选择建议
| 场景 | 推荐方式 |
|---|---|
| 写操作/唯一性 | 数据库唯一约束 |
| 表单提交 | Token 机制 |
| 状态流转 | 状态机/版本号 |
| 热点并发 | 分布式锁 |
| API/支付 | 幂等键 |
| 消息消费 | 消息去重表 |
幂等性设计原则
- 底层兜底:数据库唯一索引是最后防线。
- 组合拳:上层防重,底层幂等。
- 全链路透传:确保幂等上下文不丢失。
- 监控告警:监控冲突率,及时发现攻击或逻辑错误。
总结
幂等性是分布式系统的基石。通过 数据库约束、Token、版本号、锁、缓存、幂等键、去重表 这 7 种武器,配合全链路透传 和异常补偿,我们可以构建出极其健壮的业务系统。
选择合适的方案,让系统更可靠。 🚀