

大家好,我是Java烘焙师,本文结合笔者的经验和思考,对分布式锁做个总结。 当同时操作共享资源时,需要做并发控制。在单机上用synchronized或ReentrantLock能解决的互斥问题,到了多机部署的分布式环境就行不通了,因为它们只在单个JVM进程内生效,跨多机就需要分布式锁了。 先说结论,最常用的分布式锁方案是:Redis锁、DB乐观锁、或DB逻辑锁。高并发、能容忍极端情况下短暂的弱一致,选Redis锁;并发不高、强一致要求高,选DB乐观锁、或DB逻辑锁;不推荐DB行锁、ZK锁。下面展开介绍。 一把合格的分布式锁要满足什么 先对齐一下标准,后续每种方案的好坏,都可以对照: 互斥:最基本的要求,任意时刻只能有一个客户端持有同一把锁 防死锁:持有锁的客户端如果宕机了,锁能自动释放,防止其它客户端永远加锁失败 可重入:同一个客户端在持锁期间能再次拿到这把锁,避免自己阻塞自己 性能:加锁、解锁开销要小,吞吐要高 悲观锁和乐观锁 悲观锁 觉得并发冲突大概率会发生,所以操作前先加锁,把资源独占起来,操作完再释放。 典型实现:DB行锁select ... for update、DB逻辑锁(通过新增lock_status状态字段实现)、Redis的set nx px命令 优点:严格互斥,不用像乐观锁那样反复重试 缺点:持锁期间其它线程只能等、有死锁风险、吞吐被锁的粒度和持有时间卡住 适用场景:适合写冲突频繁的场景 乐观锁 觉得冲突很少,所以一开始不加锁,等更新时再判断中途是否被其它线程修改了。 典型实现:version字段、CAS先比较再修改 优点:无锁实现,开销较低 缺点:并发冲突较多时,需要不断重试;可能有ABA问题,但用递增的version能解决,仅CAS比较值则不行 适用场景:适合读多写少的场景;如果写并发很高,但误用了乐观锁的话,会导致线程不停地重试,最终线程池耗尽的严重问题 两个并发更新请求的时序图如下: sequenceDiagram participant G as 业务入口服务(无存储) participant S as 存储服务(持有数据) G->>S: 线程1:先到达请求,查询数据 S-->>G: 线程1:返回业务数据、version=v1 Note over G,S: 两个并发请求都基于 v1 计算后回写 G->>S: 线程2:后到达请求,查询数据 S-->>G: 线程2:返回业务数据、version=v1 G->>S: 线程2:更新业务数据(带 v1,先到达存储) S->>S: version匹配,更新成功,version升到v2 S-->>G: 线程2:操作成功 G->>S: 线程1:更新业务数据(带 v1,因网络延迟后到达存储) S->>S: version不匹配(当前版本v2≠v1),更新失败 S-->>G: 线程1:操作失败,需上游重试 Note over G,S: 线程1先发起请求但因网络问题后到达,version校验阻止了旧数据覆盖新数据