为什么说幂等是分布式系统的必修课
很多团队第一次严肃对待幂等问题,往往是在线上出现资损之后。用户因为页面卡顿多点击了几次支付,结果账户被扣了两次款;后台任务重试导致同一张优惠券给用户发放了多次;或者消息队列的“至少一次”投递语义,让库存扣减逻辑执行了两次。这些都不是理论风险,而是每天都在发生的生产事件。
幂等的本质是保证同一个操作执行一次和执行多次,对系统状态的影响是一致的。在分布式环境下,网络超时、前端重试、网关抖动、服务重启、消息重投等各种不确定因素,都会导致业务逻辑被重复触发。因此,对于支付、下单、退款、发券这类会改变状态或产生副作用的写操作,幂等设计不是可选项,而是必选项。
重复请求从何而来
在动手设计之前,先得弄清楚敌人是谁。重复请求的来源远比想象中多样:
- 用户行为:连续点击提交按钮、刷新页面后再次提交。
- 网络层面:HTTP/TPC超时后的客户端自动重试、负载均衡器或API网关的重试策略。
- 消息中间件:为实现“至少一次”投递保证,在网络闪断或消费确认失败时,Broker会重新投递已被处理过的消息。
- 系统运维:服务重启、扩容缩容触发的Rebalance,可能导致少量消息重复消费。
- 定时任务:多实例部署时,任务可能被同时触发;或任务执行超时后被再次调度。
一个常见的误区是,认为只要前端做了防重复点击或网关做了限流就能高枕无忧。事实上,重试机制遍布系统各个层级,真正的安全边界必须建立在最终的业务逻辑层。
核心武器库:四种主流幂等方案剖析
没有一种方案能通吃所有场景,理解每种方案的原理和边界是关键。
1. 唯一键 + 数据库约束:简单粗暴的基石
这是最直接、最可靠的方法之一。业务层生成一个全局唯一的请求标识(如订单号、支付流水号),并在数据库表中为该字段建立唯一索引。首次请求插入成功,后续相同标识的请求会触发唯一约束冲突,从而被拦截。
// 订单表DDL示例
CREATE TABLE `order` (
`id` bigint PRIMARY KEY,
`order_no` varchar(64) NOT NULL UNIQUE COMMENT '订单号,幂等键',
`user_id` bigint NOT NULL,
`amount` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL DEFAULT 0,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
// 下单服务伪代码
@Transactional
public OrderDTO createOrder(CreateOrderRequest request) {
String orderNo = request.getOrderNo(); // 通常由前端或网关生成传入
try {
// 尝试插入,依赖数据库唯一索引
orderDao.insert(new Order().setOrderNo(orderNo)...);
// ... 后续业务逻辑
return convertToDTO(order);
} catch (DuplicateKeyException e) {
// 唯一键冲突,说明是重复请求
Order existingOrder = orderDao.selectByOrderNo(orderNo);
return convertToDTO(existingOrder); // 返回已存在的订单
}
}
适用场景:订单创建、支付流水记录、退款单生成等所有可以预先生成唯一业务标识的插入操作。
注意点:幂等键的生成必须保证全局唯一,常用雪花算法或号段模式。捕获到重复异常后,应返回第一次请求的结果,避免调用方因收到错误码而继续重试。
2. 状态机 + 乐观锁:贴合业务语义的优雅方案
对于支付、退款、订单状态变更等有明确生命周期和状态流转的业务,状态机是天然契合的幂等实现方式。其核心在于,通过数据库的乐观锁(如版本号或条件更新)原子性地校验当前状态并推进到下一状态。
// 支付回调处理示例
@Transactional
public void handlePaymentNotify(PaymentNotifyRequest notify) {
String orderNo = notify.getOrderNo();
// 关键:原子性状态更新,只有处于“待支付”状态的订单才能被更新为“已支付”
int updatedRows = orderDao.updateStatus(
"已支付",
orderNo,
"待支付" // WHERE条件:旧状态
);
if (updatedRows == 0) {
// 更新行数为0,说明状态已经不是“待支付”
Order currentOrder = orderDao.selectByOrderNo(orderNo);
if ("已支付".equals(currentOrder.getStatus())) {
// 已是目标状态,属于重复回调,直接返回成功
return;
} else {
// 订单已取消或其他状态,返回业务错误
throw new BizException("订单当前状态不允许支付");
}
} else {
// 更新成功,说明是第一次处理,执行后续逻辑(如记录支付流水、发货等)
recordPayment(notify);
}
}
适用场景:所有具有明确状态流转的领域,如订单(待支付→已支付→已发货)、工单、审批流等。
优势:逻辑清晰,直接体现业务规则,不依赖外部组件,性能好。
3. 幂等Token机制:专治前端重复提交
当业务无法在请求前生成唯一标识(如表单提交),或者需要专门防止用户短时间内重复点击时,Token机制非常有效。其流程是:先申请一个一次性的Token,提交时携带,服务端校验Token有效性并原子性地消费它。
// 使用Redis Lua脚本保证“校验+删除”的原子性
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else " +
"return 0 " +
"end";
public boolean consumeToken(String token) {
String key = "idempotent:token:" + token;
Long result = (Long) redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
"1" // 期望的value
);
return result == 1L;
}
// 在支付发起接口中应用
public PaymentInitResult initPayment(PaymentRequest request, String idempotentToken) {
if (!consumeToken(idempotentToken)) {
throw new BizException("请勿重复提交");
}
// Token消费成功,执行支付初始化逻辑
return doInitPayment(request);
}
适用场景:前端表单提交、支付页面发起、评论发布等需要强交互防重的场景。
关键:必须保证“判断Token存在”和“删除Token”是原子操作,否则高并发下仍可能重复通过校验。
4. 分布式锁:控制并发执行的卫士
分布式锁主要用于在短时间内串行化处理同一资源的并发请求,为后续的幂等校验(如状态机或唯一键检查)创造一个安全的执行环境。它本身通常不直接作为幂等方案,而是组合方案的一部分。
public PaymentResult deductBalance(String orderNo, BigDecimal amount) {
String lockKey = "lock:payment:" + orderNo;
String requestId = UUID.randomUUID().toString(); // 锁标识,防误删
try {
// 尝试加锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("支付处理中,请稍候");
}
// 获得锁后,在锁的保护下进行幂等业务处理(例如结合状态机)
return doDeductBalanceWithIdempotence(orderNo, amount);
} finally {
// 释放锁,通过Lua脚本保证只删除自己加的锁
String releaseScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(releaseScript, Long.class),
Collections.singletonList(lockKey), requestId);
}
}
适用场景:高并发下的支付扣款、库存扣减、优惠券领取等,需要与其它幂等方案结合使用。
避坑指南:务必设置合理的锁超时时间,并为锁设置唯一标识,使用原子脚本释放锁,防止因进程停顿导致的锁超时后误删其他请求的锁。
分场景落地实战
理解了核心方案,我们将其组合应用到具体场景中。
场景一:支付场景(扣款与回调)
支付是幂等要求最高的场景,涉及用户真金白银。通常需要组合拳:
- 支付发起(防重):采用Token机制,防止用户在前端重复点击支付按钮。
- 支付核心扣款:采用“分布式锁 + 状态机”。以订单号为锁Key,在锁内检查订单支付状态,并原子性地从“待支付”更新为“支付中”。
- 支付渠道回调:采用“唯一键 + 状态机”。以支付渠道返回的支付单号作为幂等键,在本地支付流水表建立唯一索引。回调逻辑先查流水,不存在则插入流水(唯一键防重),并原子化更新订单状态为“已支付”。
支付回调的幂等处理至关重要,因为微信、支付宝等支付渠道在未收到明确成功响应时,会进行多次回调。
场景二:订单创建
下单接口的重复调用可能导致商品超卖、用户收到多个相同订单。推荐方案:
- 方案A(前端生成幂等键):下单请求由前端或网关生成一个全局唯一的请求ID(Request ID)作为订单号或附加参数。服务端利用订单表的唯一索引进行防重插入。这是最简洁高效的方式。
- 方案B(服务端生成):若不想暴露生成逻辑,可以先调用一个“预下单”接口获取临时订单号,再用此订单号调用正式下单接口。服务端需在短时间内(如5分钟)维护该订单号与“预下单”状态的映射,并在正式下单时进行状态校验和转换。
场景三:消息队列消费
消息队列(如RocketMQ、Kafka)的“至少一次”语义决定了消息可能重复投递。消费端的幂等是必须的。核心思路是:以消息中的业务唯一标识(如订单号)作为去重键。
| 方案 | 实现方式 | 优点 | 注意事项 |
|---|---|---|---|
| 数据库唯一键 | 消费前,尝试插入一条以业务ID为主键的处理记录。 | 可靠,利用数据库持久化能力。 | 插入冲突即视为重复。需考虑记录清理。 |
| Redis SETNX | 消费前,用业务ID作为Key执行SETNX命令。 | 性能高,实现简单。 | 需设置合理过期时间,防止Redis重启或宕机导致数据丢失。 |
// 基于Redis的消费幂等示例
public void consumeOrderMessage(Message message) {
String orderNo = message.getOrderNo();
String dedupKey = "mq:dedup:order_create:" + orderNo;
// 使用SETNX原子操作尝试设置键,成功返回true代表首次处理
Boolean firstProcess = redisTemplate.opsForValue().setIfAbsent(dedupKey, "1", 2, TimeUnit.HOURS);
if (Boolean.TRUE.equals(firstProcess)) {
try {
// 执行业务逻辑,如创建订单
createOrderService.process(orderNo, message.getData());
} catch (Exception e) {
// 业务处理失败,可以选择删除Key,允许重试消费
redisTemplate.delete(dedupKey);
throw e;
}
// 业务成功,Key自然过期即可
} else {
// 已处理过,直接确认消息(ack)
log.info("消息已幂等处理,orderNo: {}", orderNo);
}
}
重要提醒:消息幂等不应依赖消息本身的Message ID,因为不同消息内容可能拥有相同的Message ID(冲突),或者同一消息在重投时Message ID可能变化。始终应以业务标识为准。
方案选型与避坑总结
在实际项目中,可以根据业务特性和团队技术栈进行选择和组合:
| 场景特征 | 优先推荐方案 | 组合建议 |
|---|---|---|
| 纯数据插入,有唯一业务号 | 唯一索引 | 最简单直接,配合异常捕获返回已有结果。 |
| 有明确状态流转(支付、退款) | 状态机 + 乐观锁 | 最贴合业务,性能好,无需外部依赖。 |
| 防止用户端重复提交 | Token机制 | 需前端配合,注意Redis操作的原子性。 |
| 超高并发下的写操作 | 分布式锁 + 上述任一方案 | 锁用于控制并发入口,内部仍需业务幂等。 |
| 消息队列消费 | Redis SETNX 或 数据库唯一键 | 以业务ID为去重依据,设置合理过期时间。 |
必须避开的坑:
- 分布式锁不是银弹:它解决的是并发问题,不能区分同一请求的重试和不同请求。必须与业务幂等逻辑结合。
- Token机制必须原子化:“检查是否存在”和“删除Token”必须是原子操作,否则高并发下会失效。
- 幂等响应应返回成功:对于重复请求,HTTP状态码应返回200,并携带第一次请求的结果数据。返回4xx错误会导致调用方(如支付渠道)认为失败而持续重试。
- 幂等键的生成要全局唯一:确保雪花算法Worker ID或号段服务配置正确,避免集群内产生冲突。
- 考虑清理策略:Redis中的去重Key或Token需要设置过期时间,数据库中的流水记录可考虑按时间分表或归档。
归根结底,幂等设计是一种业务架构思维。它要求开发者在设计任何一个会改变系统状态的接口时,都本能地思考:“如果这个请求因为任何原因被重复执行了,会发生什么?” 把这个问题想清楚并落实在代码中,是构建稳定、可信赖的分布式系统的基石。
原创文章,作者:,如若转载,请注明出处:https://fczx.net/wiki/140