Spring Cloud Stream Kafka 如何实现幂等性
结论
不能只凭 Kafka offset、Spring Message 的 id header,或者“以前是否见过这条消息”来决定是否丢弃。更可靠的处理方式是:
- 为业务命令设置稳定的幂等键,例如
orderId + commandType,或者使用独立的commandId。 - 在 Product Service 的同一个本地数据库事务中完成以下操作:
- 判断该业务操作是否已经完成;
- 更新库存或预留状态;
- 记录处理结果;
- 写入待发送的响应事件。
- 使用唯一约束或条件更新处理并发重复消费。
- 通过 Transactional Outbox 可靠地发布
PRODUCT_UNRESERVATION_COMPLETED。
判断依据不应是“消息是否重复到达”,而应是“这次业务操作是否已经成功提交”。
为什么只检查消息 header 不够
消息重复不等于业务重复。
Order Service 重试时,可能会重新构造消息。新消息的 Spring MessageHeaders.ID 和 Kafka offset 都会变化,但它表达的仍然是同一个业务操作,也就是“取消订单 123 的库存预留”。
另一种情况是,Product Service 已经收到相同的 commandId,却在事务提交前崩溃。由于操作尚未完成,服务重启后仍然需要再次执行。
所以,幂等状态必须保存在 Product Service 的数据库中,并与库存变更放在同一个事务里。内存缓存、Kafka header,或者单独提交的“已处理消息表”,都无法独立消除崩溃窗口。
推荐的数据模型
与单纯维护 processed_messages 相比,为每个订单保存库存预留状态通常更稳妥:
CREATE TABLE product_reservation (
order_id VARCHAR(64) PRIMARY KEY,
product_id VARCHAR(64) NOT NULL,
quantity INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE event_outbox (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL
);
product_reservation.status 可以使用以下状态:
RESERVED -> RELEASED
对于同一个 orderId:
- 状态为
RESERVED时,允许归还一次库存,随后将状态改为RELEASED。 - 状态为
RELEASED时,说明库存已经归还,不能再次增加库存。 - 如果找不到预留记录,应根据业务约定报错、延迟重试或记录异常事件,不能直接增加库存。
这个模型记录的是业务事实,比“某条 Kafka 消息是否处理过”更便于审计和修复。
消费端处理示例
下面使用 Spring Cloud Stream 函数式消费者演示。具体的绑定配置会因 Spring Cloud Stream 版本而异,但事务边界的处理原则相同。
public record UndoProductReservation(
String commandId,
String orderId,
String productId,
int quantity
) {
}
@Configuration
public class ProductConsumers {
@Bean
public Consumer<Message<UndoProductReservation>> undoProductReservation(
ProductReservationService service) {
return message -> service.undo(message.getPayload());
}
}
核心逻辑放在一个数据库事务中:
@Service
public class ProductReservationService {
private final ReservationRepository reservationRepository;
private final ProductRepository productRepository;
private final OutboxRepository outboxRepository;
public ProductReservationService(
ReservationRepository reservationRepository,
ProductRepository productRepository,
OutboxRepository outboxRepository) {
this.reservationRepository = reservationRepository;
this.productRepository = productRepository;
this.outboxRepository = outboxRepository;
}
@Transactional
public void undo(UndoProductReservation command) {
ProductReservation reservation =
reservationRepository.findByOrderIdForUpdate(command.orderId())
.orElseThrow(() ->
new IllegalStateException(
"Reservation not found: " + command.orderId()));
if (reservation.getStatus() == ReservationStatus.RELEASED) {
ensureCompletionEvent(command);
return;
}
if (reservation.getStatus() != ReservationStatus.RESERVED) {
throw new IllegalStateException(
"Cannot release reservation in state: "
+ reservation.getStatus());
}
int updated = productRepository.increaseStock(
reservation.getProductId(),
reservation.getQuantity());
if (updated != 1) {
throw new IllegalStateException(
"Stock update failed: " + reservation.getProductId());
}
reservation.markReleased();
reservationRepository.save(reservation);
ensureCompletionEvent(command);
}
private void ensureCompletionEvent(UndoProductReservation command) {
outboxRepository.insertIfAbsent(
"product-unreservation-completed:" + command.orderId(),
command.orderId(),
"PRODUCT_UNRESERVATION_COMPLETED",
createPayload(command));
}
private String createPayload(UndoProductReservation command) {
return """
{"orderId":"%s","productId":"%s"}
""".formatted(command.orderId(), command.productId());
}
}
findByOrderIdForUpdate 可以通过悲观锁实现:
public interface ReservationRepository
extends JpaRepository<ProductReservation, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
select r
from ProductReservation r
where r.orderId = :orderId
""")
Optional<ProductReservation> findByOrderIdForUpdate(String orderId);
}
event_outbox.event_id 必须有唯一约束。insertIfAbsent 可以使用数据库提供的冲突忽略语法,也可以先尝试插入,再捕获唯一键冲突。具体 SQL 取决于使用的数据库。
崩溃时会发生什么
这个事务设计可以覆盖几个主要的崩溃窗口。
更新库存之前崩溃
事务没有提交,库存和预留状态都不会发生变化。消息重试后仍会读到 RESERVED,系统将重新执行库存归还。
更新库存之后、事务提交之前崩溃
整个数据库事务都会回滚,其中包括:
- 增加库存;
- 将状态改为
RELEASED; - 写入 Outbox 事件。
消息重试后可以安全地再次执行。
数据库事务提交之后、响应发布之前崩溃
此时库存已经增加,状态已经变为 RELEASED,Outbox 中也已经写入响应事件。
消息再次到达时,系统不会重复增加库存。独立的 Outbox 发布器会继续发送 PRODUCT_UNRESERVATION_COMPLETED。
响应已经发送,但 Outbox 尚未标记完成时崩溃
Outbox 发布器可能重复发送响应。这是至少一次投递的正常情况,因此 Order Service 也需要使用 eventId 或业务状态机对响应做幂等处理。
Outbox 发布
后台任务负责读取尚未发布的事件,并将它们发送到 Kafka:
@Component
public class OutboxPublisher {
private final OutboxService outboxService;
public OutboxPublisher(OutboxService outboxService) {
this.outboxService = outboxService;
}
@Scheduled(fixedDelayString = "${outbox.publish-delay-ms:1000}")
public void publish() {
outboxService.publishPendingEvents();
}
}
实现时,不能简单地在同一个长事务中先发送 Kafka 消息,再把 published 改为 true,因为数据库和 Kafka 之间仍有故障窗口。常见做法是接受 Outbox 至少发布一次,并要求接收方实现幂等处理。也可以使用 CDC 工具,将 Outbox 表的变更转发到 Kafka。
如果只使用已处理消息表
也可以创建如下表结构:
CREATE TABLE processed_command (
consumer_name VARCHAR(100) NOT NULL,
command_id VARCHAR(64) NOT NULL,
processed_at TIMESTAMP NOT NULL,
PRIMARY KEY (consumer_name, command_id)
);
不过,插入 processed_command、更新库存和写入 Outbox 必须放在同一个事务中:
@Transactional
public void handle(UndoProductReservation command) {
boolean firstProcessing =
processedCommandRepository.insertIfAbsent(
"product-service",
command.commandId());
if (!firstProcessing) {
return;
}
productRepository.increaseStock(
command.productId(),
command.quantity());
outboxRepository.insert(...);
}
如果先提交“已处理”记录,再更新库存,中途崩溃会导致后续重试被错误丢弃。如果先提交库存变更,再写入处理记录,中途崩溃又会导致库存重复增加。因此,这些操作必须原子提交。
在库存预留场景中,业务状态表通常比通用的 processed_command 表更合适。它不仅能防止重复处理,还能检查订单是否确实预留过库存、释放数量是否正确,以及当前状态是否合法。
其他注意事项
- Kafka producer 的幂等发送只能减少部分生产端的重复写入,不能代替业务幂等。
- Kafka exactly-once semantics 主要处理从 Kafka 读取、处理并写回 Kafka 的事务链路,无法自动实现普通关系数据库与 Kafka 的原子提交。
- 同一订单的事件可以使用
orderId作为 Kafka key,让它们进入同一分区并保持顺序。即使这样,数据库端仍然需要使用唯一约束或锁来处理并发。 - 消费失败时应抛出异常,让消息进入重试流程。不要捕获异常后正常返回,否则框架可能会提交 offset。
- 重试次数超过限制后,应将消息送入 DLQ,并保留
commandId、orderId、异常原因和原始 payload,以便人工补偿。 UNDO_PRODUCT_RESERVATION最好携带稳定的commandId,但最终约束仍应落在orderId对应的预留状态上,防止协调者错误地生成多个不同的commandId。
核心原则是:消息可以重复投递,业务状态不能重复迁移。只要库存变更、状态迁移和 Outbox 写入处于同一个数据库事务中,Product Service 就能准确判断上一次处理究竟是尚未完成、需要重试,还是已经完成、只需返回已有结果。
备注:内容仅供参考。
