幂等性设计模式:幂等键、去重表、状态机、乐观锁、分布式锁、幂等性中间件
各位同学,今天我们来聊聊幂等性。这个词听起来有点学术,说白了就是——同一个操作,无论你调用多少次,结果都是一样的。我刚开始做分布式系统时,觉得这玩意儿可有可无。直到有一次线上事故,用户重复下单,库存扣了两次,财务对账对了一整夜……嗯,从那以后,我再也不敢忽视幂等性了。
幂等性不是某个单一的技术,而是一套组合拳。今天我把常用的六种模式串起来讲,你想想看,它们各自适合什么场景,又有什么坑。
核心观点:幂等性设计的本质,是把“非幂等操作”转化为“幂等操作”。不是所有接口天生幂等,但我们可以通过模式让它变得幂等。
1. 幂等键(Idempotency Key)
这是最直观的模式。客户端每次请求时,带上一个全局唯一的键。服务端收到请求后,先查这个键是否处理过。处理过就直接返回上次结果,没处理过就正常执行并记录。
我在项目中遇到过支付回调的场景。第三方支付平台会多次回调同一个订单,如果不做幂等,用户可能被重复扣款。我的做法是:用订单号+支付流水号拼接成幂等键,存到Redis里,设置24小时过期。
// 伪代码示例
public Response handlePayment(PaymentRequest request) {
String idempotentKey = request.getOrderId() + "_" + request.getTransactionId();
// 检查是否已处理
if (redisClient.exists(idempotentKey)) {
return redisClient.get(idempotentKey); // 返回上次结果
}
// 执行核心逻辑
Response result = doPayment(request);
// 记录结果,设置过期时间
redisClient.setex(idempotentKey, 86400, result);
return result;
}
小技巧:幂等键的过期时间要合理设置。太短可能重复处理,太长浪费存储。我个人习惯设置为业务超时时间的2倍。
2. 去重表(Deduplication Table)
去重表是幂等键的数据库版本。说白了,就是在数据库里建一张去重表,用唯一索引来保证幂等性。
举个例子,用户注册场景。用户点击注册按钮时,前端生成一个请求ID。后端收到请求后,先往去重表里插入一条记录(请求ID作为唯一键)。如果插入成功,说明是第一次请求,正常处理。如果插入失败(唯一键冲突),说明是重复请求,直接返回已处理。
CREATE TABLE idempotent_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_id VARCHAR(64) NOT NULL UNIQUE,
status TINYINT NOT NULL DEFAULT 0,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 插入时捕获唯一键冲突
INSERT INTO idempotent_records (request_id, status) VALUES (?, 0);
-- 如果成功,继续业务逻辑
-- 如果抛出DuplicateKeyException,说明重复请求
注意:去重表需要定期清理,否则数据会无限膨胀。我曾经见过一个系统,去重表积累了上亿条数据,查询性能急剧下降。建议设置定时任务,清理超过业务超时时间的记录。
3. 状态机(State Machine)
状态机模式适用于有明确状态流转的业务。比如订单状态:待支付 → 已支付 → 已发货 → 已完成。每个状态只能向前流转,不能回退。
为什么状态机能保证幂等?因为同一个状态转换操作,只有第一次会生效。比如“支付成功”这个事件,订单状态从“待支付”变成“已支付”。如果重复触发,状态机发现当前状态已经是“已支付”,就不会再执行转换逻辑。
public enum OrderState {
PENDING_PAYMENT,
PAID,
SHIPPED,
COMPLETED;
public OrderState next(Event event) {
switch (this) {
case PENDING_PAYMENT:
if (event == Event.PAY_SUCCESS) return PAID;
break;
case PAID:
if (event == Event.SHIP) return SHIPPED;
break;
// ... 其他状态转换
}
throw new IllegalStateException("Invalid state transition");
}
}
我个人习惯在状态机里加一个版本号字段。每次状态变更时,检查版本号是否匹配。这样能防止并发场景下的状态覆盖问题。
4. 乐观锁(Optimistic Lock)
乐观锁适合读多写少的场景。它的核心思想是:更新数据时,带上一个版本号或时间戳。只有版本号匹配时,更新才成功。
你想想看,如果两个人同时操作同一条数据,谁先提交谁成功,后提交的会因为版本号不匹配而失败。这不就是天然的幂等吗?
-- 更新时带上版本号
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = ? AND version = ?;
-- 如果影响行数为0,说明版本号不匹配,重试或报错
避坑指南:我曾经在库存扣减场景里用过乐观锁,但发现高并发下失败率太高。后来改成了“预扣库存+异步对账”的方案。乐观锁不是万能的,要根据业务选型。
5. 分布式锁(Distributed Lock)
分布式锁是解决并发幂等问题的利器。多个节点同时处理同一个请求时,只有拿到锁的节点才能执行,其他节点等待或放弃。
常用的分布式锁实现有Redis的SETNX、ZooKeeper的临时节点、数据库的悲观锁等。我个人偏爱Redis的Redlock算法,虽然实现复杂一点,但可靠性高。
// Redis分布式锁示例
String lockKey = "lock:order:" + orderId;
String requestId = UUID.randomUUID().toString();
// 尝试获取锁,过期时间10秒
Boolean locked = redisClient.setnx(lockKey, requestId, 10, TimeUnit.SECONDS);
if (locked) {
try {
// 执行业务逻辑
processOrder(orderId);
} finally {
// 释放锁,只释放自己的锁
if (requestId.equals(redisClient.get(lockKey))) {
redisClient.del(lockKey);
}
}
} else {
// 获取锁失败,重试或返回
}
注意:分布式锁不是银弹。锁的过期时间设置不当,可能导致死锁或锁提前释放。我建议在业务代码里加一个“续期”机制,防止业务执行时间超过锁的过期时间。
6. 幂等性中间件(Idempotency Middleware)
如果每个业务都自己实现幂等,那代码就太冗余了。成熟的团队会把幂等能力封装成中间件,对业务透明。
幂等性中间件通常包含以下几个组件:
- 拦截器:在请求入口处拦截,提取幂等键
- 存储层:存储已处理的请求和结果(Redis或数据库)
- 一致性保障:确保幂等键的写入和业务操作在同一个事务里
- 过期策略:自动清理过期数据
我参与过一个项目,把幂等中间件做成了Spring Boot Starter。业务方只需要在接口上加一个@Idempotent注解,配置幂等键的提取规则,剩下的交给中间件处理。效果非常好,开发效率提升了一大截。
@Idempotent(key = "#request.orderId", expire = 3600)
public Response createOrder(CreateOrderRequest request) {
// 业务逻辑,不需要关心幂等
return doCreateOrder(request);
}
知识体系总览
下面这张图,我把六种模式的关系和适用场景梳理了一下。你保存下来,以后做架构设计时可以参考。
如何选择?
说了这么多,你可能会问:到底该用哪种?我的建议是:
- API接口幂等:优先用幂等键,配合Redis存储。简单高效。
- 数据库操作幂等:去重表或乐观锁。去重表适合插入场景,乐观锁适合更新场景。
- 有状态流转的业务:状态机模式。比如订单、审批流程。
- 跨节点并发控制:分布式锁。但要注意性能和死锁问题。
- 团队标准化:封装成幂等中间件,统一管理。
最后说一句:幂等性设计不是一蹴而就的。我见过很多系统,一开始没做幂等,后来出了问题才补。与其事后补救,不如在设计阶段就把幂等性考虑进去。毕竟,一个可靠的系统,不是靠运气运行的。