
从零理解分布式锁用抢票场景讲透原理、实现与实战一、从生活场景理解锁1.1 单人使用的卫生间想象一个只有一个隔间的公共卫生间你进去后锁上门加锁其他人看到门锁了只能在外面等阻塞/等待你用完后打开门释放锁下一个人进去再锁门这就是锁的本质保证同一时刻只有一个人在使用某个资源。1.2 为什么需要分布式锁在单机程序中Java 自带的锁synchronized就像一栋楼里的卫生间门锁楼里的人都认这把锁。但如果你的系统部署了多个实例比如3台服务器同时运行就像3栋楼各有各的卫生间。楼A的门锁管不了楼B的人。分布式锁就是在3栋楼之间建一个公共登记处如 Redis所有人都去那里登记我要用登记处保证同一时刻只有一个人能登记成功。二、为什么需要分布式锁2.1 没有锁会怎样抢票场景假设有一张演唱会门票库存1两个用户同时抢购用户A服务器1 用户B服务器2 │ │ ├── 查询库存 → 1有票 ├── 查询库存 → 1有票 │ │ ├── 扣减库存 → 0 ├── 扣减库存 → 0 │ │ ├── 创建订单 成功 ├── 创建订单 成功 │ │ 结果1张票卖给了2个人超卖了2.2 加了分布式锁之后用户A服务器1 用户B服务器2 │ │ ├── 尝试加锁 → 成功 ├── 尝试加锁 → 失败等待... │ │ ├── 查询库存 → 1有票 │ 等待中... ├── 扣减库存 → 0 │ 等待中... ├── 创建订单 成功 │ 等待中... ├── 释放锁 ──────────────────────── 锁释放了 │ │ │ ├── 加锁成功 │ ├── 查询库存 → 0没票了 │ ├── 返回已售罄 │ ├── 释放锁 │ │ 结果只有用户A买到票用户B被正确拒绝三、分布式锁的核心概念3.1 三个基本操作操作含义类比加锁Lock声明我要独占这个资源进卫生间锁门持有锁正在使用资源的过程在卫生间里面释放锁Unlock声明我用完了别人可以用开门出来3.2 锁的关键属性/** * 分布式锁的核心属性. */publicclassLockProperties{Stringkey;// 锁的名字标识锁的是哪个资源longtimeout;// 最大持有时间防止死锁longwaitTime;// 等待获取锁的最大时间booleanreentrant;// 是否可重入同一线程能否重复加锁}3.3 锁的 Key锁的是什么锁的 Key 决定了你在保护哪个资源// 锁住商品SKU-1001的库存操作StringlockKeystock-deduct-1001;// 锁住用户ID-888的账户操作StringlockKeyaccount-transfer-888;// 锁住订单ORD-123的状态变更StringlockKeyorder-status-ORD123;不同的 Key 不同的锁 互不影响。用户A扣减SKU-1001的库存时不会阻塞用户B扣减SKU-2002的库存。3.4 超时时间防止死锁如果一个线程加了锁后崩溃了永远不会释放锁其他线程就永远等下去死锁。解决方案给锁设置一个最大持有时间超时后自动释放。// 这把锁最多持有2分钟2分钟后即使没有主动释放也会自动失效DistributedLocklocklockProvider.getLock(stock-deduct-1001,TimeUnit.MINUTES,2);四、分布式锁的实现方式4.1 基于 Redis 实现最常用原理利用 Redis 的SETNXSET if Not eXists命令只有 Key 不存在时才能设置成功。加锁SETNX lock-key holder-id EX 120 → 返回 OK 加锁成功 → 返回 nil 锁已被别人持有 释放锁DEL lock-key需要验证 holder-id 是自己的Java 伪代码publicclassRedisDistributedLockimplementsDistributedLock{privatefinalRedisTemplateString,StringredisTemplate;privatefinalStringlockKey;privatefinalStringholderId;// 当前持有者标识防止误释放别人的锁privatefinallongtimeoutSeconds;/** * 尝试加锁. */OverridepublicbooleantryLock(TimeUnitunit,longwaitTime){longdeadlineSystem.currentTimeMillis()unit.toMillis(waitTime);while(System.currentTimeMillis()deadline){// SETNX只有 key 不存在时才设置成功BooleansuccessredisTemplate.opsForValue().setIfAbsent(lockKey,holderId,timeoutSeconds,TimeUnit.SECONDS);if(Boolean.TRUE.equals(success)){returntrue;// 加锁成功}// 加锁失败短暂等待后重试Thread.sleep(50);}returnfalse;// 超时未获取到锁}/** * 释放锁. */Overridepublicvoidunlock(){// 只有锁的持有者才能释放防止释放别人的锁StringcurrentHolderredisTemplate.opsForValue().get(lockKey);if(holderId.equals(currentHolder)){redisTemplate.delete(lockKey);}}}4.2 基于 ZooKeeper 实现原理利用 ZooKeeper 的临时有序节点。每个加锁请求创建一个临时节点序号最小的获得锁。/locks/stock-deduct-1001/ ├── node-0001 ← 序号最小持有锁 ├── node-0002 ← 等待中监听 node-0001 └── node-0003 ← 等待中监听 node-00024.3 基于数据库实现原理利用数据库的唯一索引插入成功加锁成功。-- 加锁插入一条记录INSERTINTOdistributed_lock(lock_key,holder_id,expire_time)VALUES(stock-1001,server-A,NOW()INTERVAL2MINUTE);-- 如果 lock_key 有唯一索引重复插入会失败 加锁失败-- 释放锁删除记录DELETEFROMdistributed_lockWHERElock_keystock-1001ANDholder_idserver-A;4.4 三种方式对比维度RedisZooKeeper数据库性能高毫秒级中十毫秒级低百毫秒级可靠性中主从切换可能丢锁高强一致性中实现复杂度低中低是否需要额外中间件需要 Redis需要 ZooKeeper不需要适用场景大多数业务场景对一致性要求极高简单场景/无中间件五、完整实战示例抢购秒杀5.1 场景描述电商秒杀活动100件商品1000人同时抢购。需要保证不超卖卖出数量不超过库存不重复购买同一用户只能买一次5.2 代码实现/** * 秒杀服务实现. */ServicepublicclassSeckillServiceImplimplementsSeckillService{ResourceprivateDistributedLockProviderdistributedLockProvider;ResourceprivateProductRepositoryproductRepository;ResourceprivateSeckillOrderRepositoryseckillOrderRepository;/** * 执行秒杀. * * param userId 用户ID * param productId 商品ID * return 订单IDnull表示秒杀失败 */Transactional(rollbackForException.class)publicIntegerexecuteSeckill(IntegeruserId,IntegerproductId){// 第一步加分布式锁 // 锁的粒度是商品级同一商品的秒杀请求串行处理StringlockKeyseckill-productId;DistributedLocklockdistributedLockProvider.getLock(lockKey,TimeUnit.MINUTES,1);// 尝试获取锁最多等5秒秒杀场景等待时间要短if(!lock.tryLock(TimeUnit.SECONDS,5)){log.info(秒杀获取锁超时, userId:{}, productId:{},userId,productId);returnnull;// 获取锁超时秒杀失败}// 第二步注册事务后释放锁 AfterTransactionActionCollectorcollectornewAfterTransactionActionCollector();collector.addCommitSyncAction(lock::unlock);collector.addRollbackSyncAction(lock::unlock);TransactionSynchronizationManager.registerSynchronization(collector);// 第三步业务逻辑 // 3.1 检查是否已经购买过幂等SeckillOrderexistingOrderseckillOrderRepository.findByUserIdAndProductId(userId,productId);if(existingOrder!null){log.info(用户已购买过, userId:{}, productId:{},userId,productId);returnnull;}// 3.2 检查库存ProductproductproductRepository.findById(productId).orElse(null);if(productnull||product.getStock()0){log.info(商品库存不足, productId:{}, stock:{},productId,productnull?0:product.getStock());returnnull;}// 3.3 扣减库存product.setStock(product.getStock()-1);productRepository.save(product);// 3.4 创建秒杀订单SeckillOrderordernewSeckillOrder();order.setUserId(userId);order.setProductId(productId);order.setOrderTime(newDate());order.setStatus(SUCCESS);seckillOrderRepository.save(order);log.info(秒杀成功, userId:{}, productId:{}, orderId:{},userId,productId,order.getId());returnorder.getId();}}5.3 执行时序用户A和用户B同时秒杀商品-1001库存1 用户A服务器1 用户B服务器2 │ │ ├── 加锁 seckill-1001 → 成功 ├── 加锁 seckill-1001 → 等待... │ │ ├── 检查是否购买过 → 没有 │ 等待中... ├── 查库存 → 1 │ 等待中... ├── 扣库存 → 0 │ 等待中... ├── 创建订单 │ 等待中... ├── 事务提交 │ 等待中... ├── 释放锁 ──────────────────────── 锁释放了 │ │ │ ├── 加锁成功 │ ├── 检查是否购买过 → 没有 │ ├── 查库存 → 0 │ ├── 库存不足返回null │ ├── 事务提交无修改 │ ├── 释放锁 │ │ 结果用户A秒杀成功用户B被正确拒绝六、关键设计决策6.1 锁的粒度选择// 方案A锁整个秒杀活动所有商品串行StringlockKeyseckill-activity;// 缺点商品A和商品B互相阻塞并发度极低// 方案B锁单个商品推荐StringlockKeyseckill-productId;// 优点不同商品互不影响// 方案C锁用户商品更细StringlockKeyseckill-productId-userId;// 缺点无法防止库存超卖不同用户可以同时扣同一商品库存结论锁的粒度取决于你要保护的共享资源是什么。秒杀场景中共享资源是商品库存所以按商品维度加锁。6.2 等待时间 vs 快速失败// 秒杀场景等待时间短快速失败lock.tryLock(TimeUnit.SECONDS,5);// 用户体验5秒内没抢到就告诉用户没抢到// 后台任务场景可以等待较长时间lock.tryLock(TimeUnit.MINUTES,25);// 因为是异步处理用户不在等待6.3 加锁失败后的处理策略策略适用场景代码快速失败秒杀、抢购return null;抛异常让MQ重试异步消息处理throw new RuntimeException();自旋等待必须完成的操作while(!lock.tryLock()) sleep();七、分布式锁 vs 数据库乐观锁 vs 悲观锁7.1 三种并发控制方式对比// 方式一分布式锁在应用层控制DistributedLocklocklockProvider.getLock(stock-skuId);lock.tryLock();// 查询 → 修改 → 保存lock.unlock();// 方式二数据库乐观锁通过版本号控制// 表中有 version 字段// UPDATE product SET stock stock - 1, version version 1// WHERE id 1001 AND version 5;// 如果 version 不匹配更新0行说明被别人改过了// 方式三数据库悲观锁SELECT FOR UPDATE// SELECT * FROM product WHERE id 1001 FOR UPDATE;// 这行数据被锁住其他事务读这行会等待7.2 选择建议方式优点缺点适用场景分布式锁灵活可锁任意资源需要额外中间件复杂业务逻辑多步操作乐观锁无需额外中间件并发度高冲突时需要重试冲突概率低的场景悲观锁简单直接并发度低可能死锁冲突概率高且不能重试八、Redisson生产级分布式锁框架在实际项目中很少自己实现分布式锁通常使用成熟的框架。Redisson 是 Java 中最流行的 Redis 分布式锁框架。8.1 基本使用ResourceprivateRedissonClientredissonClient;publicvoidbusinessMethod(){// 获取锁对象RLocklockredissonClient.getLock(my-lock-key);try{// 尝试加锁等待10秒锁自动释放时间30秒booleanacquiredlock.tryLock(10,30,TimeUnit.SECONDS);if(!acquired){thrownewRuntimeException(获取锁失败);}// 执行业务逻辑doBusinessLogic();}catch(InterruptedExceptione){Thread.currentThread().interrupt();}finally{// 释放锁只有持有者才能释放if(lock.isHeldByCurrentThread()){lock.unlock();}}}8.2 Redisson 的看门狗机制如果你不指定锁的释放时间Redisson 会启动一个看门狗watchdog每隔一段时间自动续期直到你主动释放锁。// 不指定 leaseTime看门狗自动续期默认30秒续一次lock.tryLock(10,TimeUnit.SECONDS);// 指定 leaseTime30秒不启用看门狗30秒后自动释放lock.tryLock(10,30,TimeUnit.SECONDS);8.3 可重入性Redisson 的锁默认支持可重入同一线程可以多次加同一把锁publicvoidmethodA(){RLocklockredissonClient.getLock(my-lock);lock.lock();// 调用 methodB内部再次加同一把锁不会死锁methodB();lock.unlock();}publicvoidmethodB(){RLocklockredissonClient.getLock(my-lock);lock.lock();// 可重入不会阻塞// 业务逻辑lock.unlock();}九、常见面试题Q1分布式锁如何防止死锁答设置锁的超时时间。即使持有者崩溃不释放超时后锁自动失效。Q2Redis 主从切换时锁会丢失怎么办答方案一使用 RedLock 算法向多个独立 Redis 实例加锁方案二使用 ZooKeeper强一致性方案三业务层做幂等兜底即使锁丢了业务逻辑也能正确处理Q3锁的超时时间设多少合适答业务最大耗时的 2-3 倍。比如业务通常1秒完成最慢5秒那锁超时设10-15秒。Q4加锁和释放锁不是同一个线程怎么办答锁中记录持有者ID线程ID 实例ID释放时验证是否是同一个持有者。十、总结什么时候需要分布式锁 │ ├── 多个服务实例可能同时操作同一条数据 ├── 操作不是原子的查询 → 判断 → 修改中间可能被打断 └── 数据不一致会造成业务损失超卖、重复扣款等 分布式锁的使用模板 │ ├── 1. 确定锁的 Key保护什么资源 ├── 2. 确定超时时间业务耗时的2-3倍 ├── 3. 加锁tryLock设置等待时间 ├── 4. 执行业务逻辑 └── 5. 释放锁事务提交后释放或 try-finally