幂等性
# 幂等性
# 什么是幂等性
接口幂等性指的是,无论对某个操作发起一次请求还是多次请求,最终结果都是一致的,并且不会因为多次执行该操作而产生副作用。例如,在支付场景中,用户发起支付请求时,如果发生网络异常导致客户端没有收到支付成功的确认,用户可能会再次点击支付按钮。如果系统没有幂等性保证,可能会导致重复扣款的情况。幂等性保证可以防止这种重复操作的副作用。
# 幂等性的重要性
- 用户体验:避免用户因为重复提交操作而产生不必要的混乱,比如订单重复支付、重复提交表单等。
- 系统稳定性:确保多次重复请求不会对系统资源产生额外的负担,从而提高系统的稳定性和响应速度。
- 业务一致性:防止因为重复请求而破坏业务逻辑,导致数据不一致或重复处理业务。
# 哪些场景需要防止重复操作
- 用户多次点击按钮:用户可能由于网络延迟或误操作,连续多次点击某个提交按钮,导致系统处理了多次相同的请求。
- 用户页面回退再次提交:用户提交表单后可能点击浏览器的“返回”按钮并再次提交,系统会收到重复的请求。
- 微服务间调用:在微服务架构中,服务之间的调用请求可能由于网络问题或服务端异常而出现重复请求的情况。
- Feign 或其他客户端重试机制:某些 HTTP 客户端(如 Feign)会自动重试请求,可能会导致同一个请求被多次执行。
- 其他业务场景:例如在支付、库存管理等需要保证唯一性的业务场景中,重复处理可能会带来严重的问题。
# 幂等操作的分类
# 天然幂等的操作
有些操作本身就具备幂等性,不需要额外的机制来保证其幂等性。以下是一些天然幂等的数据库操作示例:
- 查询操作:例如
SELECT * FROM table WHERE id = 1
,每次查询的结果都不会改变,不论执行多少次都是相同的结果。 - 更新固定值:如
UPDATE tab1 SET col1 = 1 WHERE col2 = 2
,每次执行都会将col1
更新为 1,无论执行多少次,结果都是一致的。 - 删除操作:如
DELETE FROM user WHERE userid = 1
,删除操作每次执行的结果都是相同的,即使多次执行也不会改变最终结果。 - 唯一插入操作:如
INSERT INTO user(userid, name) VALUES (1, 'a')
,如果userid
是主键或唯一索引,那么即使多次执行插入操作,也只能插入一条数据,后续的插入会失败。
# 非幂等的操作
有些操作天然不具备幂等性,需要通过额外的机制来保证幂等性。以下是一些非幂等的操作示例:
- 自增操作:如
UPDATE tab1 SET col1 = col1 + 1 WHERE col2 = 2
,每次执行会让col1
增加 1,每次执行的结果都不一样,因此该操作不具备幂等性。 - 非唯一插入操作:如
INSERT INTO user(userid, name) VALUES (1, 'a')
,如果userid
不是唯一约束,则多次执行插入操作会导致数据库中出现多条重复数据,无法保证幂等性。
# 幂等性解决方案
在实际系统中,保证幂等性可以通过多种技术手段来实现,以下是几种常用的解决方案,每种方案根据具体场景的不同适用性会有所不同。
# 1. Token 机制
Token 机制是通过为每个请求生成一个唯一标识符(即 Token)来确保每个请求只会被处理一次,从而保证幂等性。它适用于那些不易产生重复数据的场景,比如表单提交、支付操作等。
实现步骤:
获取 Token:
- 客户端在发起业务请求之前,首先向服务器申请一个唯一的 Token。服务器生成这个 Token 并将其存储在 Redis 中,设置一定的过期时间。
- 客户端拿到 Token 后,在接下来的业务请求中,将该 Token 附加在请求头中发送给服务器。
携带 Token 请求:
- 当客户端发起实际的业务请求时,Token 会被包含在请求中发送给服务端。
服务端验证 Token:
- 服务端接收到请求后,首先从 Redis 中检查 Token 是否存在。
- Token 存在:说明是首次请求,删除该 Token 并执行业务逻辑。
- Token 不存在:说明是重复请求,直接返回重复请求提示,避免再次处理该业务。
- 服务端接收到请求后,首先从 Redis 中检查 Token 是否存在。
注意事项:
- Token 获取、验证和删除必须是原子操作:在高并发场景下,多个请求可能同时进行 Token 检查,因此必须保证 Token 的获取、验证和删除是原子性的,以防止并发问题。
- 可以通过 Redis 的 Lua 脚本实现这一操作,保证多个操作在同一个事务中执行,确保原子性。
Redis Lua 脚本示例:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
2
3
4
5
风险与最佳实践:
先删除 Token 还是后删除 Token:
- 如果先删除 Token,可能会导致业务未执行成功,但 Token 已经被删除,用户重试时无法再次请求。
- 如果后删除 Token,可能会导致业务执行成功但 Token 没有被删除,用户重试时业务会被重复执行。
最佳实践:建议在业务执行前删除 Token,确保如果业务失败,重新请求时可以重新获取 Token。
# 2. 数据库锁机制
# 悲观锁
悲观锁是一种在并发控制中使用的机制,它通过锁定数据库中的记录,防止其他事务访问被锁定的资源,确保同一时间只能有一个事务处理该资源。适用于并发较低的场景。
实现方式:
- 在查询或更新数据时使用
FOR UPDATE
,该命令会锁定查询的行,防止其他事务修改该行的数据,直到当前事务提交或回滚。
示例:
SELECT * FROM table WHERE id = 1 FOR UPDATE;
优缺点:
- 优点:简单直接,确保数据的唯一性和正确性。
- 缺点:会锁定资源较长时间,在并发较高的情况下可能会导致性能问题,容易引起死锁。
注意事项:
- 使用悲观锁时,查询字段必须是主键或唯一索引,避免锁表或锁太多数据,导致系统性能下降。
# 乐观锁
乐观锁是一种并发控制的机制,它不会锁定数据,而是在提交更新时检查数据是否被其他事务修改过。适合读多写少的场景。
实现方式:
- 在更新数据时,除了指定数据的条件外,还附加一个版本号,只有在版本号匹配的情况下才能更新数据。每次更新成功后,版本号都会递增,防止重复更新。
示例:
UPDATE t_goods SET count = count - 1, version = version + 1 WHERE good_id = 2 AND version = 1;
原理:在执行更新时,检查版本号是否匹配。如果版本号匹配,则说明数据未被修改过,更新操作成功;如果版本号不匹配,则更新操作会失败,从而保证幂等性。
优缺点:
- 优点:适合高并发场景,不会长时间锁定资源。
- 缺点:适用于写少读多的场景,在并发写入较多时可能会导致较多的重试和失败。
# 3. 分布式锁
分布式锁用于在分布式系统中确保不同节点不会同时处理同一个资源。适合多个节点可能同时处理相同业务的场景,例如多个定时任务同时处理同一批数据。
实现方式:
- 使用 Redis 的
SETNX
命令实现分布式锁,只有一个节点能成功获取锁,其他节点需要等待锁被释放后才能继续操作。 - 在处理业务时,首先通过分布式锁锁定资源,业务处理完成后释放锁。
示例:
SET resource_lock "locked" NX EX 10
NX
确保只有当锁不存在时才能获取锁。EX 10
设置锁的过期时间,防止锁因异常情况无法释放。
注意事项:
- 需要处理锁的过期时间和解锁逻辑,防止锁未能及时释放导致的死锁问题。
- 加锁和解锁的操作必须有明确的错误处理逻辑,确保系统不会在意外情况下持有锁过长时间。
# 4. 数据库唯一约束
通过设置数据库的唯一索引,确保某些业务场景下的数据插入是唯一的,从而防止重复插入。这种方法适合需要确保某个字段唯一的业务场景,比如订单号、用户名等。
实现方式:
- 在数据库中为某个字段(如订单号)设置唯一约束,保证同一个订单号只能插入一次。
示例:
CREATE TABLE orders (
order_no VARCHAR(50) NOT NULL,
user_id INT,
amount DECIMAL(10, 2),
PRIMARY KEY (order_no)
);
2
3
4
5
6
当 order_no
为主键或唯一索引时,重复插入相同订单号的数据会失败,确保幂等性。
注意事项:
- 在分库分表的场景下,确保数据能够落到相同的数据库实例和表中,否则不同的数据库实例可能无法保证唯一性。
# 5. Redis Set 防重
利用 Redis Set 结构的天然去重特性,可以有效避免数据的重复处理。在某些需要确保数据唯一处理的场景中,如任务调度、异步处理等,适合使用此方案。
实现方式:
- 每次处理数据时,先将数据标识(如 MD5)存入 Redis 的 Set 集合中,判断该标识是否已经存在。如果标识已经存在,则跳过处理,否则继续处理。
示例:
if redis.call('sismember', 'processed_set', 'data_md5') == 0 then
redis.call('sadd', 'processed_set', 'data_md5')
-- 执行业务逻辑
end
2
3
4
sismember
:检查某个标识是否在 Set 集合中。sadd
:将标识加入到 Set 中,表示该数据已经被处理。
优缺点:
- 优点:性能高效,适合高并发场景,天然去重。
- 缺点:需要为每条数据计算唯一标识(如 MD5),同时需要合理设计 Redis 中数据的过期时间,防止占用太多内存。
# 6. 防重表
通过创建专门的防重表,利用数据库的唯一性约束来防止重复请求。在业务处理之前,先将唯一标识(如订单号)插入防重表中,确保请求只会处理一次。
实现方式:
- 在数据库中创建防重表,业务处理前先将请求的唯一标识插入该表,插入成功则继续处理业务;插入失败则表明该请求已经处理过,直接返回。
- 防重表和业务表需要在同一个数据库中,以确保事务的一致性。
示例:
INSERT INTO idempotent_table (request_id) VALUES ('unique_request_id');
注意事项:
- 防重表与业务表应在同一个事务中处理,确保幂等操作和业务操作的一致性。
- 在高并发场景下,防重表的性能可能会成为瓶颈,需进行合理优化。
# 7. 全局请求唯一 ID
为每次请求生成一个唯一 ID,可以使用 Nginx 或应用程序生成一个唯一的请求标识符,每次请求带上该 ID,在服务端进行去重判断。
实现方式:
- 使用 Nginx 配置,每次请求时为其分配一个唯一的
X-Request-Id
,并在后端通过 Redis 存储该 ID,确保同一个 ID 只会处理一次。
Nginx 配置示例:
proxy_set_header X-Request-Id $request_id;
在服务端:
- 请求到达时,首先检查 Redis 中是否存在该 ID,如果存在则返回重复请求提示;如果不存在则将该 ID 存入 Redis 并处理请求。
总结
幂等性是确保系统稳定性和数据一致性的重要设计原则。不同的业务场景需要不同的幂等性解决方案,如 Token 机制、数据库锁机制、分布式锁、Redis 去重等。通过合理选择和设计,可以有效防止重复请求带来的副作用,确保系统的稳定运行。