
一个重复请求是如何让系统崩溃的
在实现秒杀/下单接口时,我发现一个很棘手的问题:
用户重复点击、网络重试、消息重复消费,都会导致同一请求被执行多次。一旦这些操作被执行多次,相应的数据在数据库就会保存多份,一下就带来了极大的后果。
于是我开始思考:系统该如何保证“请求只生效一次”?
这个系统如何抵御重复请求的问题,便是我们常说的幂等性问题。
下面,让我们一步步由浅入深,让一个系统真正做到幂等。
幂等概念
幂等性(Idempotency): 同一个请求执行一次和执行多次,结果必须一致
准确地说,当系统面对多个重复请求时,不会产生额外副作用。
比如,面对多个下单请求,数据库最终只会插入一条成功的订单。
幂等不只是一个模糊的概念,它也实实在在存在于我们的操作中:
| 操作类型 | 是否幂等 | 原因分析 |
|---|---|---|
| SELECT | 是 | 只读操作,不改变数据状态。 |
| DELETE | 是(通常) | 第一次删除成功,后续删除返回 404 或成功,数据状态不变。 |
| UPDATE (绝对值) | 是 | 如 set status = 2,无论执行多少次结果都一样。 |
| UPDATE (相对值) | 否 | 如 set score = score + 10,重复执行会导致数据错误。 |
| INSERT | 否 | 重复插入会导致主键冲突或产生多条重复记录。 |
问题拆解
重复请求会来自哪些操作呢?
其一般存在于:
- 用户行为
- 连续点击
- 表单重复提交
- 网络问题
- 超时重试
- 前端重发请求
- 系统机制
- MQ消息重复投递
- 重试机制
因为种种原因,重复请求是必然会存在的,我们在设计时,就要考虑好该怎么做幂等方案。
幂等性问题,本质上就是分布式系统不可避免的问题。
幂等方案
对于幂等控制,有五种主流实现路径,下面我们一一来看:
数据库唯一索引
原理: 利用数据库的唯一索引(unique index),在插入重复数据时强行拦截。
适用场景: 每条数据有唯一不重复标识时可用,对该标识加上唯一索引即可。常见的有:订单Id,用户account等诸如此类的数据。
去重表(Token 机制的变种)
原理:进入业务逻辑前,先往数据库的去重表插入一条记录,成功则继续,失败则回滚。
适用场景: 在正式创建订单前,先往数据库插入一条记录(如:订单Id + 操作类型Insert),再进入后续逻辑。这样,我们就能保证后续重复的请求被拦截再正式创建订单前。
状态机控制 (State Machine)
原理: 在数据库表中设计status字段,用update更新时校验状态是否正确,保证只有一个请求能update成功。
适用场景: 有流转状态的业务(如:待支付 -> 已支付),例如update order set status = 'PAID' where status = 'UNPAID'
,如果状态不对,数据库不会更新。
分布式锁 + 业务检查
原理: 类似于我们的双重检查锁,加锁前先校验是否已存在记录,加锁后再检查一次是否存在记录。加锁保证了请求是串行执行的,一个请求操作成功后续就不再操作。
适用场景: 低并发、强一致写入的场景,这个方案解决并发请求同时通过校验导致的重复写问题。
通用 Token 机制
原理: 服务端提供一个唯一Token,客户端在后续请求中接受这个Token后将这个Token失效。没有携带Token的请求不接收。
适用场景: 较为通用,但需考虑过期时间,业务失败等情况。
多方案结合
在实战中,我们多会组合使用上述方案。
以我自身的项目为例,使用到了Token机制(业务前) + 状态机(业务中) + 数据库唯一索引(业务最后)。
我们可以根据自身项目合理使用幂等控制方案。
Token机制实现
下面,我们来看看Token机制的流程图:
sequenceDiagram
autonumber
participant Client as 前端 (Client)
participant Server as 后端服务 (API Server)
participant Redis as Redis 缓存
Note over Client, Server: 第一阶段:申请 Token (进入页面或发起操作前)
Client ->> Server: 请求获取幂等 Token
activate Server
Server ->> Server: 生成全局唯一 UUID/Snowflake ID
Server ->> Redis: 存储 Token (设置过期时间,如5分钟)
activate Redis
Redis -->> Server: 存入成功
deactivate Redis
Server -->> Client: 返回 Token
deactivate Server
Note over Client, Server: 第二阶段:业务请求 (携带 Token)
Client ->> Client: 将 Token 放入请求头 (e.g., X-Idempotent-Token)
Client ->> Server: 发起业务操作请求 (如:提交订单)
activate Server
Note right of Server: 核心鉴权与原子性操作
Server ->> Redis: 尝试删除该 Token (利用 DEL 命令的原子性)
activate Redis
Redis -->> Server: 返回删除结果 (1-成功, 0-失败/不存在)
deactivate Redis
alt DEL 返回 1 (第一次请求,Token 存在且删除成功)
Server ->> Server: 执行业务处理逻辑 (如:扣减库存、创建订单)
alt 业务处理成功
Server -->> Client: 返回业务处理结果 (成功)
else 业务处理失败
Note right of Server: 可选:若失败,可考虑恢复 Token (重试机制)
Server -->> Client: 返回业务错误信息
end
else DEL 返回 0 (重复请求,Token 已被删除或过期)
Server ->> Server: 拦截请求,不执行业务逻辑
Server -->> Client: 返回错误响应 (如:请勿重复提交)
end
deactivate Server我们可以将这个Token方案利用AOP封装成一个注解,在接口上加上这个注解就能一键实现幂等性了。
自定义幂等注解
创建自定义注解:
1 |
|
AOP 切面:
1 |
|
Controller示例:
1 |
|
总结
幂等性并不是一个“锦上添花”的设计,而是系统一致性的底线保障。
很多系统的问题,并不是因为“做错了什么”,而是因为“多做了一次”。
一次重复请求:
可能多生成一笔订单,多扣减一次库存,然后引发资金问题。
而这些问题,往往难以在测试中暴露。
在真实系统中,我们无法避免:
- 用户的重复操作
- 网络的不稳定
- 消息的重复投递
这些问题不会消失,只会不断出现。
真正稳定的系统,不是“没有重复请求”,而是在重复请求发生时,依然能保证结果正确。
幂等性的本质,不是防重复,而是在不确定性中,保证系统行为的确定性。
当你开始认真设计幂等性,说明你已经从“写代码”,走向“做系统”了。