资讯动态

去阿里面试到第二轮就被虐惨:高并发下怎么防止数据重复?

发布时间:2026/10/26 18:09:21 来源:尧图企业网站定制
一、面试现场二面被问懵的那道题先说背景。简历上写着“熟悉高并发”“做过分布式项目”“熟悉 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请求 BT1selectByOrderNo查到不存在T2准备 insertselectByOrderNo查到不存在T3insert 成功insert 也执行T4订单数1订单数变成 2重复了这就是典型的TOCTOUTime 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;当两个并发请求同时插入相同 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 UPDATEMySQL 还提供了两种对抗重复的写法但要理解它们各自的副作用-- 冲突则忽略返回 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 UPDATETransactional 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 PXNX 保证只有一个实例设置成功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 就直接 deleteA 业务还没执行完锁就过期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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;处理流程是先尝试往幂等表插入一条记录插入成功才继续执行真正的业务插入冲突说明这个请求已经处理过直接查询并返回上一次的结果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 生成就变成了防重体系里经常被忽略但非常关键的一环。常见的方案有三个雪花算法Snowflake64 位 long 型 ID按时间戳、机器 ID、序列号分段单机内递增、全局趋势递增。优点是生成快、不依赖数据库缺点是对时钟回拨敏感需要做容错。号段模式应用从数据库批量申请一段 ID内存里逐个发放号段用完再申请。吞吐量高、实现简单但 ID 不连续扩容时要注意号段分配。UUID实现最简单全局唯一性最好但无序字符串在 InnoDB 里做主键会导致页分裂通常不建议直接作为数据库主键如果要用可以配合有序时间戳做折中。面试时只需要明确一点ID 生成的职责是“给防重体系提供稳定唯一键”它本身不防重真正拦住重复的是唯一索引和幂等表。不要把一个 ID 工具答成防重方案本身。十一、总结面试怎么答才抗得住追问回到开头那道题“支付回调重试三次、页面连点五次怎么保证订单不重复创建”扛得住追问的答案是先拆问题、再给分层方案前端做按钮置灰和 loading先拦住普通手滑连点但同时承认它可被绕过。服务端用幂等 Token 或网关 requestId 做一层短窗口去重减少无效请求进入后端。业务层为订单号建幂等表用唯一索引挡住“同一订单重复处理”。数据库层用唯一索引约束 order_no让并发插入只有一个成功扣库存、改余额再用悲观锁、乐观锁保证更新正确。跨服务互斥用 Redis 分布式锁但明确锁超时必须由唯一索引兜底。MQ 重复投递用消费端去重表或业务唯一键解决。这一整套方案中每一层都有明确的失效边界前端可绕过Token 会过期锁会超时唯一索引在分库分表后只是局部唯一。因此但凡问到“你只用其中一种行不行”正确答案一定是不行必须组合。外层防重负责体验和性能数据库唯一约束负责最终正确性幂等设计负责跨时间重复。把这句话讲清楚这道题就从一个八股考点变成了你展示架构思维的加分项。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑