一、先想清楚一个朴素的问题:同一件事做两遍,结果会一样吗?
在现实生活里,我们其实一直在跟“重复事件”打交道。比如你取快递,驿站小哥扫码签收,网络卡了一下,他又扫了一次。如果这次扫码系统不够聪明,你的包裹就可能被记录成“到达两件”。这就是典型的重复事件导致的数据不一致。
在事件驱动架构里,系统之间靠“事件”传递消息。比如用户下单后,订单服务发一个“订单已创建”的事件,库存服务收到后扣库存,积分服务收到后加积分,通知服务收到后发短信。听起来顺理成章,但有个问题无法回避:消息队列不保证“只送一次”,大多数情况下是“至少一次”。也就是说,同一个事件可能被送出好几遍,消费者也会收到好几遍。
如果消费者是“愣头青”,收到一次就处理一次,那结果就是:库存多扣了、积分多加了、通知多发了一次。这些问题的本质都一样——重复操作破坏了数据一致性。
所以,幂等性设计要解决的核心问题就是:同一个事件不管被接收多少次,最终的业务结果都跟只处理一次完全一样。 说大白话就是,让重复的事件在系统里“第一次生效,之后自动失效”。
二、哪些场景最需要防重复
先别急着聊技术,我们先看几个很常见、如果不做幂等就会出事的场景。
第一个场景:支付回调。 用户在支付页面完成付款,支付平台会通过回调通知我们的后台“这笔订单已支付”。由于网络波动,支付平台可能回调好几次。如果我们每次收到回调都给订单加一遍“已收金额”,那客户花了一块钱,账户里可能冒出三块钱。
第二个场景:扣减库存。 电商大促时,用户秒杀一件商品,订单系统发出“订单已支付”事件,库存中心要扣掉一件库存。一旦重复消费,本来一万件的库存,可能被扣成九千九百九十九,明明没卖那么多,货却没了。
第三个场景:积分累计。 用户购物后,积分服务监听订单事件发放积分。如果重复发放,用户什么都没做,就能靠同一笔订单赚两倍积分,这在运营上是不可接受的。
你会发现,这些场景都有共同特点:它们的业务动作不是天然的幂等。什么是“天然幂等”?比如“把订单状态改成已支付”,你改两次,最终状态还是“已支付”,结果不变。但“积分加100”就是非幂等的,加两次就是200。所以我们需要一套机制,把非幂等的操作包装成幂等的效果。
三、幂等设计的核心思路:给事件一张“身份证”
想清楚一句话:要让重复事件失效,必须先能认出重复事件。 怎么认出?靠唯一标识。
这个标识可以来自事件本身,比如消息头里带的 eventId,也可以来自业务上的自然键,比如订单号、流水号、支付单号。在业务系统里,我们通常会给每个事件设置一个“幂等键”,这个键在全局必须是唯一的,而且在整个事件的生命周期内保持不变。
有了幂等键之后,处理流程就变成三步:
- 收到事件,先查一下这个幂等键有没有被处理过。
- 如果没处理过,就执行真正的业务操作。
- 如果处理过,直接丢弃或返回成功,什么都不做。
这个思路听起来很简单,但在分布式系统里,有两个地方很容易踩坑。第一,“查一下有没有处理过”和“执行业务操作”这两个动作不是原子的,可能在查完之后、执行之前,第二个重复事件又来了,结果两边都认为自己“第一次”。第二,如果处理完业务之后,还没来得及把幂等标记写下来,系统就崩溃了,恢复后这个事件又会被当成新的来处理。
所以,真正的难题不是想到幂等键,而是怎么让“标记放行”和“业务处理”之间形成一种可靠的默契。下面我们用实际代码来拆解。
四、工程实践:用数据库唯一键做去重,最笨也最可靠
在所有幂等方案里,数据库唯一约束是我个人最推荐的“压舱石”。原因很简单:数据库的唯一键天生就是为“唯一性”设计的,它能在底层保证同一时间只能插入一条记录。
下面所有示例统一采用 Java 17 + Spring Boot + Kafka + Redis + MySQL 这套技术栈。
先看建表语句,我们专门建一张事件处理记录表,用来记录哪些事件已经被处理过。
-- 支付事件幂等记录表
CREATE TABLE payment_event_processed (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
event_id VARCHAR(64) NOT NULL COMMENT '事件唯一ID,也就是幂等键',
order_id VARCHAR(32) NOT NULL COMMENT '业务订单号',
handle_result VARCHAR(255) DEFAULT NULL COMMENT '处理结果摘要',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次处理时间',
UNIQUE KEY uk_event_id (event_id)
) COMMENT '支付事件幂等处理记录表';
这张表最关键的就是 event_id 上的唯一索引。它像是数据库在门口设置了一个保安,只允许同一个 event_id 进门一次。
接下来是消费者处理代码。我们假设从 Kafka 收到一个支付成功事件,处理逻辑如下:
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
public class PaymentEventConsumer {
private final PaymentProcessedRecordMapper recordMapper;
private final OrderService orderService;
public PaymentEventConsumer(
PaymentProcessedRecordMapper recordMapper,
OrderService orderService) {
this.recordMapper = recordMapper;
this.orderService = orderService;
}
/**
* 处理支付成功事件
*
* @param eventId 事件唯一ID,由生产者生成
* @param orderId 业务订单号
*/
@Transactional
public void handlePaidEvent(String eventId, String orderId) {
// 第一步:尝试插入幂等记录,这是整个流程的“守门员”
try {
PaymentProcessedRecord record = new PaymentProcessedRecord();
record.setEventId(eventId);
record.setOrderId(orderId);
recordMapper.insert(record);
} catch (DuplicateKeyException e) {
// 如果插入失败,说明这个 eventId 之前已经处理过了
// 这里直接记录日志并返回,不执行任何业务操作
log.info("检测到重复支付事件,直接忽略,eventId={}, orderId={}", eventId, orderId);
return;
}
// 第二步:执行真正的业务逻辑,这里用订单支付状态更新做示例
orderService.markOrderPaid(orderId);
// 第三步:事务提交后,幂等记录和业务操作一起生效
// 因为两步在同一个事务里,业务操作失败,幂等记录也会回滚
// 这样就不会出现“记录存在但业务没做”的错误情况
}
}
注意,这个方法加了 @Transactional 注解,并且把“插入幂等记录”和“修改订单状态”放在同一个事务里。这个设计非常重要,它保证了两件事的一致性:
- 如果订单状态更新失败,整个事务回滚,幂等记录也不会留下。
- 如果幂等记录插入成功,订单状态更新一定成功。
这就是用数据库做幂等的一大好处:不需要额外引入分布式锁,也不需要担心两个请求同时查到“不存在”的情况,因为唯一索引会在数据库层把后到的那一次拦截掉。
当然,这个方案也有它的代价。首先是性能和数据库压力:每处理一个事件,都要往数据库里插一条记录,高并发下这个表会越来越大,需要定期清理。其次是强依赖数据库,如果数据库性能成了瓶颈,整个事件处理链路也会变慢。
五、工程实践:用 Redis 做“快速令牌”
在一些高性能场景里,高频插入数据库可能扛不住,这时候我们往往会先用 Redis 做一些前置判断。Redis 里有一个原子操作叫 SET NX EX,通俗讲就是“只有键不存在时才设置值,同时设置过期时间”。依靠这个原子操作,我们可以快速实现一个轻量级幂等控制。
下面这段代码展示了如何利用 Redis 实现幂等:
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
public class RedisIdempotentService {
private final StringRedisTemplate redisTemplate;
public RedisIdempotentService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 尝试抢占事件处理权
*
* @param eventId 事件唯一ID
* @return true 表示第一次处理,false 表示重复事件
*/
public boolean tryAcquire(String eventId) {
// 拼接成 Redis 的键
String key = "idempotent:" + eventId;
// setIfAbsent 就是 SET NX EX
// 如果 key 不存在,设置成功并返回 true;如果已经存在,返回 false
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofHours(24));
return Boolean.TRUE.equals(success);
}
/**
* 在业务处理完成之后,可以主动释放,也可以等待过期
* 清理方法按照实际场景决定,这里提供一种思路
*/
public void release(String eventId) {
String key = "idempotent:" + eventId;
// 注意:只有业务处理失败且需要允许重试时才调用删除
redisTemplate.delete(key);
}
}
这段代码里的 setIfAbsent 是原子操作,多个线程同时到达时,Redis 会保证只有一个线程设置成功。所以它天然能防住“同时刻重复事件都通过检查”的问题。
但使用 Redis 做幂等,有几个容易忽略的坑:
- 过期时间怎么定? 太短,事件在过期后又被重复投递一次,就会二次处理;太长,白白占用内存。
- 缓存与数据库的一致性。 如果业务操作已经完成,但是 Redis 里的幂等标记意外丢失,重复事件又会被当成新的处理。
- Redis 本身的稳定性。 如果 Redis 挂了,幂等检查就失灵了,所以线上一般不会单独用 Redis 做唯一防线,而是把它当成“加速层”。
在实际项目中,比较稳妥的做法是:先用 Redis 做一层快速过滤,如果 Redis 认为没处理过,再去数据库里插入唯一记录。也就是说,Redis 负责“拦住大部分重复”,数据库负责“最后的兜底”。两者结合,既能扛住流量,又不会因为缓存丢数据而破功。
六、工程实践:状态机 + 版本号,用业务自身防重复
有些场景不需要专门建一张幂等表,因为业务数据本身就带着“状态”和“版本号”。比如订单,我们可以规定订单状态只能从“待支付”变成“已支付”,一旦变成“已支付”,后续再来的支付成功事件就不该把它再变一次。
数据库里通过 UPDATE ... WHERE status = ? AND version = ? 这样的条件更新,来判断状态是否合法。这种方式在并发控制里叫乐观锁,也能天然实现幂等效果。
请看下面的例子:
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface OrderMapper {
/**
* 将订单从待支付状态改为已支付状态
* 只有状态匹配时才会更新成功
*
* @param orderId 订单号
* @param expectStatus 期望的当前状态
* @param targetStatus 目标状态
* @return 更新行数,0 表示没有更新成功
*/
@Update("UPDATE orders SET status = #{targetStatus} " +
"WHERE id = #{orderId} AND status = #{expectStatus}")
int compareAndSetStatus(@Param("orderId") String orderId,
@Param("expectStatus") String expectStatus,
@Param("targetStatus") String targetStatus);
}
对应的业务处理代码:
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class OrderStatusService {
private final OrderMapper orderMapper;
public OrderStatusService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public void handleOrderPaid(String orderId) {
// 在支付成功事件里,期望的状态是“待支付”,目标状态是“已支付”
int updatedRows = orderMapper.compareAndSetStatus(
orderId,
"PENDING_PAYMENT",
"PAID"
);
// 如果更新行数为 1,说明状态切换成功,这是第一次处理
if (updatedRows == 1) {
log.info("订单状态已从待支付改成已支付,orderId={}", orderId);
// 这里可以去执行后续动作,比如开发票等
return;
}
// 如果更新行数为 0,说明订单已经不是“待支付”状态
// 可能是已经支付过了,也可能是被其他事件改掉了
// 不管哪种情况,都不应该再执行“支付成功”的业务动作
log.info("订单状态已不是待支付,重复事件直接忽略,orderId={}", orderId);
}
}
这种方案的好处是完全没有额外的表,也不用考虑数据清理,业务字段本身就是判重依据。但缺点也很明显:它只适用于业务状态有明确流转方向的情况。如果是“积分加100”这种没有状态约束的纯累加操作,状态机就帮不上忙了。
七、三种主流方案到底怎么选
我们来对比一下这三种思路的使用场景和优缺点。
数据库唯一键:适合几乎所有需要严格幂等的场景,特别是有事务性要求、业务操作必须和幂等标记绑定在一起时。优点是绝对可靠,缺点是对数据库有压力,表会膨胀。
Redis 令牌:适合高并发、重复流量特别多、业务本身可以容忍偶尔漏判的场景。优点是快,缺点是不能单独使用,最好只做前置过滤。
状态机 + 版本号:适合业务本身存在状态流转的场景,比如订单、支付、审批。优点是干净高效,不依赖额外存储,缺点是无法覆盖“无状态”的纯累加操作。
在实际的系统里,这三种方案经常是组合使用的。比如订单系统用状态机管订单,同时用数据库唯一表记录所有支付回调事件,Redis 挡在最前面。组合设计并不会增加多少成本,反而能让每个环节都找到最舒服的位置。
八、注意事项与避坑指南
这部分是踩坑换来的经验,值得你多看几遍。
第一,幂等键的选择绝对不能想当然。 有人喜欢用“订单号”做幂等键,但如果一个订单对应多个事件类型,比如“支付成功”和“退款成功”是两个不同事件,你这边的业务可能允许退款发生在支付之后,那它们就是两个独立的业务动作,不能共用一个幂等键。最好用一个完整的、语义明确的 eventId,由生产者生成,全链路唯一。
第二,幂等标记和业务操作必须原子生效,要么都成功,要么都失败。 如果先做业务操作,再写幂等标记,中间宕机就会重复处理。如果先写标记,再做业务,标记成功后业务失败,有可能导致后续重试被忽略。所以要把它们放到同一个事务里,或者用本地消息表的方式处理。
第三,业务失败之后的处理策略要想清楚。 很多时候,事件第一次处理时业务失败了,按幂等逻辑要不要把幂等记录删除?如果删了,重试还能继续处理;如果不删,这个事件就等于被永久“判死”。我的建议是:如果失败是临时性的,比如数据库抖动,可以删除幂等标记让重试重新处理;如果是业务规则不允许,比如金额校验不过,那就保留记录,给人工处理留时间。
第四,幂等记录不能一直堆积。 数据库表里的历史记录,Redis 里的过期键,都需要定期清理。清理的时候要尊重业务语义,比如支付事件的幂等记录保留三个月后再删,而不是今天处理完明天就清掉。
第五,要留好日志。 每次遇到重复事件时,一定要打出日志,记录事件 ID、业务 ID、当前时间和处理结果。线上排查重复问题的时候,这些日志是救命稻草。
第六,不要盲目追求“绝对幂等”。 在一些极端情况下,比如两个服务各自做幂等,拼在一起却可能产生不一致。这种场景下,与其硬扛,不如让业务接受“最终一致”,用对账任务去修复,反而更容易落地。
九、总结
事件驱动架构里的幂等性设计,本质上是给“重复事件”设置一道闸门。这道闸门可以是数据库唯一键,可以是 Redis 原子操作,可以是业务状态约束,也可以把它们组合起来。核心始终只有一个:确保同一个业务事件,第一次处理时开启全部流程,后面再来的时候,能够被温柔而坚定地拒绝。
不要小看这一点。很多线上事故,比如重复扣款、重复发奖、重复扣库存,归根结底都是没有守住这一道闸门。工程上的幂等设计并不需要多么复杂高深的技术,它更像是一份责任——承认网络会抖动、消息会重放、系统会崩溃,然后用一种朴素可靠的机制,让系统在这些意外面前依然稳如磐石。
希望这篇文章能帮你在下一次设计事件驱动系统时,多想一层防重复的机制,也让你在排查线上问题时,多一个可以依赖的解题方向。
评论
围绕“事件驱动架构幂等性设计的核心思路确保重复事件不会破坏数据一致性的工程实践与关键考量要点深入分析”参与讨论