后端必须掌握的幂等性设计:从重复请求到系统稳定
SryYa Three

一个重复请求是如何让系统崩溃的

在实现秒杀/下单接口时,我发现一个很棘手的问题:

用户重复点击、网络重试、消息重复消费,都会导致同一请求被执行多次。一旦这些操作被执行多次,相应的数据在数据库就会保存多份,一下就带来了极大的后果。

于是我开始思考:系统该如何保证“请求只生效一次”?

这个系统如何抵御重复请求的问题,便是我们常说的幂等性问题

下面,让我们一步步由浅入深,让一个系统真正做到幂等。


幂等概念

幂等性(Idempotency): 同一个请求执行一次和执行多次,结果必须一致

准确地说,当系统面对多个重复请求时,不会产生额外副作用。

比如,面对多个下单请求,数据库最终只会插入一条成功的订单。

幂等不只是一个模糊的概念,它也实实在在存在于我们的操作中:

操作类型是否幂等原因分析
SELECT只读操作,不改变数据状态。
DELETE是(通常)第一次删除成功,后续删除返回 404 或成功,数据状态不变。
UPDATE (绝对值)set status = 2,无论执行多少次结果都一样。
UPDATE (相对值)set score = score + 10,重复执行会导致数据错误。
INSERT重复插入会导致主键冲突或产生多条重复记录。

问题拆解

重复请求会来自哪些操作呢?

其一般存在于:

  1. 用户行为
    • 连续点击
    • 表单重复提交
  2. 网络问题
    • 超时重试
    • 前端重发请求
  3. 系统机制
    • 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
2
3
4
5
6
7
8
9
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
// 这里的 token 会在请求头中传递
String headerKey() default "X-Idempotent-Token";

// 过期时间(秒)
int expireSeconds() default 300;
}

AOP 切面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Aspect
@Component
public class IdempotentAspect {

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// 1. 从请求头获取 Token
HttpServletRequest request = getRequest();
String token = request.getHeader(idempotent.headerKey());

if (StringUtils.isEmpty(token)) {
throw new RuntimeException("非法请求:Token 缺失");
}

// 2. 利用 Redis 的原子性删除 Token
// del 返回 true 代表删除成功,说明是第一次请求
// del 返回 false 代表删除失败,说明 Token 不存在或已使用
Boolean isFirstRequest = redisTemplate.delete(token);

if (Boolean.TRUE.equals(isFirstRequest)) {
// 3. 执行业务逻辑
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
// 可选:如果业务执行报错,可以自定义是否回滚Token,可以据此自定义重试逻辑
// redisTemplate.opsForValue().set(token, "1", ...);
throw throwable;
}
} else {
// 4. 重复提交处理
throw new RuntimeException("请勿重复操作,您的请求正在处理中...");
}
}

private HttpServletRequest getRequest() {
return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
}
}

Controller示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/order")
public class OrderController {

@Autowired
private TokenService tokenService;

// 获取 Token 的接口
@GetMapping("/token")
public String getSubmitToken() {
return tokenService.generateToken(); // 内部生成 UUID 并存入 Redis
}

// 业务接口,加上注解即可实现幂等
@PostMapping("/submit")
@Idempotent // 默认拦截 X-Idempotent-Token
public Result submitOrder(@RequestBody OrderDTO order) {
// 执行创建订单、扣减库存逻辑
return Result.success("订单创建成功");
}
}

总结

幂等性并不是一个“锦上添花”的设计,而是系统一致性的底线保障。

很多系统的问题,并不是因为“做错了什么”,而是因为“多做了一次”。

一次重复请求:
可能多生成一笔订单,多扣减一次库存,然后引发资金问题。

而这些问题,往往难以在测试中暴露。

在真实系统中,我们无法避免:

  • 用户的重复操作
  • 网络的不稳定
  • 消息的重复投递

这些问题不会消失,只会不断出现。

真正稳定的系统,不是“没有重复请求”,而是在重复请求发生时,依然能保证结果正确

幂等性的本质,不是防重复,而是在不确定性中,保证系统行为的确定性

当你开始认真设计幂等性,说明你已经从“写代码”,走向“做系统”了。

由 Hexo 驱动 & 主题 Keep
总字数 28.1k 访客数 访问量