幂等性解决方案

幂等性设计方案通常在分布式系统中,常见的幂等性设计方案如下:

1、唯一性约束

利用数据库的唯一性约束,如唯一索引或主键,来避免插入重复数据。

mysql> INSERT INTO mydb.`orders` (`order_id`, user_id, product_id, quantity, order_status, create_time, pay_time, version) VALUES ('ORD-20231023-0001', 'USR-A123456', 'PRD-X123', 2, 0, '2023-10-23 10:15:30', NULL, 1);

ERROR 1062 (23000): Duplicate entry 'ORD-20231023-0001' for key 'orders.PRIMARY'

注意:业务上要求生成全局唯一的主键。且不是自增策略,否则在分库分表的场景下,不同的表之间主键互不关联。

2. 乐观锁

  通过记录数据的版本号或时间戳,仅当数据未被其他事务修改时,才允许更新操作执行。每次更新数据时,版本号都会递增。

UPDATE orders

SET

quantity = 1,

order_status = 1,

pay_time = '2024-04-30 10:20:00',

version = version + 1

WHERE

order_id = 'ORD-20231023-0001' AND

version = 1;

效果演示:

如果 Session-01 已经提交了事务,Session-02 的更新操作将不会影响任何行,因为 version 已经从 1 增加到了 2。

3. 悲观锁

使用悲观锁,事务在读取数据时会锁定相应的数据行,直到事务结束(提交或回滚)。这可以防止其他事务在锁定期间修改这些数据,从而确保数据的一致性。

在执行读取操作时,使用 SELECT ... FOR UPDATE 语句来锁定相关记录。

-- 锁定记录

SELECT * FROM orders WHERE order_id = 'ORD-20231023-0001' FOR UPDATE;

-- 执行业务逻辑

UPDATE orders SET quantity = 1, order_status = 1, pay_time = '2023-10-23 10:20:00' WHERE order_id = 'ORD-20231025-0003';

效果演示:

由此可见,悲观锁确保每个事务也能安全地执行,而不会导致数据不一致的问题。但是,悲观锁可能会因为锁定机制而导致 性能问题 ,尤其是在高并发的系统中,这可能会引起 锁争用和死锁 

4. 分布式锁

在分布式系统中,使用分布式锁来保证同一时间只有一个实例处理特定消息或请求。

5. Token令牌机制


为每个请求生成一个唯一的Token,并在服务端进行校验,一旦处理了对应的请求,就丢弃该Token,避免重复处理。具体步骤:

1、服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。

2、然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。

3、服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。

4、如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行。

注意:最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。可以在 redis 使用 lua 脚本完成这个操作

6. 状态机

使用状态机是判断业务流程,确保操作只执行一次。

状态机设计:

订单创建:订单初始化,状态为 PENDING(待支付)。

支付操作:当订单状态为 PENDING 时,允许执行支付操作,支付成功后状态变为 PAID(已支付)。

重复支付检查:如果再次尝试支付一个已经是 PAID 状态的订单,状态机将拒绝该操作,保持订单状态不变。

public enum OrderStatus {

PENDING, PAID, CANCELLED

}

public class Order {

private OrderStatus status; // 订单当前状态

// 其他订单属性...

public Order() {

this.status = OrderStatus.PENDING; // 初始化状态为待支付

}

// 执行支付操作

public synchronized void pay() {

if (this.status == OrderStatus.PENDING) {

// 执行支付逻辑,如减少库存、扣款等

this.status = OrderStatus.PAID; // 状态转变为已支付

} else {

// 如果订单不是在待支付状态,抛出异常或记录日志

throw new IllegalStateException("Order can only be paid when status is PENDING");

}

}

// 其他业务逻辑...

}

幂等性保证:

  • 支付操作 pay 在订单状态不是 PENDING 时不会被执行,从而保证了幂等性。

  • 如果有重复的支付请求,由于状态机的保护,第二次及后续的支付请求将不会改变订单状态,因此不会执行重复的支付逻辑。

7. 去重表

  记录已经处理过的请求标识,对于重复的请求直接返回结果,而不再次执行业务逻辑。

1、去重表结构设计

表字段至少包括:

请求标识符:唯一标识一次请求。

创建时间:记录请求的时间戳。

处理状态:标识请求是否已处理,以及处理的结果。

2、设置过期策略

  为了防止去重表无限增长,表中的记录可以设置过期时间。使用定时任务定期清理旧的请求记录。

实现案例:

1、检查去重表

在执行业务逻辑之前,检查去重表确定该请求是否已经被处理过。

boolean isDuplicate = checkDuplicateInDatabase(requestId);

2、处理请求

if (isDuplicate) {

// 返回之前的结果或拒绝处理

return previousResult;

} else {

// 执行业务逻辑

doSomthing();

// 记录去重表

saveRecord(requestId);

// 返回新的结果

return newResult;

}

注意事项:

数据一致性:确保去重表的更新与业务逻辑的执行保持一致性,避免出现数据不一致的情况。

8. 全局请求唯一ID

调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。

可以使用 nginx 设置每一个请求的唯一 id;

proxy_set_header X-Request-Id $request_id;