
标题 这篇文章记录我对抢单/下单这类典型高并发写场景的理解和实现先看结果10000 并发请求零超卖用CountDownLatch让 10000 个线程同时起跑抢同一个资源。结果如下压测结果**数据库验证**10000 个并发请求最终只有 1 笔订单成功落库资源状态正确变更没有出现超卖。下面讲讲这个结果是怎么做到的。一、场景背景在抢单、秒杀、商品下单这类场景中同一份资源一个岗位、一件商品可能同时被大量用户请求但最终只能被一个用户成功获取。如果不做并发控制就会出现超卖——一份资源被生成了多笔订单。二、整体思路三层防护漏斗把并发请求当作一个漏斗逐层过滤大量并发请求 │ ├─ 第一层Redis 分布式锁 ──→ 让请求排队大部分被挡在外面 │ ├─ 第二层锁内双重检查 ──→ 重新确认资源状态 │ └─ 第三层数据库乐观锁 ──→ 最终兜底保证正确性第一层负责排队、减冲突、快速失败第三层负责最终正确性兜底。三、第一层Redis 分布式锁用 Redisson 实现分布式锁锁的粒度是单个资源单个岗位/单个商品publicTTexecuteWithLock(StringlockKey,SupplierTsupplier){RLocklockredissonClient.getLock(lockKey);try{// 不传 leaseTime → 启动看门狗自动续期默认 30 秒 TTL// waitTime3 秒抢不到最多等 3 秒booleanacquiredlock.tryLock(3,TimeUnit.SECONDS);if(!acquired){thrownewBusinessException(429,操作过于频繁请稍后重试);}returnsupplier.get();}finally{// 释放前判断锁是否还在自己手上防止误删别人的锁if(lock.isHeldByCurrentThread()){lock.unlock();}}}// 接单场景publicTTlockOrder(LongresourceId,SupplierTsupplier){returnexecuteWithLock(order:resourceId,supplier);}几个设计要点锁 key 带资源 ID锁的是单个资源不同资源的请求互不影响可以并行。waitTime3s用户等不了太久超过 3 秒直接返回。不传 leaseTime 启用看门狗Redisson 默认 30 秒 TTL后台线程每隔 10 秒检查一次只要业务还没跑完就自动续期不会出现任务没跑完锁先过期。isHeldByCurrentThread()判断防止锁已过期后误删别人的锁。四、第二层锁内双重检查Double-Check拿到锁之后第一件事不是改数据而是再检查一下returnredisLockUtil.lockOrder(resourceId,()-{// 双重检查Resourceresourcemapper.selectById(resourceId);if(resourcenull)thrownewBusinessException(404,资源不存在);if(resource.getStatus()null||resource.getStatus()!1)thrownewBusinessException(409,资源已被抢完或已下架);if(Objects.equals(resource.getOwnerId(),userId))thrownewBusinessException(400,不能操作自己发布的资源);...});为什么拿到锁了还要查因为用户点击时页面显示的状态可能是旧数据等抢到锁进来前面可能已经有人把它抢走了。必须以数据库此刻的真实状态为准。五、第三层数据库乐观锁最终兜底这是最关键的一层。用一条带条件的 UPDATEintupdatedmapper.update(null,newLambdaUpdateWrapperResource().eq(Resource::getId,resourceId)// WHERE id ?.eq(Resource::getStatus,1)// AND status 1 ← 关键.set(Resource::getStatus,2)// SET status 2);if(updated0){thrownewBusinessException(409,手慢了已被他人抢先);}翻译成 SQLUPDATEresourceSETstatus2WHEREid?ANDstatus1;WHERE 里除了id还带了status 1。执行后看影响行数影响 1 行资源确实还是可抢状态被我成功改了影响 0 行在我执行的瞬间status 已经不是 1 了被别人抢先了。六、最核心的问题锁提前释放怎么办这是最容易被问到的点。方法上有Transactional而锁的unlock在 Lambda 结束时就执行了——锁释放在前事务提交在后。第二个人拿到锁进来时第一个人的事务可能还没提交他会不会看到旧数据会不会超卖答案是不会。完整时序时刻1 线程A 抢到 Redis 锁线程B 在 Redis 排队 时刻2 A 查库status1 ✅双重检查通过 时刻3 A 执行 UPDATE SET status2 WHERE id? AND status1 → 影响1行 时刻4 A 执行 insert 订单 时刻5 A 的 Lambda 结束 → Redis unlock⚠ 锁释放了但事务还没提交 时刻6 B 拿到 Redis 锁 时刻7 B 查库status1快照读A 未提交B 看到旧值 双重检查通过 ✅但这是假象 时刻8 B 执行 UPDATE SET status2 WHERE id? AND status1 → 被 InnoDB 行锁挡住A 还没提交B 必须等 时刻9 A 的事务提交status2 落库 时刻10 B 的行锁放开UPDATE 继续 但 WHERE status1 已不匹配现在是 2 → 影响 0 行 时刻11 B 抛已被他人抢先 ✅ B 的事务回滚不会产生重复订单核心原理MySQL 的 UPDATE 是当前读会加行锁。两个事务改同一行后者必须等前者提交或回滚后才能继续。提交后WHERE status1条件已不成立后者自然影响 0 行、正确失败。所以三层防护中分布式锁是减少冲突、快速失败数据库乐观锁才是绝不超卖的最终底线。哪怕 Redis 完全挂了只要这条 SQL 还在就不会超卖。七、最后才创建订单乐观锁通过后才创建订单记录OrderordernewOrder();order.setResourceId(resourceId);order.setBuyerId(userId);order.setStatus(10);// 待支付orderMapper.insert(order);注意顺序先改资源状态确认抢到了、再插订单。Transactional保证这两步要么全成功要么全回滚不会出现资源被抢了但没有订单的脏数据。八、额外优化接口限流大量无效请求打到数据库会造成行锁竞争。可以用 Redis 计数器对抢单、下单等高并发写接口限流按用户ID 接口路径作 key固定窗口内限制请求次数超限直接返回 429。Redis 挂了自动降级放行不阻塞业务。九、总结抢单/下单防超卖的本质是同一份数据的并发写。三层防护层层兜底分布式锁负责让请求排队、减少冲突双重检查负责在锁内重新确认数据状态数据库乐观锁负责最终正确性是绝不能省的底线。其中第三层是关键——它利用了 InnoDB 的行锁机制让锁提前释放这个看似致命的问题变得无害。理解了这一点就理解了分布式锁和数据库锁的边界分布式锁解决的是应用层的并发排队数据库锁解决的是数据层的最终一致。以上是我对这类高并发场景的一些理解实现上肯定还有很多考虑不周的地方比如限流策略、分布式锁的可重入性、事务与锁的顺序等等有什么不足希望各位指点感谢