1. 并发问题的根源:三个看起来简单却天天踩坑的底层机制
做Java开发的朋友应该都有过这种经历:明明写了i++这种一行代码,压测一跑起来结果就不是期望值;明明加了synchronized,换了个分布式环境又发现锁根本不管用。高并发编程的难点不在于API怎么调用,而在于你根本看不见底层发生了什么。
这一篇我系统讲一讲从JVM内置锁到分布式锁的完整链路,包括我实际写过的代码、压测过的数据、坑过的线上事故,以及怎么一步步定位这些问题。
先说并发编程必须面对的三大问题:可见性、原子性、有序性。不想用教科书定义来讲,用大白话就三句话:
- 可见性:线程A改了变量,线程B不一定能立刻看到这个修改。
- 原子性:一个操作不可分割,比如
i++其实包含"读取、加1、写回"三步,多线程下这三步会被打乱穿插。 - 有序性:编译器、CPU为了性能会重排指令,代码写的是A在B前面,执行时B可能先跑了。
有一个反直觉的结论先说在前面:加锁并不是为了"阻塞线程",加锁真正解决的是这三类问题。阻塞只是加锁带来的副作用,不是目的。理解了这一点,后面你再看各种锁的实现就会非常通透。
以经典案例来说,我曾经在某电商类的模拟项目里复现过一个库存超卖bug:库存表stock初始值100,用10个线程并发执行stock--,结果跑完剩余库存是93到99不等,永远对不上。原因就是stock--这个操作不是原子的:每个线程同时读到100,然后各自减1,写回时互相覆盖。这种情况单机靠synchronized可以解决,但到了多实例部署,本地锁就不再有意义了,这就自然引出了分布式锁。
这一篇围绕"从并发三大问题到锁,从本地锁到分布式锁"这条主线展开,适合有一定Java基础、但没系统梳理过并发锁机制的同学,也适合已经在写业务代码但经常被并发bug困扰的开发者用来做排查思路参考。
2. 先从synchronized说起:JVM替你做的那些锁优化,以及两个反直觉的坑
2.1 synchronized的本质与锁升级路径
Java最基础的锁就是synchronized,在JDK 6之后它已经不是重量级锁的代名词了,JVM在内部做了大量优化,形成了从偏向锁到重量锁的升级路径。
synchronized底层依赖Monitor监视器锁,每次进入同步代码块都会执行monitorenter,退出时执行monitorexit。但JVM并不会上来就用重量级锁,因为重量级锁涉及操作系统内核态的系统调用,线程阻塞和唤醒的代价非常高。锁升级过程是这样的:
- 偏向锁:只有一个线程访问时,在对象头Mark Word里记录线程ID,这个线程以后进入同步块不再做任何同步操作,性能与不加锁几乎一致。
- 轻量级锁:有第二个线程也来竞争时,偏向锁撤销,升级为轻量级锁。这个阶段通过CAS自旋获取锁,不阻塞线程。
- 重量级锁:自旋超过阈值或自旋线程数过多时,升级为重量级锁,线程进入阻塞队列,依赖操作系统互斥量实现。
实际开发中需要注意的一点:偏向锁在JDK 15开始默认被禁用,后续版本逐渐废弃。如果你的应用运行在JDK 15+,偏向锁相关的调优参数已经没用了。
2.2 synchronized的两个常见误用
第一个误区:以为synchronized加在方法上就能锁住"这段业务逻辑"。
实际上synchronized锁的是对象,不是方法。加在实例方法上锁的是当前实例this,加在静态方法上锁的是Class对象。来看一个常见翻车案例:
public class StockService { public synchronized void deduct() { // 扣减库存逻辑 } }如果这个StockService在Spring容器中是单例,没问题。但如果被new出了多个实例,比如在某些定时任务里每次手动创建,那么每个实例锁的对象都不一样,锁就完全失效了。我当时排查线上问题的时候,先用jstack抓线程状态,发现多个线程各自进入同步方法互不阻塞,第一反应就是"实例是不是多份"。
第二个误区:synchronized锁不住分布式环境。
这就是前面说的天然边界问题。A机器上的线程锁住了A机器上的对象,B机器上根本感知不到这个锁的存在。很多团队在单体应用阶段依赖synchronized很顺手,一旦改成多实例部署就出问题,本质就是没有理解锁的粒度边界在哪里。
2.3 锁对象选择的一个实用原则
选锁对象有个经验法则:锁的范围应该等于共享资源的范围。操作同一个库存商品的扣减逻辑,锁就该是这个商品的维度,比如用商品ID作为锁粒度;如果是扣用户余额,锁的用户维度。锁粒度太粗(整表锁)会严重降低吞吐量,锁粒度太细(比如每次new一个Object当锁)等于没锁。
用一段生产上常见的写法说明:
public class InventoryService { private final ConcurrentHashMap<String, Object> lockMap = new ConcurrentHashMap<>(); public void deduct(String productId, int count) { // 每个商品一把锁,互不影响 Object lock = lockMap.computeIfAbsent(productId, k -> new Object()); synchronized (lock) { // 查询库存、校验、扣减、写日志 doDeduct(productId, count); } } }这种方式称为"分段锁"或"细粒度锁",在高并发场景下性能远优于直接锁整个方法。ConcurrentHashMap本身线程安全,computeIfAbsent保证同一商品ID只会创建一把锁对象,不同商品之间的锁互不干扰,吞吐量会高很多。
3. JUC锁工具箱:ReentrantLock、读写锁与StampedLock的适用场景选择
3.1 ReentrantLock和synchronized怎么选
Redis读者可能听过Java并发包(JUC——Java Util Concurrent)里的ReentrantLock。它是基于AQS(AbstractQueuedSynchronizer)实现的,这个AQS是整个JUC锁体系的基石,核心是一个volatile状态变量加CLH等待队列。
ReentrantLock相比synchronized几个显著的能力差异是:
- 可中断:
lockInterruptibly()可以在等待锁的过程中响应中断。 - 可超时:
tryLock(timeout, TimeUnit.SECONDS)等待超过指定时间就放弃,避免死锁。 - 可公平:构造时传入
fair=true可以启用公平锁。 - 多个条件变量:
newCondition()可以创建多个等待队列,比synchronized的wait/notify更灵活。
实际生产中的一个判断依据是:如果只是简单互斥,优先synchronized,因为写法简单、JVM层面还在持续优化;如果需要超时控制、公平性控制,或者需要多个条件队列,那就选ReentrantLock。强锁(tryLock超时)在分布式锁场景里也经常用到,比如本地缓存刷新时多线程竞争更新。
不过有一点要特别注意:必须在finally里释放锁。synchronized是JVM自动释放,ReentrantLock可不会,忘记unlock的后果就是死锁,而且这种死锁用jstack一看全是WAITING状态,排查起来很头疼。
3.2 读写锁与缓存场景
读多写少的场景最典型的是缓存。ReentrantReadWriteLock维护一个读锁和一个写锁,读锁之间可以共享,读锁和写锁互斥,写锁和写锁互斥。这种"多读单写"的设计能让读并发能力成倍提升。
但ReadWriteLock有一个隐蔽的坑:写锁饥饿。如果有大量读线程持续持有读锁,写线程可能长时间得不到执行。ReentrantReadWriteLock里非公平模式下写锁获取概率也不高,极端情况下写线程会一直等待。长事务和高频读同时存在时,可能出现读线程源源不断进来、写线程始终抢不到锁的情况。
针对这个问题,JDK 8引入的StampedLock提供了乐观读功能。乐观读不实际加锁,先读,读完校验版本号(stamp),如果期间没有写操作就直接用数据,如果有写操作就升级为读锁重新读。适合读多写少且读操作较快的场景。使用时要小心,StampedLock不支持重入,而且用不好容易产生CPU占用问题。
3.3 JUC底层AQS的要义
AQS是一条抽象队列同步器,JUC里的大部分锁工具都基于它实现。其核心是state(volatile int)加FIFO等待队列。以ReentrantLock为例,lock时CAS尝试把state从0改成1,成功就拿到锁;失败则进入等待队列。重入时state累加,释放时递减到0才算完全释放。
我最初学AQS的时候觉得这个东西太抽象,后来写了一个自定义的限流器才真正理解它的设计。实际使用中,你基本不需要自己实现AQS,但理解它的模型对排查锁问题非常有用。比如用jstack看到大量线程阻塞在AbstractQueuedSynchronizer$ConditionObject时,就知道它们是在条件等待队列上等待信号,可以用signal()唤醒来排查。
4. JVM锁之外的并发利器:从原子类到并发容器的工程选择
4.1 AtomicXXX与CAS为什么会ABA
Java并发包除了锁还有一套无锁方案:CAS(Compare And Swap)。AtomicInteger、AtomicLong、AtomicReference这些原子类都基于Unsafe类的CAS原语实现,不需要加锁也能保证原子性。
CAS的核心思路是:更新变量前先比对我读到的值和内存当前值是否一致,一致才更新,否则重试。这看起来很美,但存在一个经典问题——ABA问题:线程A读到值是1,线程B把它改成2再改成1,线程A再次CAS时发现还是1,以为没人改过。对于某些场景(如无锁栈),ABA会导致意想不到的数据错乱。
解决方案是使用AtomicStampedReference或AtomicMarkableReference,它们额外维护版本号,CAS时同时比较值和版本号。
实际工程中CAS有另外一个性能隐患:高竞争下大量线程CAS失败会导致频繁自旋,CPU飙升。所以如果竞争非常激烈,原子类的性能可能反而不如锁。我压测过8线程下AtomicLong和LongAdder的差异,LongAdder在高并发下胜出非常多。原因是LongAdder把单一热点value拆成了多个slot,线程分散到不同slot累加,最后sum()时再汇总。所以计数场景现在我的默认选择是LongAdder而不是AtomicLong。
4.2 并发容器的选型原则
很多人提到高并发就想到ConcurrentHashMap,但它也有使用边界。ConcurrentHashMap的弱一致性迭代器(entrySet、size等方法)不保证实时一致,如果你需要"读到的数据一定是当前最新的"这种强一致性,它并不合适。
不同并发场景下的容器选择原则:
| 场景 | 推荐容器 | 理由 |
|---|---|---|
| 高并发KV读写 | ConcurrentHashMap | 分段锁/ CAS + 红黑树优化,读写并发高 |
| 生产者消费者队列 | ArrayBlockingQueue / LinkedBlockingQueue | 自带阻塞与唤醒机制 |
| 多线程安全计数器 | LongAdder / AtomicLong | 无锁或分散热点,吞吐量高 |
| 有序不重复集合 | ConcurrentSkipListSet | 基于跳表,支持高并发有序读写 |
| 延迟任务调度 | DelayQueue | 按过期时间排序,配合ScheduledThreadPoolExecutor使用 |
当你选择容器的时候,先回答三个问题:数据能不能丢失?一致性要求多高?读写比例是多少?答案直接决定容器选择。比如日志采集场景可以允许少量丢,用ConcurrentLinkedQueue就可以了;但订单状态流转不能丢,必须用有界阻塞队列或数据库行锁兜底。
4.3 ThreadLocal与线程池搭配的隐患
另一个高频坑是ThreadLocal在线程池里使用。ThreadLocal是按线程维度存储变量的,线程池里的线程会复用,如果不清理,上一次请求留下的数据会泄漏到下一次任务中。严重一点如果是引用了大对象还可能引发内存泄漏。
我的处理习惯:
try { // 业务逻辑,使用 ThreadLocal 保存上下文 } finally { contextThreadLocal.remove(); }在finally里remove几乎是必须的规范。真实线上排查过一个诡异问题:同一个线程处理不同请求时,偶尔能看到上一个请求的用户ID串进来,最后定位就是ThreadLocal没有清理。
5. 从本地锁到分布式锁:到底锁不住的东西是什么
5.1 为什么本地锁到了分布式环境就失效
把前面几节串起来,本地锁(synchronized、ReentrantLock、AQS)锁的是JVM进程内的对象和线程。但到了分布式部署,多个服务实例各自跑在不同的JVM里,库存数据、订单数据存在共享的数据库或缓存里,某个实例内的锁管不到其他实例,这就是分布式锁问题的根源。
一个最直观的例子:一个商品有100件库存,服务部署了3个实例,每个实例都执行synchronized(deduct)扣减。假设同时来了3个请求,每个请求恰好打到不同实例,3个线程同时读到库存100并各自减1,最终可能得到97而不是99。这三个本地锁各锁各的,谁也不认识谁。
分布式锁的本质:需要一个所有实例都能访问到的、带有互斥能力的公共存储,在这个公共存储上记录"谁持有锁",其他实例来抢锁时看到有人持有了就等待或失败。
5.2 分布式锁的设计要求清单
设计一个可靠的分布式锁,工程上至少要满足以下几条:
- 互斥性:任何时刻只有一个客户端能持有锁。
- 死锁防护:持锁客户端崩溃后,锁必须能在一定时间后自动释放,不能永久死锁。
- 可重入性(视场景):同一个客户端可以重复获取同一把锁。
- 锁超时与自动续期:业务执行时间超过锁过期时间时,需要自动续期,避免业务未结束锁就失效。
- 安全释放:只能释放自己持有的锁,不能误删别人的锁。
- 高可用:锁服务本身不能成为单点。
这些要求看起来不算多,实际做起来坑非常多。后面讲Redis实现时会逐个对应。
5.3 选型路径:从数据库锁到Redis再到ZK
分布式锁的常见实现路径有三条:数据库锁、Redis锁、ZooKeeper/etcd锁。
- 数据库锁:利用数据库唯一索引或
for update行锁,实现简单,但性能较差,且依赖数据库可用性。 - Redis锁:基于单节点SETNX或Redisson封装,性能高,是互联网业务的主流选择。
- ZooKeeper锁:利用临时顺序节点和事件监听,可靠性高,能自动释放,但部署维护成本高、性能低于Redis。
之后我会用大量篇幅拆解Redis方案——因为它是目前工业界应用最广泛、方案最成熟、但也是最容易用错的一种。
6. Redis分布式锁实战:SETNX手写到Redisson的完整进化
6.1 用SETNX实现分布式锁的三个迭代版本
第一版:最简单的SETNX。
public boolean tryLock(String key, String value, long expireMillis) { // 返回true表示获取锁成功 return redisTemplate.opsForValue().setIfAbsent(key, value, expireMillis, TimeUnit.MILLISECONDS); }setIfAbsent对应Redis的SET key value NX EX,原子地实现"不存在才设置且带过期时间"。这是目前最基础的Redis分布式锁写法,解决了互斥性和基本死锁防护(过期时间兜底)。
第二版:加入防误删。
只设置过期时间还不够,如果线程A持有锁执行较久,锁过期自动释放后,线程B拿到锁开始执行;此时线程A业务结束,执行删除锁操作,会把线程B持有的锁误删掉,B的锁形同虚设,两个线程同时进入临界区,超卖问题立刻复现。
正确做法是删除前比较value是否是自己写入的那个唯一值:
public boolean releaseLock(String key, String value) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(key), value); return result != null && result == 1L; }用Lua脚本保证"比对+删除"两步的原子性。这个value就是每次获取锁时生成的UUID或者业务请求ID,作为锁的唯一标识。
第三版:WatchDog自动续期。
第二个问题:锁过期时间设多少合适?设短了,业务没跑完锁没了,并发控制失效;设长了,持有锁的线程崩溃后其他线程要等很久。
Redisson的WatchDog机制是每lockLeaseTime / 3时间间隔自动续期一次(默认leaseTime是30秒,每10秒续期到30秒)。这样只要业务没结束,锁就一直续期;业务结束释放锁后,续期任务也随之取消。这个机制不是Redisson私有,但很多团队手写不完整——一个标准的续期调度器需要:启动一个后台定时任务,检查锁是否还存在且当前持有者是自己,是则延长过期时间,业务结束时关闭定时任务。
6.2 Redisson的使用与可重入实现
实际工程中不太建议自己手写上面这些细节,成熟方案Redisson已经把这些都处理好了:
RLock lock = redissonClient.getLock("inventory:lock:" + productId); try { // 尝试加锁,最多等待5秒,锁自动过期30秒,WatchDog自动续期 if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }Redisson的RLock是可重入的,同一个线程可以多次加锁同一把锁,内部用Hash结构维护线程标识和重入次数。对比手写SETNX,Redisson帮你处理了续期、防误删、重入和公平锁。
有一点需要注意:tryLock(waitTime, leaseTime, unit)传入leaseTime后,WatchDog续期机制就不会生效,锁在指定时间后必定释放。如果需要续期,使用不带leaseTime的重载方法,只在等待时间内尝试加锁,锁的自动过期时间由默认值控制并自动续期。
6.3 Redis主从切换引发的锁丢失问题
在实际生产架构中,Redis通常是主从模式。这里有一个隐蔽的风险:客户端A在主节点成功写入锁key,同步给从节点这一步尚未完成,主节点发生故障,从节点晋升为主节点,但新主节点没有这个锁key的记录。此时客户端B来加锁会发现这个key不存在,加锁成功,两个客户端同时持有锁,互斥失效。
这里需要引入Redis官方提到的RedLock算法:向多个独立的Redis节点同时加锁,超过半数成功才算获取锁成功。但RedLock争议很大,我的看法是:如果业务对互斥性极度敏感(比如资金操作、扣库存),最好别把命运压在Redis主从切换上,用ZooKeeper更稳妥;如果是常规业务容忍极小概率并发超量,RedLock或单节点+合理监控可以接受。
我在生产里见过一个折中做法:Redis锁负责高效互斥,同时在最终持久化层(数据库)做乐观锁或唯一索引兜底,双重校验。也就是"缓存层锁加快失败,数据库层锁保证最终一致"。这套方案虽然多一次DB校验,但能把损失控制在极低概率内。
7. ZooKeeper分布式锁:临时顺序节点为什么更可靠
7.1 基于ZK原生API实现分布式锁
ZooKeeper实现分布式锁的思路和Redis完全不同。它利用了ZooKeeper的两个特性:临时节点(客户端断开连接自动删除)和顺序节点(节点名自增序号)。
实现逻辑大致是:
- 在指定锁路径下创建一个临时顺序节点,比如
/locks/lock_000000001。 - 获取当前路径下所有子节点,判断自己是不是序号最小的那个。
- 如果是,获得锁;如果不是,对前一个节点注册监听。
- 前一个节点被删除(锁释放或客户端崩溃),自己被唤醒,重新判断。
因为每个客户端创建的节点是有序的,就形成了一条FIFO的等待队列,天然支持公平锁,且不会有"惊群效应"(每个节点只监听前一个节点)。
7.2 ZK锁对比Redis锁的本质差异
ZK锁的核心优势是:临时节点依赖客户端会话,一旦客户端崩溃或网络断开,会话失效,节点自动删除,锁自动释放,不会出现Redis锁那种"主从切换丢锁"问题。
性能上ZK锁平均耗时通常高于Redis锁,因为它有磁盘同步和节点监听回调的成本。但可靠性要求很高的场景,比如秒杀系统的库存扣减、分布式任务调度中的唯一执行者选举,我更倾向于ZK方案。
用Apache Curator框架实现ZK分布式锁非常简洁:
InterProcessMutex lock = new InterProcessMutex(client, "/locks/inventory_" + productId); try { if (lock.acquire(5, TimeUnit.SECONDS)) { // 业务处理 } } finally { lock.release(); }Curator还提供了InterProcessReadWriteLock(读写锁)、InterProcessSemaphoreMutex(不可重入锁),可以根据场景选择。
7.3 选型建议:别迷信某一种方案
我自己的选型参考表:
| 维度 | Redis锁 | ZooKeeper锁 |
|---|---|---|
| 性能 | 高,微秒级 | 中,毫秒级 |
| 可靠性 | 依赖部署模式,主从切换有丢锁风险 | 高,临时节点自动释放 |
| 复杂度 | 低,Redis通常已有 | 高,需要维护额外集群 |
| 公平性 | 默认不公平,需额外实现 | 天然公平 |
| 适用场景 | 常规业务限流、缓存防击穿、幂等控制 | 库存扣减、任务调度、分布式事务协调 |
如果Redis已经是你系统的基础组件,优先用Redisson封装好的锁;如果业务对一致性和可靠性极其敏感,ZK(或etcd)是更稳妥的选择。
8. 生产级落地:事务边界、锁粒度与性能压测的关键细节
8.1 锁与事务执行顺序
这是分布式锁实践里我最想强调的一点:锁必须在事务提交之后再释放,而且锁的释放最好放在事务提交方法的外层。
常见错误写法:
@Transactional public void deductStock(String productId, int count) { // 在这个方法内部获取分布式锁 RLock lock = redisson.getLock("lock:" + productId); lock.lock(); try { // 扣库存SQL } finally { lock.unlock(); } }问题在于:@Transactional的方法执行结束时,事务可能尚未提交。如果先unlock再提交事务,另一个线程在锁释放后立刻查到旧库存数据,又做了一次扣减,等于锁白加了。
正确姿势是让锁和事务边界分离:
public void deductStock(String productId, int count) { RLock lock = redisson.getLock("lock:" + productId); lock.lock(); try { // 手动控制事务范围,确保事务提交成功后再释放锁 doDeductWithTransaction(productId, count); } finally { lock.unlock(); } } @Transactional public void doDeductWithTransaction(String productId, int count) { // 真正的扣减逻辑 }这样事务提交完了,锁才释放,其他线程才能看到新数据。这里再补充一个细节:Spring的@Transactional默认是方法内代码执行完马上提交事务,所以如果你在事务方法内直接操作锁,一定要把锁放到事务方法外层的调用者那一层。
8.2 锁粒度设计的三个案例
锁粒度直接影响系统吞吐量。我用三类业务来说:
- 库存扣减:按
productId加锁,不同商品不互相影响。极端热点商品可再拆成多级库存桶。 - 订单支付幂等:按
orderId加锁,同一订单串行,不同订单并发。 - 用户余额变更:按
userId加锁,粒度足够细且安全。
尽量别做"全局锁"。我见过一个项目为了防止所有的写操作并发冲突,直接对一把全局锁加锁,结果压测QPS从预期的2000掉到50,排查半天发现是锁粒度太粗。
8.3 压测验证锁的正确性
分布式锁写完不压测等于没写。我的压测三步走:
- 单实例JUnit并发测试:用
CountDownLatch模拟多线程并发执行,验证互斥性。 - 多实例部署测试:在本机启动多个应用实例或端口不同的多个进程,通过
nginx或直连不同地址压测,验证分布式锁是否真正生效。 - 故障注入测试:杀掉持有锁的实例,观察锁能否在预期时间内自动释放,其他实例能否正常获取锁。
压测指标看两个:正确性(最终库存是否准确)和性能(QPS、平均响应时间、P99)。如果正确性没问题但性能极低,优先检查锁粒度和锁等待时间设置。
一个我在实战中总结出来的结论:分布式锁能用好,一半靠锁本身的实现,一半靠事务边界、锁粒度、超时时间这些业务侧参数。锁只是工具,设计合理才能发挥价值。
8.4 死锁与锁超时的检测手段
分布式环境下死锁不像单机那么好抓。我常用的排查手段:
- 通过
Redis的HGETALL查看锁key的持有者信息和重入次数。 - 通过
ZK的节点列表看临时顺序节点卡在哪个位置。 - 通过日志埋点记录"加锁时间、获得锁耗时、释放锁耗时",用监控平台看分布。
- 对每个关键锁加ThreadPoolExecutor任务超时告警,超过阈值就告警人工介入。
还有一条:锁key的命名空间一定要规范,比如{业务}:lock:{资源ID}。否则多业务混用同一Redis集群时,key冲突排查会非常痛苦。
9. 从锁机制到无锁设计:我在实战中总结的并发优化路线
9.1 锁不是唯一的答案
做高并发时间长了会发现,很多时候不应该先想"加什么锁",而是先想"能不能不加锁"。
以扣库存为例,如果允许超卖,完全可以用Redis的DECR原子操作直接扣减;如果要求精确库存,可以用数据库乐观锁UPDATE ... SET stock = stock - #{count} WHERE stock >= #{count},配合影响行数判断。这种"原子性由底层实现,而不是互斥锁"的思路,往往比锁更高效。
9.2 乐观锁在数据库层的落地
在数据库层用乐观锁解决并发扣减是分布式锁外的常用手段:
UPDATE inventory SET stock = stock - #{count}, version = version + 1 WHERE product_id = #{productId} AND stock >= #{count} AND version = #{version}影响行数大于0说明扣减成功,等于0说明并发冲突,业务方决定重试或返回失败。这种方案的好处是不用额外维护分布式锁基础设施,缺点是冲突频繁时重试会导致请求延迟增加。
9.3 串行化+异步化的极端思路
某个秒杀系统里我见过一种思路:把秒杀请求先全部写入MQ削峰,消费端串行处理,实际并发度被强制压到1。这种做法牺牲了部分吞吐但彻底避开锁竞争。它适用写的低吞吐但必须极稳的场景。
这给我们一个启示:高并发不是"越高越要上复杂技术",而是要根据真实业务约束选择最合适的策略。有时候把并发模型改串行反而比用一堆锁更简单、更可靠。
结束前的一点个人经验
最后分享一个实战中反复验证过的观点:判断系统是否需要引入分布式锁,先看你的服务是否真的会部署多个实例,再看共享资源是否会被多个实例同时写。单实例阶段引入分布式锁是过度设计,多实例阶段还只用synchronized那就是事故隐患。
如果真的要用分布式锁,建议直接把Redisson或Curator这套成熟方案落地上手,不要自己从SETNX开始造轮子。手写分布式锁能帮你理解原理,拿来当生产方案则太容易遗漏细节。我在某模拟项目里分阶段观察过:手写方案在上线后半年内踩到了续期失效、主从切换丢锁、误删他人锁三个问题,而这些问题在Redisson中都早已被处理得很完善。
从这个角度看,Java并发编程的学习路径也很清晰:先用好synchronized和JUC的锁工具理解本地互斥,再理解分布式环境下公共存储的互斥设计,最后再根据一致性和性能要求做技术选型。这条路径走通一遍,你对"锁"的理解就不再停留在API调用层面,而是真正建立起一套判断并发问题的工程思维。