一、面试现场:二面被问懵的那道题
先说背景。简历上写着“熟悉高并发”“做过分布式项目”“熟悉 Redis 和 MySQL”,本以为一面聊项目还算顺利,结果到了阿里二面,面试官上来就是一句:
“如果现在有一个下单接口,用户在支付宝支付成功回调时网络超时重试了三次,或者用户在页面上一秒内连续点了五次提交按钮,你的系统怎么保证订单不会重复创建?”
我当时的回答大致是:先查一下订单号是否存在,不存在再插入。面试官追问:
“那如果两个请求同时查,都发现不存在,然后同时插入呢?如果业务已经分库分表了,唯一索引还能保证全局唯一吗?如果用了 Redis 锁,锁超时了但业务还没执行完怎么办?如果消息队列重复投递怎么办?”
几个追问下来,我基本被打回了“只会背八股”的原形。回来后我把这道题完整复盘了一遍,发现它根本不是一道单点题,而是一整套贯穿“前端、网关、数据库、缓存、消息队列、幂等设计”的体系题。下面我把这套体系完整拆开,尽量写到面试能扛住追问的程度。
先说明一点:本文所有示例以Java + Spring Boot + MySQL + Redis这套阿里最常用的技术栈为主,但所有思路同样适用于其他语言和中间件。下文代码都是可以落地的简化版示例,核心是讲清楚“为什么这样设计”和“每种方案的边界在哪里”。
二、先把问题拆解清楚:什么才算“数据重复”
很多人一上来就聊“去重”,但连“重复”的定义都没对齐。在生产环境里,“防重复”至少包含下面几层含义:
- 同一业务操作的重复执行:同一个用户、同一笔订单、同一个支付流水,被重复提交或重复回调,导致产生两条业务记录。
- 唯一性约束被破坏:例如订单号重复、用户手机号重复注册、优惠券被同一用户重复领取。
- 非幂等写操作被重复触发:例如“余额加 100”执行两次变成了加 200,而业务上只应该加一次。
- 消息重复消费:消息队列为了保证可靠性,在消费失败或网络抖动时会重投,导致同一条消息被处理两次。
这四类问题看似相同,解决方案却不一样:唯一性约束靠数据库兜底,幂等靠业务状态和唯一键设计,消息重复消费靠消费端去重表,而纯并发抢锁靠分布式锁。面试时如果能先把问题分类,会比直接背方案高出不少分。
下面给出本文的第一个核心结论:
防重复没有银弹,真正的生产方案一定是“多层防线组合”。前端防重提升体验,接口幂等保证正确,唯一索引做最终兜底,分布式锁解决并发串行化,幂等表解决消息重投。每一层都只能挡住一部分问题,缺一层就可能漏。
三、为什么高并发下会重复:从时间线看竞争条件
先看一段最常见的“先查再插”代码,这也是我面试时的第一版回答:
public Order createOrder(OrderDTO dto) { // 先查订单是否已存在 Order exist = orderMapper.selectByOrderNo(dto.getOrderNo()); if (exist != null) { return exist; // 存在就直接返回,不重复创建 } // 不存在则插入 Order order = new Order(); order.setOrderNo(dto.getOrderNo()); orderMapper.insert(order); return order; }单线程、低并发下这段代码看起来没问题。但它的致命弱点是:“查询”和“插入”是两个独立操作,中间没有原子性保护。用时间线表示如下:
| 时间点 | 请求 A | 请求 B |
|---|---|---|
| T1 | selectByOrderNo,查到不存在 | |
| T2 | 准备 insert | selectByOrderNo,查到不存在 |
| T3 | insert 成功 | insert 也执行 |
| T4 | 订单数:1 | 订单数变成 2,重复了 |
这就是典型的TOCTOU(Time Of Check To Time Of Use)竞争条件:检查时是一个状态,使用时状态已经变了。并发越高,这个窗口被命中的概率越大。更糟的是,在分布式环境下,两个请求甚至可能落在两台不同的机器上,单机的 synchronized 都不管用,因为它们连同一把 JVM 锁都拿不到。
所以要彻底防重复,必须从三个维度同时考虑:
- 纵向:同一个流程里,检查与写入要原子化。
- 横向:多个服务实例之间,要有一把全局唯一的“门闩”。
- 时间:重复可能不是同时发生,而是相隔几秒、几分钟甚至重试多次,因此需要“幂等”而不是只防“并发”。
四、方案总览:防重复的技术全景图
为了让你先有一个整体地图,我把常见的防重复手段按“从外到内、从软到硬”整理成下表。后文会逐个展开,并给出完整代码。
| 层级 | 手段 | 解决的问题 | 是否可靠兜底 |
|---|---|---|---|
| 前端 | 提交按钮置灰、防抖、防连点 | 普通用户手滑连点 | 不可靠,可绕过 |
| 网关/服务端 | Token 机制、请求唯一 ID 去重 | 表单重复提交、接口重放 | 中等,需配合存储 |
| 数据库 | 唯一索引、唯一约束 | 业务主键重复、手机号重复等 | 非常可靠,最终兜底 |
| 数据库 | 悲观锁、乐观锁 | 同一行数据的并发更新 | 可靠,影响并发度 |
| 缓存 | Redis 分布式锁、Redisson | 跨服务的临界区互斥 | 可靠,需处理锁超时 |
| 业务 | 幂等表、状态机、版本号 | 重复请求返回同一结果 | 可靠,业务侵入性较高 |
| 消息 | 消费端去重表、业务唯一键 | 消息重复投递/重复消费 | 可靠,需落库或缓存 |
| ID 生成 | 雪花算法、号段、UUID | 全局唯一 ID,支撑唯一键 | 依赖自身实现质量 |
这张表背下来不难,难的是面试官追问“为什么不只用其中一种”。答案的核心是:每一层都有失效条件,必须组合。例如前端可以绕过,网关去重需要考虑存储成本,唯一索引在分库分表后局部唯一而不是全局唯一,Redis 锁有超时和主从切换问题。理解了这些边界,你就能把八股答成架构思考。
五、第一道防线:前端防重与网关去重
5.1 前端按钮防重
前端防重是成本最低、体验最好的一层。最经典的做法是提交后立刻置灰按钮并加 loading,减少手滑连点:
let submitting = false; function submitOrder() { if (submitting) return; submitting = true; document.getElementById("submitBtn").disabled = true; fetch("/api/order", { method: "POST", body: JSON.stringify(data) }) .finally(() => { submitting = false; document.getElementById("submitBtn").disabled = false; }); }但前端防重是“防君子不防小人”:用户可以关掉 JS、用脚本直接调接口、或者在弱网下连续重试。所以它只能作为体验层,不能作为正确性保证。面试时一定要主动说出这一点,避免让面试官觉得你天真地依赖前端。
5.2 接口层幂等 Token
比前端更可靠一点的,是服务端在发起表单前先下发一个一次性 Token。浏览器进入下单页时,后端生成 Token 并放入 Redis;真正提交时,请求必须携带 Token,服务端用原子操作校验并删除 Token,删成功才执行业务。
@RestController public class OrderController { @Autowired private StringRedisTemplate redis; // 进入下单页时下发 token @GetMapping("/api/order/token") public String getToken(String userId) { String token = UUID.randomUUID().toString(); redis.opsForValue().set("order:token:" + token, userId, 10, TimeUnit.MINUTES); return token; } @PostMapping("/api/order") public Result createOrder(@RequestBody OrderDTO dto) { String token = dto.getToken(); // 用 setIfAbsent + 过期时间,保证删除动作的原子性 Boolean deleted = redis.opsForValue() .getOperations() .delete("order:token:" + token); if (deleted == null || !deleted) { return Result.fail("请勿重复提交"); } // 执行业务 return orderService.createOrder(dto); } }这种方案有两个细节必须注意:
- 校验与删除必须原子:如果先查再删,两个并发请求可能同时查到 Token 存在,然后都执行下去。上面的 delete 动作天然具备原子性,删成功的那一个才继续。
- Token 校验成功之后业务失败怎么办:如果业务执行到一半挂了,用户重试时 Token 已经没了,就会无法重试。因此 Token 机制一般只适合“防重复点击”,真正的业务幂等还要靠后文的唯一键或幂等表兜底。
5.3 网关层全局请求去重
如果是开放平台或对外接口,可以在网关层给每个请求生成 requestId 并做短窗口去重。例如用 Redis 记录“最近 5 分钟内处理过的 requestId”,命中则直接返回上一次结果或 429。但这类方案要注意存储成本和去重窗口的取舍,一般不作为核心兜底,只用于限流和防攻击。
六、第二道防线:数据库唯一约束,最硬核的兜底
无论前面多少层防线,数据库唯一索引都是防重复最后的底线。它的优势非常明显:由数据库引擎保证,不依赖业务代码是否正确、不依赖服务是否宕机、不依赖锁是否释放。其他任何方案挂掉后,唯一索引仍然能兜住。
以订单表为例,给 order_no 建唯一索引:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL COMMENT '订单号', user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;当两个并发请求同时插入相同 order_no 时,InnoDB 会保证只有一个成功,另一个抛出DuplicateKeyException。业务代码捕获这个异常后,把已存在的订单查出来返回即可:
public Order createOrderWithUniqueIndex(OrderDTO dto) { try { Order order = new Order(); order.setOrderNo(dto.getOrderNo()); order.setUserId(dto.getUserId()); order.setStatus(0); orderMapper.insert(order); return order; } catch (DuplicateKeyException e) { // 唯一索引冲突,说明已经有这笔订单,返回已存在订单 return orderMapper.selectByOrderNo(dto.getOrderNo()); } }这段代码虽然看起来和“先查再插”很像,但本质完全不同:它不再依赖查询去判断,而是让数据库并发插入时的唯一索引替我们做判断。即使一万个请求同时打进来,也只有一个能插入成功。这里顺便回答一个高频追问:为什么不能用“insert ... where not exists”或者“先查后插”?因为那些写法在高并发下依然没有原子性,而唯一索引是在存储引擎层完成冲突检测。
6.1 唯一索引的局限
唯一索引当然不是万能的,面试时主动说出局限会很加分:
- 只能约束列的唯一,不能约束“同一业务请求重复执行”:例如充值 100 元,唯一索引只能保证充值流水号不重复,如果业务没把流水号设成唯一,它管不了重复入账。
- 分库分表后不再全局唯一:如果订单表按 user_id 分了 16 张表,单表的 order_no 唯一索引只保证分片内唯一,跨分片仍可能重复。此时需要把唯一性检查收敛到“全局表”或用一致性哈希把同一业务键路由到同一分片。
- 对 UPDATE 类非幂等操作无效:余额加 100 重复执行两次,唯一索引拦不住,因为两次更新的是同一行而不是插入新行。
- 可能造成锁等待和死锁:大量并发插入相同键时,后到的请求会在索引上排队,极端情况下还可能与其他事务形成死锁。
6.2 INSERT IGNORE 与 ON DUPLICATE KEY UPDATE
MySQL 还提供了两种对抗重复的写法,但要理解它们各自的副作用:
-- 冲突则忽略,返回 0 行受影响 INSERT IGNORE INTO t_order(order_no, user_id, amount) VALUES ('1001', 1, 99.00); -- 冲突则执行更新 INSERT INTO t_order(order_no, user_id, amount, status) VALUES ('1001', 1, 99.00, 0) ON DUPLICATE KEY UPDATE status = 0;其中INSERT IGNORE会忽略所有错误而不只是重复键错误,生产上要谨慎使用。ON DUPLICATE KEY UPDATE适合“存在则更新”的场景,但每次冲突都会触发更新,在高频重复写入时可能放大锁竞争。对于“只想要第一笔生效、后续请求原样返回”的幂等场景,前面“插入失败后查询返回”的做法更干净。
七、第三道防线:悲观锁与乐观锁
唯一索引解决的是“不能多插”,但对“同一行数据不能被并发改错”的场景,需要锁机制。经典问题就是扣减库存:
// 错误写法:先查再改,并发下会超卖 Stock stock = stockMapper.selectById(skuId); if (stock.getCount() >= buyCount) { stock.setCount(stock.getCount() - buyCount); stockMapper.updateById(stock); }两个线程同时读到库存 10,都判断可以各买 6 件,最终库存可能被扣成 -2。这就不是“重复创建”而是“重复扣减”,本质也是并发下非原子判断导致的错误。
7.1 悲观锁:SELECT ... FOR UPDATE
悲观锁认为冲突一定会发生,读取时就锁住数据,直到事务结束才释放。MySQL 中在事务内使用SELECT ... FOR UPDATE:
@Transactional public boolean deduct(Long skuId, int buyCount) { Stock stock = stockMapper.selectForUpdate(skuId); // for update if (stock.getCount() < buyCount) { return false; } stockMapper.updateCount(skuId, stock.getCount() - buyCount); return true; }SELECT * FROM t_stock WHERE sku_id = #{skuId} FOR UPDATE;悲观锁保证同一行同一时刻只有一个事务能操作,逻辑简单且绝对安全。代价是并发度低——其他事务即使只做查询,也必须等锁释放之后才能访问同一行。另一个容易被忽略的问题是:悲观锁的边界是数据库事务,如果事务内混入 Redis 调用、RPC 调用等长耗时操作,事务会被拉长,锁的持有时间也会随之变长,反而进一步放大阻塞。所以悲观锁更适合锁窗口短、竞争集中、正确性优先的场景,例如库存扣减、账户余额变更。
7.2 乐观锁:版本号与条件更新
乐观锁的思路相反:它假定冲突概率不高,读取时不加锁,更新时才检查数据有没有被别的事务动过。最常见的实现就是给表加一个 version 字段,更新时带上旧版本号做条件:
UPDATE t_stock SET count = count - #{buyCount}, version = version + 1 WHERE sku_id = #{skuId} AND count >= #{buyCount} AND version = #{oldVersion};如果影响行数为 0,说明版本已经变了,本次更新失败,业务层可以直接返回失败,也可以根据业务需要做有限次重试:
public boolean deductStock(Long skuId, int buyCount) { int updated = stockMapper.deductStock(skuId, buyCount); // 影响行数为 0,说明库存不足或版本冲突 return updated > 0; }乐观锁的优点是不会长时间阻塞,读多写少、冲突概率低的场景下吞吐量更高。但它也有明显局限:冲突时只能靠重试,热点行高并发下容易造成大量无效重试;此外,乐观锁本质上只是“更新时的条件校验”,如果业务还需要在库存扣减后执行发消息、写流水等后续动作,单靠一条 UPDATE 仍然无法保证整个链路只执行一次。所以乐观锁通常要和唯一索引、幂等表配合使用,而不是二选一。
八、第四道防线:Redis 分布式锁
前面聊的悲观锁、乐观锁都建立在数据库事务之上。一旦两个请求落在不同服务实例,JVM 锁和单库行锁就都不够用了,这时需要在多实例之间维护一把“全局唯一的门闩”,最常用的就是 Redis 分布式锁。
先看一个基础版本,核心是 SET key value NX PX:NX 保证只有一个实例设置成功,PX 设置过期时间防止客户端宕机后锁永不释放:
public boolean tryLock(String key, String requestId, long expireMs) { Boolean ok = redis.opsForValue().setIfAbsent( key, requestId, expireMs, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); } public void unlock(String key, String requestId) { // 必须校验 requestId,防止误解别人持有的锁 String current = redis.opsForValue().get(key); if (requestId.equals(current)) { redis.delete(key); } }上面这段代码有两个面试必问的坑:
- 解锁必须校验持有者:如果不比较 value 就直接 delete,A 业务还没执行完锁就过期,B 拿到锁后,A 再执行 delete 就会把 B 的锁误删。给锁设置唯一 value(如 UUID + 线程名)是最低成本的防护。
- “持有者校验 + 删除”不是原子操作:极端情况下,校验通过后锁刚好过期,另一个请求又拿到了锁,还是会发生误删。生产上正确做法是用 Lua 脚本把比较和删除合成一个原子步骤,或者直接使用 Redisson 这类封装良好的客户端。
-- 原子解锁 Lua 脚本 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end还有一个更隐蔽的问题是锁超时:如果业务执行时间超过锁的过期时间,锁会在业务完成前自动释放,第二个请求就能提前进入临界区,分布式锁形同虚设。Redisson 的看门狗(Watchdog)会定期给锁续期,能够缓解一部分问题,但它本质是在延长锁持有时间,并不能从根本上解决“业务执行时长不可控”。因此正确认知应该是:分布式锁只负责“并发串行化”,真正的“只执行一次”仍然要交给唯一索引或幂等表兜底。
如果面试官继续追问 Redis 主从切换,可以回答:普通 Redis 锁在主节点宕机、从节点刚被提升的极短窗口内可能丢失锁数据,RedLock 试图通过多节点半数以上成功来降低这种概率,但社区对 RedLock 的必要性一直存在争议。对大多数业务来说,先保证锁有合理超时,再用数据库唯一索引兜底,比纠结 RedLock 的实现细节更实际。
九、第五道防线:幂等表与消息去重
行锁和分布式锁都只能解决“同一时刻的并发”,但生产中的重复更多是相隔一段时间的重试:用户隔几秒再点一次、支付回调超时后重推、MQ 消费失败后重投。这些场景需要的是幂等——同一个业务键不管来多少次,结果都只生效一次。
最通用的落地方案是幂等表。核心思想是在业务记录之外,单独维护一张记录“哪个业务键已经处理过”的表,并给它建唯一索引:
CREATE TABLE t_idempotent ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型', biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一键,如订单号/请求号', status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;处理流程是:先尝试往幂等表插入一条记录,插入成功才继续执行真正的业务;插入冲突说明这个请求已经处理过,直接查询并返回上一次的结果:
@Transactional public void handleOrderEvent(OrderEvent event) { try { idempotentMapper.insert(event.getBizType(), event.getOrderNo()); } catch (DuplicateKeyException e) { // 已经处理过,直接返回,不重复执行 return; } // 第一次处理,执行真正业务 orderService.createOrder(event.toDTO()); }消息队列的重复消费也可以用同一套思路:消费端先根据消息 ID 写去重表,再执行业务。如果消息本身没有可靠唯一 ID,就用业务键(订单号、流水号)代替,但前提是这个业务键在消息生产端就必须确定好。
状态机是另一种常见防重手段。例如订单状态从“待支付”改成“已支付”时,SQL 写成条件更新:
UPDATE t_order SET status = 1, pay_time = NOW() WHERE order_no = #{orderNo} AND status = 0;影响行数为 0 就说明这笔订单不是“待支付”状态,第二次回调自然不会重复入账。状态机的优点是把幂等逻辑和业务状态绑定在一起,缺点是状态定义得太多时,可读性和维护成本会明显上升,所以它更适合支付、订单这类状态驱动极强的业务。
十、ID 生成与全局唯一:所有防重的前提
无论唯一索引、幂等表还是消息去重,都依赖一个前提:存在一个可靠的唯一业务键。这个键要么来自业务本身(如订单号、流水号),要么由 ID 生成器提供。分库分表之后,数据库自增主键不再全局唯一,ID 生成就变成了防重体系里经常被忽略但非常关键的一环。
常见的方案有三个:
- 雪花算法(Snowflake):64 位 long 型 ID,按时间戳、机器 ID、序列号分段,单机内递增、全局趋势递增。优点是生成快、不依赖数据库;缺点是对时钟回拨敏感,需要做容错。
- 号段模式:应用从数据库批量申请一段 ID,内存里逐个发放,号段用完再申请。吞吐量高、实现简单,但 ID 不连续,扩容时要注意号段分配。
- UUID:实现最简单,全局唯一性最好,但无序字符串在 InnoDB 里做主键会导致页分裂,通常不建议直接作为数据库主键;如果要用,可以配合有序时间戳做折中。
面试时只需要明确一点:ID 生成的职责是“给防重体系提供稳定唯一键”,它本身不防重;真正拦住重复的是唯一索引和幂等表。不要把一个 ID 工具答成防重方案本身。
十一、总结:面试怎么答才抗得住追问
回到开头那道题:“支付回调重试三次、页面连点五次,怎么保证订单不重复创建?”扛得住追问的答案是先拆问题、再给分层方案:
- 前端做按钮置灰和 loading,先拦住普通手滑连点,但同时承认它可被绕过。
- 服务端用幂等 Token 或网关 requestId 做一层短窗口去重,减少无效请求进入后端。
- 业务层为订单号建幂等表,用唯一索引挡住“同一订单重复处理”。
- 数据库层用唯一索引约束 order_no,让并发插入只有一个成功;扣库存、改余额再用悲观锁、乐观锁保证更新正确。
- 跨服务互斥用 Redis 分布式锁,但明确锁超时必须由唯一索引兜底。
- MQ 重复投递用消费端去重表或业务唯一键解决。
这一整套方案中,每一层都有明确的失效边界:前端可绕过,Token 会过期,锁会超时,唯一索引在分库分表后只是局部唯一。因此但凡问到“你只用其中一种行不行”,正确答案一定是:不行,必须组合。外层防重负责体验和性能,数据库唯一约束负责最终正确性,幂等设计负责跨时间重复。把这句话讲清楚,这道题就从一个八股考点,变成了你展示架构思维的加分项。