1. 这不是“加个synchronized就能跑通”的问题:一次生产环境线程安全事故的复盘起点
去年冬天,某电商大促压测期间,一个看似简单的库存扣减服务突然在凌晨两点开始持续超时。接口平均响应从80ms飙升至2.3秒,错误率突破15%。运维告警堆成山,而开发团队最初排查方向全是“是不是数据库慢了”“是不是Redis连接池不够”。直到凌晨四点,一位老同事把线程dump文件拖进VisualVM,放大到某个业务线程栈帧时,指着一行inventory.decrementAndGet()轻声说:“等等——这个AtomicInteger,我们真以为它万能吗?”
这句话成了整个复盘的起点。我们后来发现,问题根本不在原子类本身,而在于它被嵌套在一个非原子的复合操作里:先用get()读取当前值判断是否足够,再用decrementAndGet()执行扣减。两个原子操作之间存在时间窗口,多个线程并发时,完全可能同时读到“库存充足”,然后一起扣减,导致超卖。这恰恰暴露了一个被大量开发者忽略的事实:线程安全不是靠堆砌“线程安全类”就能自动获得的,而是对整个操作语义边界的精确控制。
这正是本文要深挖的核心——Java线程安全从来不是一道选择题(“用锁还是用CAS?”),而是一张需要你亲手绘制的作战地图。这张图上,synchronized和ReentrantLock是重武器,但开火前得确认目标坐标;volatile是信号旗,只管传递状态,不管执行逻辑;CAS是精准点射,但得防备ABA陷阱和自旋开销;而wait/notify、Condition、CountDownLatch这些线程通信机制,则是不同部队间的协同指令系统。它们各自有明确的职责边界、适用场景和致命短板。本文不讲概念定义,不列API文档,而是以一个真实可复现的库存服务为锚点,逐层拆解每种机制在真实代码中如何落地、为何这样设计、踩过哪些坑、以及当它们组合使用时,边界在哪里、雷区在何处。如果你曾写过private static volatile boolean flag = false;却不确定它到底保证了什么,或者在ConcurrentHashMap里加了锁还遇到死锁,那这篇复盘就是为你写的。
2. 锁机制:从synchronized到AQS,不是“加锁”而是“定义临界区”
2.1 synchronized:JVM层面的“自动挡”,但档位切换逻辑藏在字节码里
很多开发者把synchronized当作最省事的方案,却很少有人真正看过它编译后的字节码。我们用一个极简的库存扣减方法来观察:
public class InventoryService { private int stock = 100; public void deduct(int amount) { synchronized (this) { if (stock >= amount) { stock -= amount; System.out.println("扣减成功,剩余:" + stock); } else { System.out.println("库存不足"); } } } }反编译后关键字节码如下(简化):
0: aload_0 1: dup 2: astore_1 3: monitorenter // 进入同步块:获取monitor锁 4: aload_0 5: getfield #2 // 获取stock字段值 8: iload_2 9: if_icmplt 27 // 判断stock >= amount 12: aload_0 13: aload_0 14: getfield #2 17: iload_2 18: isub 19: putfield #2 // 执行stock -= amount 22: getstatic #3 // 输出日志 ... 27: aload_1 28: monitorexit // 退出同步块:释放monitor锁 29: goto 37 32: astore_2 33: aload_1 34: monitorexit // 异常路径也要释放锁 35: aload_2 36: athrow 37: return关键点在于monitorenter和monitorexit指令。JVM规范规定,每个对象实例都有一个与之关联的monitor(监视器),monitorenter会尝试获取该monitor的所有权。如果monitor未被占用,当前线程立即成为owner;如果已被其他线程占用,当前线程将被阻塞,进入该monitor的Entry Set等待队列。而monitorexit则负责释放所有权,并唤醒Entry Set中的一个等待线程。
提示:
synchronized的“自动释放”特性,本质是JVM在字节码层面强制插入了monitorexit指令,无论正常执行完还是抛出异常。这是它比手动Lock更安全的根本原因——你无法绕过这个保障。
但synchronized的“自动挡”也有代价。它的锁升级过程(偏向锁→轻量级锁→重量级锁)虽然优化了无竞争场景,但在高竞争下,monitorenter的开销远高于Lock.lock()。更重要的是,它无法实现可中断等待、超时获取或公平锁。当你的业务要求“用户下单等待库存锁不能超过3秒,超时就放弃”,synchronized就无能为力了。
2.2 ReentrantLock:手动挡的精密控制,但油门和刹车全在你手上
ReentrantLock的设计哲学是“把控制权交还给开发者”。它基于AQS(AbstractQueuedSynchronizer)框架构建,核心是一个state整型变量和一个FIFO等待队列。我们来看一个支持超时的库存扣减实现:
public class InventoryServiceWithLock { private final ReentrantLock lock = new ReentrantLock(true); // true表示公平锁 private int stock = 100; public boolean deductWithTimeout(int amount, long timeoutMs) throws InterruptedException { // 尝试在timeoutMs内获取锁,失败则返回false if (!lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) { return false; // 获取锁超时,直接放弃 } try { if (stock >= amount) { stock -= amount; return true; } else { return false; } } finally { lock.unlock(); // 必须在finally中释放!这是手动挡的铁律 } } }这里的关键差异在于:
- 可中断性:
lock.lockInterruptibly()允许线程在等待锁时响应interrupt()信号,避免无限期挂起。 - 超时获取:
tryLock(long, TimeUnit)让业务逻辑可以优雅降级,而不是死等。 - 公平性:构造时传入
true,确保等待时间最长的线程优先获取锁,避免饥饿。
但手动挡的风险也在此:忘记在finally中调用unlock(),就会造成永久性死锁。我见过最典型的错误是在try块里写了return,却把unlock()放在了return之后——这段代码永远执行不到。正确的模式永远是:
lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); // 这行必须存在,且不能被任何条件跳过 }2.3 AQS:所有显式锁的“发动机总成”,理解它才能不被API表象迷惑
ReentrantLock、CountDownLatch、Semaphore甚至FutureTask,底层都依赖AQS。它的核心思想极其精炼:用一个volatile int state变量表示同步状态,用一个CLH(Craig, Landin, and Hagersten)变种的FIFO队列管理等待线程。
AQS定义了两种资源共享方式:
- 独占式(Exclusive):如
ReentrantLock,同一时刻只允许一个线程持有锁。关键方法是tryAcquire(int)和tryRelease(int)。 - 共享式(Shared):如
Semaphore、CountDownLatch,允许多个线程同时访问。关键方法是tryAcquireShared(int)和tryReleaseShared(int)。
我们以ReentrantLock的非公平锁tryAcquire为例(简化):
// NonfairSync.tryAcquire() final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 读取当前state值 if (c == 0) { // 如果锁空闲 if (compareAndSetState(0, acquires)) { // CAS设置state为acquires setExclusiveOwnerThread(current); // 设置当前线程为owner return true; } } else if (current == getExclusiveOwnerThread()) { // 如果是重入 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); // 直接更新state,无需CAS return true; } return false; }这段代码揭示了AQS的精髓:所有复杂的锁行为,最终都归结为对state变量的原子读-改-写操作。compareAndSetState(0, acquires)就是一次CAS操作,它保证了“检查锁空闲”和“设置锁被占用”这两个动作的原子性。而重入时直接setState(nextc),是因为此时已知当前线程就是owner,无需再做并发检查。
注意:AQS的
state变量是volatile修饰的,这保证了getState()读取的总是最新值,且setState()的写入对其他线程可见。但volatile不保证复合操作的原子性,所以state++这种操作必须用CAS。
3. volatile:内存可见性的“单向广播站”,不是线程安全的万能胶
3.1 为什么volatile不能替代synchronized?一个经典反例
很多初学者认为:“既然volatile能保证可见性,那我把它加在stock字段上,不就不用锁了吗?” 我们来验证这个想法:
public class VolatileInventory { private volatile int stock = 100; public void deduct(int amount) { // 问题在这里:读取和写入是两个独立的volatile操作 if (stock >= amount) { // volatile read stock = stock - amount; // volatile write,但中间的计算不是原子的! } } }这段代码在多线程下必然出错。原因在于stock = stock - amount这个表达式,实际包含三个步骤:
int temp = stock;(volatile read)temp = temp - amount;(CPU计算)stock = temp;(volatile write)
步骤1和3虽然是volatile操作,但步骤2是纯本地计算,且步骤1和3之间没有同步屏障。因此,线程A读到stock=100,开始计算100-10=90;与此同时,线程B也读到stock=100,计算100-10=90;然后A和B都把90写回stock。结果本应是80,却变成了90——这就是经典的竞态条件(Race Condition)。
volatile只保证了单个读或写操作的可见性与有序性,它不提供原子性。它解决的是“一个线程修改了变量,另一个线程何时能看到这个修改”的问题,而不是“如何保证一个复合操作不被其他线程打断”的问题。
3.2 volatile的真正战场:状态标志与双重检查锁定(DCL)
volatile最不可替代的场景,是作为轻量级的状态标志。比如一个服务的启动/停止开关:
public class ServiceController { private volatile boolean running = false; // 关键:volatile修饰 public void start() { if (!running) { // 启动耗时操作... running = true; // volatile write } } public void stop() { running = false; // volatile write } public boolean isRunning() { return running; // volatile read } }这里running的读写都是单个操作,volatile完美满足需求:start()中设为true后,isRunning()在其他线程中一定能立刻读到true,无需加锁。
另一个经典应用是双重检查锁定(Double-Checked Locking),用于单例模式的延迟初始化:
public class Singleton { private static volatile Singleton instance; // 必须volatile! public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题就在这里! } } } return instance; } }为什么instance必须是volatile?因为new Singleton()不是原子操作,它分为三步:
- 分配内存空间
- 在内存空间中初始化对象(调用构造函数)
- 将
instance引用指向分配的内存地址
在JVM和CPU的重排序优化下,步骤2和3可能被交换。即线程A执行到步骤3(instance已指向一块内存),但步骤2(构造函数还没执行完)尚未完成。此时,线程B恰好执行第一次检查if (instance == null),发现instance != null,就直接返回这个“半初始化”的对象,导致NullPointerException或诡异的逻辑错误。
volatile的禁止指令重排序特性,强制保证了步骤2(初始化)一定在步骤3(赋值)之前完成,彻底杜绝了这个问题。
经验:
volatile的适用场景非常明确——当你只需要保证一个布尔值、整数或引用的读写可见性,且该变量的读写操作本身是原子的、不依赖于其旧值时,volatile是最佳选择。一旦涉及“读-改-写”(如i++、list.add()),就必须升级到锁或CAS。
4. CAS:乐观锁的精密手术刀,但需警惕ABA与自旋开销
4.1 CAS的原子性原理:CPU指令级的“比较并交换”
CAS(Compare-And-Swap)是java.util.concurrent包的基石。它的伪代码逻辑是:
boolean compareAndSet(expectedValue, newValue) { if (currentValue == expectedValue) { currentValue = newValue; return true; } else { return false; } }但关键在于,这个“比较”和“交换”在硬件层面是一条不可分割的CPU指令(如x86的cmpxchg)。它由CPU直接保证原子性,无需操作系统介入,因此性能远高于synchronized或ReentrantLock。
Java中Unsafe类提供了底层CAS能力,而AtomicInteger等类是对它的封装。我们看AtomicInteger.getAndIncrement()的源码(简化):
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); } // Unsafe.getAndAddInt() public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); // 读取当前值(volatile read) } while (!compareAndSwapInt(o, offset, v, v + delta)); // CAS尝试更新 return v; }这是一个典型的自旋(Spin)实现:不断读取当前值v,然后尝试用CAS将v更新为v+delta。如果成功,跳出循环;如果失败(说明其他线程已修改了值),就重新读取新值,再次尝试。
4.2 ABA问题:不是“没变”,而是“变了又变回来”,CAS如何应对?
ABA问题是CAS最著名的缺陷。想象一个库存场景:初始stock=100。线程A读到100,准备扣减10;此时线程B抢先进来,把100扣减到90,又立刻补货加回100;当线程A执行CAS时,发现100==100,于是成功将100改为90。结果是:两次操作(B的扣减和补货)被A的CAS“覆盖”了,库存凭空少了10。
解决方案是引入版本号。AtomicStampedReference正是为此设计:
// 创建时指定初始值和初始版本号 AtomicStampedReference<Integer> stockRef = new AtomicStampedReference<>(100, 0); public boolean deductWithStamp(int amount) { int[] stampHolder = new int[1]; int currentStock = stockRef.get(stampHolder); // 同时获取值和版本号 int currentStamp = stampHolder[0]; while (true) { if (currentStock >= amount) { // 尝试用CAS更新:只有当值和版本号都匹配时才成功 if (stockRef.compareAndSet(currentStock, currentStock - amount, currentStamp, currentStamp + 1)) { return true; } } else { return false; } // CAS失败,重新读取最新值和版本号 currentStock = stockRef.get(stampHolder); currentStamp = stampHolder[0]; } }每次成功更新,版本号stamp都会递增。即使值变回原样,版本号也不同了,CAS自然失败。这就像银行流水,不仅要看余额,还要看交易序号。
4.3 自旋开销:当“乐观”变成“盲目等待”,如何优雅退场?
CAS的自旋策略在低竞争下效率极高,但在高竞争下,线程会陷入“读-失败-重试”的死循环,白白消耗CPU。LongAdder就是针对此问题的优化方案:
public class InventoryCounter { private final LongAdder counter = new LongAdder(); public void deduct(int amount) { counter.add(-amount); // 内部采用分段计数(Striped64) } public long getStock() { return counter.sum(); // 汇总所有分段的值 } }LongAdder的思路是“化整为零”:它内部维护一个Cell[]数组,每个线程尝试更新时,先通过哈希算法定位到一个特定的Cell进行CAS。只有当该Cell竞争激烈时,才扩容数组,增加新的Cell。这样,多个线程的操作被分散到不同的内存位置,极大降低了CAS冲突概率。sum()方法则遍历所有Cell求和。
实测数据:在100个线程并发调用increment()时,LongAdder的吞吐量通常是AtomicLong的5倍以上。但它的代价是sum()不是强一致的——它返回的是一个近似快照值,适用于统计类场景(如QPS计数),而不适用于强一致性要求的库存扣减。
踩坑经验:不要迷信“无锁”。
LongAdder的sum()在极端情况下可能比实际值小(因为某些线程的更新还未被sum()看到)。如果你的业务要求“每次查询库存必须绝对准确”,那LongAdder就不合适,必须回到ReentrantLock或StampedLock。
5. 线程通信:从wait/notify到JUC工具,协同作战的指挥系统
5.1 wait/notify:Object级别的原始信号灯,但必须与synchronized捆绑
wait()和notify()是Java最基础的线程协作机制,但它们的使用规则极其严格:
- 必须在synchronized同步块中调用;
- 调用对象必须是synchronized锁住的那个对象;
wait()会释放锁,notify()不会释放锁。
一个经典的生产者-消费者模型:
public class BoundedBuffer { private final List<Integer> buffer = new ArrayList<>(); private final int capacity = 10; public void produce(int item) throws InterruptedException { synchronized (this) { while (buffer.size() == capacity) { // 必须用while,不是if! this.wait(); // 释放this锁,进入等待队列 } buffer.add(item); this.notifyAll(); // 唤醒所有等待者(生产者唤醒消费者,消费者也可能唤醒生产者) } } public int consume() throws InterruptedException { synchronized (this) { while (buffer.isEmpty()) { // 同样必须用while this.wait(); } int item = buffer.remove(0); this.notifyAll(); return item; } } }为什么必须用while循环,而不是if?假设三个消费者线程都在wait(),此时一个生产者notifyAll(),三个消费者全部被唤醒。但只有一个能成功remove(),另外两个会因buffer.isEmpty()再次进入wait()。如果用if,第二个消费者被唤醒后会直接执行remove(0),导致IndexOutOfBoundsException。
notify()和notifyAll()的选择也至关重要。notify()只唤醒一个等待线程,但无法保证唤醒的是哪个线程。在复杂场景下(如多个条件等待),它可能导致信号丢失或死锁。notifyAll()虽然开销稍大,但能确保所有相关等待线程都有机会重新检查条件,是更安全的选择。
5.2 Condition:Lock的“多路复用”信号系统,告别单一等待队列
ReentrantLock配合Condition,解决了synchronized只能有一个隐式等待队列的痛点。我们可以为不同条件创建独立的Condition:
public class BoundedBufferWithCondition { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // 缓冲区不满条件 private final Condition notEmpty = lock.newCondition(); // 缓冲区不空条件 private final List<Integer> buffer = new ArrayList<>(); private final int capacity = 10; public void produce(int item) throws InterruptedException { lock.lock(); try { while (buffer.size() == capacity) { notFull.await(); // 等待notFull条件,释放lock } buffer.add(item); notEmpty.signal(); // 唤醒等待notEmpty条件的线程 } finally { lock.unlock(); } } public int consume() throws InterruptedException { lock.lock(); try { while (buffer.isEmpty()) { notEmpty.await(); // 等待notEmpty条件 } int item = buffer.remove(0); notFull.signal(); // 唤醒等待notFull条件的线程 return item; } finally { lock.unlock(); } } }Condition的优势在于精准唤醒。生产者调用notFull.await()后,只会被notFull.signal()唤醒;消费者调用notEmpty.await()后,只会被notEmpty.signal()唤醒。这避免了synchronized中notifyAll()唤醒所有线程造成的“惊群效应”,提升了效率。
5.3 CountDownLatch与CyclicBarrier:面向场景的“倒计时”与“集合点”
CountDownLatch和CyclicBarrier都用于协调多个线程的到达,但语义截然不同:
| 特性 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 用途 | 一个或多个线程等待其他线程完成一组操作 | 多个线程互相等待,直到全部到达一个屏障点 |
| 计数器 | 初始化后只能递减,不可重置 | 到达后自动重置,可重复使用 |
| 触发动作 | 计数器为0时,所有等待线程被释放 | 最后一个线程到达时,所有线程被释放,并可执行barrierAction |
一个库存预热场景:系统启动时,需要并行加载商品、价格、库存三个维度的数据,全部加载完成后,服务才对外提供。
public class InventoryPreloader { private final CountDownLatch latch = new CountDownLatch(3); public void preload() throws InterruptedException { // 并发启动三个加载任务 new Thread(this::loadProducts).start(); new Thread(this::loadPrices).start(); new Thread(this::loadStocks).start(); latch.await(); // 主线程等待,直到latch计数为0 System.out.println("预热完成,服务启动!"); } private void loadProducts() { // 加载商品逻辑... latch.countDown(); // 完成后计数减1 } private void loadPrices() { // 加载价格逻辑... latch.countDown(); } private void loadStocks() { // 加载库存逻辑... latch.countDown(); } }而CyclicBarrier更适合“多线程协作完成一个任务”的场景,比如分布式ID生成器中,多个节点需要定期同步时间戳:
public class TimeSyncBarrier { private final CyclicBarrier barrier = new CyclicBarrier(3, () -> { // 当3个线程都到达时,执行此回调(同步时间) System.out.println("时间同步完成!"); }); public void syncTime() throws InterruptedException, BrokenBarrierException { // 模拟各节点执行自己的时间校准逻辑 Thread.sleep(100); barrier.await(); // 等待其他节点 } }关键区别:
CountDownLatch是“一等多”(主线程等子线程),CyclicBarrier是“多等多”(所有线程互等)。选错会导致程序永远卡住。
6. 组合拳实战:一个高并发库存服务的完整实现与避坑指南
6.1 需求再确认:什么是真正的“线程安全库存”?
在动手编码前,我们必须厘清业务语义。一个电商库存服务,通常需要满足:
- 强一致性:任何时刻,数据库记录的库存数 = 系统中所有未完成订单占用的库存 + 可售库存。
- 防超卖:并发扣减时,绝不允许
stock变为负数。 - 高性能:在大促峰值下,QPS需达到10万+/秒。
- 可降级:当核心库存服务不可用时,能快速切换到本地缓存或限流策略。
这意味着,我们不能只考虑单机内存中的int stock,而必须将数据库事务、缓存一致性、分布式锁纳入整体设计。本文聚焦单机JVM内的核心机制,但会明确指出与外部系统的衔接点。
6.2 方案选型对比:为什么最终选择“Redis Lua脚本 + 本地CAS”混合架构?
我们评估了三种主流方案:
| 方案 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 纯数据库乐观锁 | UPDATE inventory SET stock=stock-? WHERE id=? AND stock>=? | 强一致性,无需额外组件 | 高并发下数据库压力巨大,连接池易打满 | 中小流量,强一致性要求极高 |
| Redis分布式锁(Redlock) | SET resource_name random_value NX PX 30000 | 解耦数据库,性能好 | 锁失效时间难精确控制,网络分区下可能失效 | 分布式系统,容忍短暂不一致 |
| Redis Lua脚本 + 本地CAS | Lua脚本在Redis端原子执行扣减,本地用AtomicLong做二级缓存 | 原子性由Redis保证,本地CAS加速读取,Lua脚本可实现复杂逻辑 | 需要Lua脚本编写能力,调试稍复杂 | 高并发,强一致性+高性能双重要求 |
最终选择了第三种。理由很务实:Redis的单线程模型天然保证了Lua脚本的原子性,而AtomicLong在本地做读缓存,将90%的读请求拦截在JVM内,只有写请求才穿透到Redis。这既满足了强一致性(写操作原子),又实现了高性能(读操作零延迟)。
6.3 核心代码实现:从原子操作到异常兜底的完整链路
@Component public class InventoryService { @Autowired private StringRedisTemplate redisTemplate; // 本地库存缓存,用于高频读取 private final AtomicLong localStockCache = new AtomicLong(-1); // -1表示未初始化 private final ScheduledExecutorService refreshScheduler = Executors.newSingleThreadScheduledExecutor(); // Redis Lua脚本:原子扣减 private static final String LUA_DEDUCT_SCRIPT = "local stock = tonumber(redis.call('GET', KEYS[1])) " + "if not stock or stock < tonumber(ARGV[1]) then " + " return -1 " + "else " + " return redis.call('DECRBY', KEYS[1], ARGV[1]) " + "end"; private final DefaultRedisScript<Long> deductScript = new DefaultRedisScript<>(LUA_DEDUCT_SCRIPT, Long.class); public InventoryService() { // 启动时异步加载库存到本地缓存 refreshLocalCache(); // 每30秒刷新一次本地缓存,防止长时间不一致 refreshScheduler.scheduleAtFixedRate( this::refreshLocalCache, 0, 30, TimeUnit.SECONDS); } private void refreshLocalCache() { try { String stockStr = redisTemplate.opsForValue().get("inventory:1001"); long stock = stockStr != null ? Long.parseLong(stockStr) : 0; localStockCache.set(stock); } catch (Exception e) { // 日志记录,但不中断主流程 log.warn("Refresh local cache failed", e); } } /** * 扣减库存 * @param itemId 商品ID * @param amount 扣减数量 * @return 扣减后剩余库存,-1表示失败(库存不足或异常) */ public long deduct(String itemId, int amount) { try { // 1. 先尝试本地CAS扣减(乐观,快速失败) long current = localStockCache.get(); if (current != -1 && current >= amount) { long updated = current - amount; // CAS更新,如果失败说明本地缓存已过期,走Redis if (localStockCache.compareAndSet(current, updated)) { return updated; } } // 2. 本地CAS失败,走Redis原子扣减 String key = "inventory:" + itemId; Long result = redisTemplate.execute( deductScript, Collections.singletonList(key), String.valueOf(amount) ); if (result != null && result >= 0) { // Redis扣减成功,更新本地缓存 localStockCache.set(result); return result; } else { return -1; // 库存不足 } } catch (Exception e) { log.error("Deduct inventory failed for item {}", itemId, e); // 降级:返回本地缓存值(可能不准,但保证可用) return localStockCache.get(); } } /** * 查询库存(只读,走本地缓存) */ public long getStock(String itemId) { return localStockCache.get(); } }6.4 生产环境踩坑实录:那些文档里不会写的细节
坑1:本地缓存的“脏读”与“雪崩”
- 现象:大促期间,发现部分商品库存显示为负数。
- 根因:
localStockCache.compareAndSet(current, updated)在高并发下,多个线程同时读到current=100,都计算100-10=90,然后都CAS成功,导致本地缓存被多次减为90,而Redis只被扣减了一次。 - 修复:移除本地CAS扣减,改为本地只读缓存。扣减操作一律走Redis Lua脚本,本地缓存仅用于
getStock()查询。deduct()方法中,localStockCache.get()只用于快速判断“大概率不足”,不参与决策。
坑2:Redis连接池耗尽
- 现象:
deduct()方法大量超时,redisTemplate.execute()耗时飙升。 - 根因:
StringRedisTemplate默认连接池大小为8,而我们的QPS峰值达5万,连接池瞬间打满。 - 修复:配置
LettuceClientConfiguration,将maxActive设为500,maxIdle设为200,并启用usePool=true。
坑3:Lua脚本的原子性边界
- 现象:Lua脚本执行成功,但业务逻辑后续报错,导致库存已扣减但订单未创建,出现“幽灵扣减”。
- 根因:Lua脚本只保证Redis操作原子,不保证整个业务事务。必须在业务层实现补偿机制。
- 修复:引入消息队列。
deduct()成功后,发送一条“库存扣减成功”消息;订单服务消费该消息,创建订单;若订单创建失败,则发送“库存回滚”消息,由库存服务执行INCRBY操作。
最后一点心得:线程安全不是终点,而是起点。一个真正可靠的库存系统,必须是“JVM内核机制 + 分布式协调 + 业务补偿”三层防御体系。本文所讲的锁、CAS、volatile、通信机制,只是第一层——它们决定了你的代码在单机上能否正确运行。而第二层(Redis、ZooKeeper)和第三层(消息队列、Saga事务)才是保障分布式系统最终一致性的关键。永远记住:没有银弹,只有根据业务场景精心组装的解决方案。