幂等性设计与实现方案
约 2179 字大约 7 分钟
distributedidempotency
2025-06-08
概述
幂等性(Idempotency)是分布式系统设计中的核心概念。一个操作如果执行一次和执行多次的效果完全相同,那么这个操作就是幂等的。在网络不可靠的分布式环境中,请求重试是常见的容错机制,如果操作不具备幂等性,重试可能导致数据不一致(如重复扣款、重复下单)。本文系统地介绍幂等性设计的各种方案。
幂等性定义
数学定义: f(f(x)) = f(x)
系统含义: 对同一个请求,执行1次和执行N次的效果相同
幂等: GET /users/123 → 每次返回相同结果
幂等: PUT /users/123 {name:"A"} → 无论执行几次,name都是"A"
幂等: DELETE /users/123 → 第一次删除成功,后续删除返回404(但效果相同)
非幂等: POST /orders → 每次执行都会创建新订单
非幂等: UPDATE balance = balance - 100 → 每次执行都会扣100天然幂等的操作
某些操作本身就是幂等的,不需要额外处理:
-- 幂等操作
SELECT * FROM users WHERE id = 1; -- 查询
UPDATE users SET name = 'Alice' WHERE id = 1; -- 绝对值更新
DELETE FROM users WHERE id = 1; -- 删除
-- 非幂等操作
INSERT INTO users (name) VALUES ('Alice'); -- 可能插入多条
UPDATE accounts SET balance = balance - 100; -- 相对值更新
UPDATE counters SET count = count + 1; -- 计数器幂等性实现方案
方案一:幂等令牌(Idempotency Key)
客户端为每个请求生成唯一的幂等键,服务端通过该键判断是否为重复请求。
@RestController
public class OrderController {
@Autowired
private RedisTemplate<String, String> redis;
@Autowired
private OrderService orderService;
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody CreateOrderRequest request) {
String cacheKey = "idempotency:" + idempotencyKey;
// 1. 检查是否已处理
String cached = redis.opsForValue().get(cacheKey);
if (cached != null) {
OrderResponse response = JsonUtils.deserialize(cached, OrderResponse.class);
return ResponseEntity.ok(response);
}
// 2. 使用分布式锁防止并发重复执行
String lockKey = "lock:idempotency:" + idempotencyKey;
boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
if (!locked) {
// 另一个相同请求正在处理中
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
try {
// 3. 二次检查(Double Check)
cached = redis.opsForValue().get(cacheKey);
if (cached != null) {
return ResponseEntity.ok(
JsonUtils.deserialize(cached, OrderResponse.class));
}
// 4. 执行业务逻辑
OrderResponse response = orderService.createOrder(request);
// 5. 缓存结果(设置过期时间,如24小时)
redis.opsForValue().set(cacheKey,
JsonUtils.serialize(response), Duration.ofHours(24));
return ResponseEntity.status(HttpStatus.CREATED).body(response);
} finally {
redis.delete(lockKey);
}
}
}方案二:数据库唯一约束
利用数据库的唯一索引来保证幂等性。
-- 创建唯一约束
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no) -- 唯一约束
);
-- 幂等插入:重复的order_no会触发唯一约束冲突
INSERT INTO orders (order_no, user_id, amount)
VALUES ('ORD-20250608-001', 1001, 99.00);
-- 第二次执行:Duplicate entry error → 说明已处理,直接返回@Service
public class OrderService {
public OrderResponse createOrder(CreateOrderRequest request) {
String orderNo = generateOrderNo(request);
try {
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderRepository.save(order);
return new OrderResponse(order);
} catch (DuplicateKeyException e) {
// 唯一约束冲突,说明订单已创建
Order existing = orderRepository.findByOrderNo(orderNo);
return new OrderResponse(existing);
}
}
private String generateOrderNo(CreateOrderRequest request) {
// 根据业务规则生成确定性的订单号
// 相同的请求应该生成相同的订单号
return "ORD-" + request.getUserId() + "-" + request.getProductId()
+ "-" + request.getTimestamp();
}
}方案三:状态机
利用有限状态机确保状态转换的合法性和幂等性。
@Service
public class OrderStateMachine {
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
OrderStatus.CREATED, Set.of(OrderStatus.PAID, OrderStatus.CANCELLED),
OrderStatus.PAID, Set.of(OrderStatus.SHIPPED, OrderStatus.REFUNDING),
OrderStatus.SHIPPED, Set.of(OrderStatus.COMPLETED),
OrderStatus.REFUNDING, Set.of(OrderStatus.REFUNDED)
);
@Transactional
public boolean transitState(String orderId, OrderStatus targetStatus) {
Order order = orderRepository.findByIdForUpdate(orderId); // SELECT ... FOR UPDATE
// 如果已经是目标状态,直接返回成功(幂等)
if (order.getStatus() == targetStatus) {
return true;
}
// 检查状态转换是否合法
Set<OrderStatus> allowedTransitions = TRANSITIONS.get(order.getStatus());
if (allowedTransitions == null || !allowedTransitions.contains(targetStatus)) {
throw new IllegalStateTransitionException(
order.getStatus() + " -> " + targetStatus + " is not allowed");
}
// 使用乐观锁更新
int updated = orderRepository.updateStatus(
orderId, targetStatus, order.getStatus(), order.getVersion());
if (updated == 0) {
throw new ConcurrentModificationException("Order was modified concurrently");
}
return true;
}
}方案四:乐观锁(版本号)
通过版本号控制并发更新,保证幂等性。
-- 表设计
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance DECIMAL(10,2),
version INT NOT NULL DEFAULT 0
);
-- 幂等更新:通过版本号控制
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1001 AND version = 5;
-- 如果version不是5,说明已经被其他操作修改过,更新失败
-- 检查受影响行数
-- affected_rows = 1: 更新成功
-- affected_rows = 0: 版本不匹配,可能是重复请求@Entity
public class Account {
@Id
private Long id;
private BigDecimal balance;
@Version // JPA乐观锁注解
private Integer version;
}
@Service
public class AccountService {
@Transactional
public void debit(Long accountId, BigDecimal amount, String requestId) {
// 先检查是否已处理
if (transactionLogRepository.existsByRequestId(requestId)) {
return; // 幂等:已处理过
}
Account account = accountRepository.findById(accountId)
.orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
try {
accountRepository.save(account); // 乐观锁校验
// 记录处理日志
transactionLogRepository.save(new TransactionLog(requestId));
} catch (OptimisticLockException e) {
throw new ConcurrentModificationException("Please retry");
}
}
}方案五:消息去重表
用于消息队列消费场景的幂等保障。
@Service
public class MessageConsumer {
@Autowired
private JdbcTemplate jdbcTemplate;
@KafkaListener(topics = "orders")
public void consume(ConsumerRecord<String, String> record) {
String messageId = record.key(); // 或从消息头获取
// 插入去重表(利用唯一约束)
try {
jdbcTemplate.update(
"INSERT INTO message_dedup (message_id, topic, created_at) VALUES (?, ?, NOW())",
messageId, record.topic());
} catch (DuplicateKeyException e) {
// 消息已处理过,直接返回
log.info("Duplicate message ignored: {}", messageId);
return;
}
// 首次收到,执行业务逻辑
try {
processMessage(record.value());
} catch (Exception e) {
// 处理失败,删除去重记录,允许重试
jdbcTemplate.update(
"DELETE FROM message_dedup WHERE message_id = ?", messageId);
throw e;
}
}
}CREATE TABLE message_dedup (
message_id VARCHAR(128) PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_created_at (created_at) -- 用于定期清理
);
-- 定期清理过期记录(7天前的)
DELETE FROM message_dedup WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY);方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 幂等令牌 | API接口通用方案 | 通用性强,与业务解耦 | 需要额外存储,需要客户端配合 |
| 唯一约束 | 插入操作 | 简单可靠 | 仅适用于插入场景 |
| 状态机 | 状态流转 | 业务语义清晰 | 需要精心设计状态图 |
| 乐观锁 | 并发更新 | 无需额外存储 | 高并发下冲突率高 |
| 消息去重表 | 消息消费 | 精确去重 | 需要维护去重表 |
最佳实践
// 综合方案:API层幂等令牌 + 数据库层唯一约束 + 状态机
@PostMapping("/payments")
public ResponseEntity<PaymentResponse> processPayment(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
// 第一层:幂等令牌(Redis)快速去重
String cachedResponse = redis.opsForValue().get("idem:" + idempotencyKey);
if (cachedResponse != null) {
return ResponseEntity.ok(deserialize(cachedResponse));
}
// 第二层:数据库唯一约束兜底
// 第三层:状态机保证状态转换合法性
PaymentResponse response = paymentService.process(request, idempotencyKey);
// 缓存结果
redis.opsForValue().set("idem:" + idempotencyKey,
serialize(response), Duration.ofHours(24));
return ResponseEntity.ok(response);
}核心原则:
- 分层防御:多层幂等保护,任何一层失效都有兜底
- 业务唯一标识:根据业务语义生成唯一标识(而非随机ID)
- 去重记录要有过期清理:避免去重存储无限增长
- 区分"正在处理"和"已完成":防止并发的相同请求
- 幂等返回一致:重复请求应返回与首次请求相同的响应
总结
幂等性是分布式系统可靠性的基石。不同的业务场景适合不同的幂等方案,实践中通常需要多种方案组合使用。幂等令牌是最通用的API级方案,数据库唯一约束是最可靠的数据级兜底,状态机则从业务语义上保证了操作的合法性。设计幂等系统时,始终要考虑网络重试、消息重复、并发竞争等分布式环境中的常见问题。
贡献者
更新日志
2026/3/14 13:09
查看所有更新日志
9f6c2-feat: organize wiki content and refresh site setup于