做全栈这些年,我踩过不少关于「重复提交」的坑:早期做电商,用户手抖点快了生成两张重复订单;后来做计费系统,网络超时重试导致账户被扣了两次钱。
很多刚入行的朋友容易走向两个极端:要么觉得在前端给按钮加个 loading 就万事大吉,要么觉得后端加个 Redis 锁就高枕无忧。其实,幂等性(Idempotency)从来不是某个单点能解决的技术,而是一条贯穿客户端、网络层、缓存、数据库的完整链路工程。
今天我想把这套全栈闭环的设计与落地拆开聊聊,顺便谈谈不同方案在实际生产环境中的取舍。
一、 什么是幂等:先对齐概念
HTTP 方法规范里对幂等有明确定义:同一个请求执行一次和执行多次,对系统资源产生的副作用是相同的。
GET、HEAD、PUT、DELETE 原则上天然具备幂等语义,而真正容易引发灾难的是 POST(比如创建订单、发起支付)。
落地全栈幂等,核心逻辑只有一句话:识别重复请求,并保证同一意图的请求在系统内只被有效处理一次,后续重复请求返回一致的业务结果。
二、 前端的「第一公里」:优化体验,收拢流量
前端的防重设计,目标从来不是「绝对安全」,而是「提升体验并替后端挡掉绝大多数无意识的无效并发」。
1. 交互层控制
最基础也最有效的手段是操作状态置灰(Loading),配合防抖(Debounce)。这能拦截 80% 以上因手抖或网络延迟导致的用户重复点击。
2. 请求层的全局契约注入
在发起可能产生副作用的请求时,前端需要主动生成并携带一个本次操作的全局唯一凭证 —— Idempotency-Key。
以 Axios 为例,我们可以在拦截器里对特定接口做统一处理:
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
const apiClient = axios.create({
baseURL: '/api/v1',
timeout: 10000,
});
apiClient.interceptors.request.use((config) => {
// 只针对写操作且未显式指定幂等键的请求注入 Key
if (['post', 'patch'].includes(config.method?.toLowerCase() || '')) {
if (!config.headers['Idempotency-Key']) {
// 如果是表单场景,推荐在表单初始化时生成该 Key,而非在点击提交时动态生成
config.headers['Idempotency-Key'] = uuidv4();
}
}
return config;
});
这里的取舍点在于:
Idempotency-Key 的生成时机非常关键。如果用户点击一次按钮生成一个新的 UUID,那这个防重就是伪命题;它应该与用户的业务操作会话绑定(比如打开创建订单弹窗时生成一次,提交失败重试时依然使用同一个 Key)。
三、 接口层的唯一契约设计
到达后端的第一站是契约的校验。服务端如何认定两次请求是「同一笔交易」?
通常有两种思路:
- 客户端透传业务唯一键:如前面提到的 Header 携带
Idempotency-Key。 - 服务端根据请求参数生成指纹:对
UserId + InterfaceName + RequestBody MD5进行特征提取。
在复杂系统里,推荐使用第一种方式,将主动权交给操作发起方。服务端可以用一个拦截器(或 Spring AOP、Node.js 中间件)统一处理:
// NestJS 风格的简易拦截逻辑示例
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(private readonly redisClient: RedisService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const req = context.switchToHttp().getRequest();
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
// 视业务严格程度决定是抛错还是放行
return next.handle();
}
const cacheKey = `idempotency:${req.user?.id || 'anon'}:${idempotencyKey}`;
// 检查是否已有处理结果
const cachedResponse = await this.redisClient.get(cacheKey);
if (cachedResponse) {
// 直接返回历史结果,不再向下执行业务代码
return of(JSON.parse(cachedResponse));
}
return next.handle().pipe(
tap(async (response) => {
// 将成功执行的结果缓存一段时间(例如 24 小时)
await this.redisClient.set(cacheKey, JSON.stringify(response), 'EX', 86400);
})
);
}
}
四、 后端防线一:分布式锁抵抗瞬时高并发
上面的代码有一个明显漏洞:如果两个携带相同 Key 的请求在毫秒级内同时到达,get 都会扑空,然后同时打进业务层。
这就是典型的瞬时并发击穿。此时需要引入分布式锁做串行化:
Client 1 ──> [获取锁成功] ──────> 执行核心业务 ──────> 写入执行结果 ──> 释放锁
Client 2 ──> [获取锁失败] ──────> 稍后重试 / 直接返回 "请求正在处理中"
在 Redis 中,使用原子操作:
SET resource_lock:order_123 requestId NX PX 5000
工程实践中的取舍与踩坑:
- 锁的粒度:不要锁大范围的接口,锁要精准到实体级(如
user_id + order_id)。 - 锁的超时时间:业务执行时间超过锁的超时时间,会导致锁被自动释放,第二个请求进入,引发严重并发问题。建议控制业务执行速度,若业务长,必须使用带看门狗(Watchdog)续期的机制(如 Redisson),或者改走异步任务机制。
- 释放锁的原子性:必须通过 Lua 脚本比对客户端 ID 与锁的值一致再释放,防止误删别人的锁。
五、 后端防线二:状态机与唯一索引(落地的兜底基石)
分布式锁和 Redis 缓存都是性能优化手段,数据库才是业务一致性的最后一道防线。一旦缓存集群发生故障或出现网络分区,底层的容错设计必须能抗住压力。
1. 数据库唯一索引(Unique Constraint)
在设计表结构时,提炼出具有业务唯一性的字段或联合字段,建立唯一索引。
- 比如支付单据表,将
source_order_no(来源订单号)和channel_code(渠道号)建立联合唯一约束。 - 再次插入时,数据库会抛出 Duplicate Key 异常。此时捕获异常,转而查询已有数据并返回,而不是直接让用户看到 500 报错。
2. 基于状态机的悲观/乐观更新
对于状态流转类业务(如从 CREATED 到 PAID),SQL 层面本身就能实现自然的幂等:
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = :orderId AND status = 'PENDING';
- 如果第一次执行成功,影响行数为 1。
- 如果网络波动导致重复提交,第二次执行时
status已经不是PENDING,影响行数为 0。 - 代码里判断
affected_rows == 0,则说明当前状态已被更新,直接走查询返回当前状态,既不报错也不重复执行逻辑。
六、 全流程闭环串联
把上述模块组合起来,一个健壮的幂等流转图如下:
- 页面初始化:前端生成针对本次提交的
biz_token或使用业务自然主键。 - 用户点击:前端置灰按钮,Axios 拦截器注入
Idempotency-Key。 - 网关 / 服务端拦截器:
- 尝试从 Redis 读取已处理结果:命中则直接响应(
Fast Return)。 - 未命中则尝试获取短效分布式互斥锁。
- 尝试从 Redis 读取已处理结果:命中则直接响应(
- 业务处理:
- 若拿锁失败,说明前序请求正在处理,返回状态码
409 Conflict或提示「请求处理中,请勿重复操作」。 - 若拿锁成功,开启本地事务,结合数据库唯一索引或状态机 SQL 完成持久化。
- 若拿锁失败,说明前序请求正在处理,返回状态码
- 收尾与释放:
- 核心事务提交成功。
- 将处理结果回写到 Redis(带 TTL)。
- 释放分布式锁。
七、 总结:做架构本质是做妥协
在做全栈幂等设计时,切忌过度工程。
- 如果业务是论坛点赞、内容阅读量统计: 前端加个防抖,后端用 Redis 做个短时 Set 去重就足够了,丢一两条数据不会影响公司的生存,没必要搞重型的事务和严格幂等。
- 如果业务涉及钱、交易、权限: 前端防重只能算礼貌性交互,后端必须严格遵循「唯一凭证 + 分布式锁排队 + 状态机约束 + 数据库唯一索引」的完整闭环。
技术方案没有高低,能把业务风险控制在可接受的成本范围内,才是合理的系统设计。
License: CC BY-NC 4.0
Updated 2 days ago
Was this article helpful? Give it a like.
0 comments


