Skip to content
Go back

Segmented分段锁——从单锁串行到10倍并发的演进

Segmented 分段锁优化:从单锁串行到 10 倍并发

一句话结论(30s)

分段锁的本质是把「一个队伍」拆成「多个队伍」——把全局资源按业务 ID 分片,不同分片独立加锁、完全并行,从而把并发度从 1 提升到分段数。它的关键设计是三步走:分片(资源分成 N 段)、路由(用 userId 取模确定性分配到某一段)、补偿(段内耗尽时安全跨段迁移)。核心权衡是「并发度」换「复杂度」——因为并发度上限等于段数,而段间迁移会引入死锁风险(需要按段号排序加锁或用 CAS 无锁操作打破循环等待),所以段数要按 CPU 核数 × 2 这类经验公式控制,不是越多越好。

核心原理(2min)

主流程:扣减请求先 userId % segmentCount 路由到固定段,然后只对 locks[seg] 加锁、扣 stocks[seg],不同段的请求完全并行;当本段库存为 0 时遍历其他段「偷」库存补偿。关键机制两点:一是路由必须确定性(同一个用户总打到同一段),随机路由会导致同一用户跨段、又退化成全局锁;二是段间迁移要防死锁——A 偷 B 的段、B 偷 A 的段会双向等待,解法是「按段号排序加锁」或直接用 AtomicInteger 做无锁 CAS。这个思想在 Java 7 ConcurrentHashMap 的 16 个 Segment、LongAdder 的动态 Cell 数组,乃至 Redis Cluster 的 16384 槽、Kafka 分区里都有投影。

底层深入(5-10min)

秒杀场景的锁瓶颈

// 最朴素的库存扣减
public class InventoryService {
    private int stock = 1000;

    public synchronized boolean deduct() {
        if (stock <= 0) return false;
        stock--;
        return true;
    }
}

1000 个请求同时抢购 → 1000 个线程排队等 synchronized同一时刻只有 1 个线程在执行 → 其余 999 个线程在 BLOCKED 状态。这个锁的时间复杂度是 O(n)——n 个线程抢同一把锁,吞吐量 = 1/扣减耗时。

想一想:为什么单锁的吞吐被卡死在”1/扣减耗时”?因为同一时刻只有 1 个线程在临界区里干活,剩下 n-1 个都在 BLOCKED 等;锁的粒度越大,浪费在排队上的并发就越多。

假设扣减操作需要 1ms(查 Redis + 写 MySQL),单锁的最大 QPS = 1000/1ms = 1000。超过 1000 的并发都是浪费——请求在积压,响应时间在飙升。

分段锁的思想:把”一个队伍”拆成”多个队伍”

核心洞察:不同用户的库存扣减不需要互斥。 用户 A 扣库存和用户 B 扣库存操作的是同一个 stock 字段,需要互斥——但如果能把库存拆分到多个桶里,不同桶之间就可以并行扣减。

单锁:
  所有请求 → [同一把锁] → 串行执行 → 1 线程工作,999 等待

分段锁(10 段):
  用户A(id=3)  → 3 % 10 = 3 → [锁3] → 扣 stock[3]
  用户B(id=17) → 17 % 10 = 7 → [锁7] → 扣 stock[7]  ← 与 A 并行!
  用户C(id=9)  → 9 % 10 = 9 → [锁9] → 扣 stock[9]  ← 与 A、B 并行!

每个分段独立加锁,不同分段的操作完全并行。锁的粒度从”全局”变成了”分段”——并发度 = 分段数。

想一想:为什么并发度上限恰好等于段数?因为不同段的锁互不影响、可以并行,但同一段内仍是串行;所以最多同时有”段数”个线程真正在工作,段数就是并发度的天花板。

完整实现代码

public class SegmentedStockService {
    // 10 个分段,每个分段有独立的库存 + 锁
    private final int segmentCount;
    private final int[] stocks;       // stocks[i] = 第 i 段的库存
    private final Object[] locks;     // locks[i] = 第 i 段的锁对象

    public SegmentedStockService(int totalStock, int segmentCount) {
        this.segmentCount = segmentCount;
        this.stocks = new int[segmentCount];
        this.locks = new Object[segmentCount];

        // 初始化:把总库存均匀分配到各段
        int base = totalStock / segmentCount;
        int remainder = totalStock % segmentCount;
        for (int i = 0; i < segmentCount; i++) {
            stocks[i] = base + (i < remainder ? 1 : 0); // 余数分摊给前几个段
            locks[i] = new Object();
        }
    }

    public boolean deduct(long userId) {
        int seg = (int) (Math.abs(userId) % segmentCount);  // userId 路由到段

        synchronized (locks[seg]) {   // 只锁当前段,其他段不受影响
            if (stocks[seg] <= 0) {
                // 本段已空 → 尝试从其他段"偷"库存(段间迁移)
                for (int i = 0; i < segmentCount; i++) {
                    if (i == seg) continue;
                    synchronized (locks[i]) {
                        if (stocks[i] > 0) {
                            stocks[i]--;
                            return true;
                        }
                    }
                }
                return false; // 全部段都已空
            }
            stocks[seg]--;
            return true;
        }
    }

    public int getTotalStock() {
        int total = 0;
        for (int s : stocks) total += s;
        return total;
    }
}

关键设计决策

路由算法:userId 取模

userId % segmentCount 将用户分配到固定的段。同一个用户的请求总是打到同一段 → 同一用户的多次请求自然串行化(防止一个用户并发扣两次)。

不要用随机路由——随机路由意味着同一个用户可能打到不同段,那就要跨段加锁 → 又退化成全局锁。

想一想:为什么随机路由会退化成全局锁?因为同一个用户这次打到段 3、下次打到段 7,为防同一用户并发扣两次,就不得不跨段加锁,等于又回到”一把大锁”管所有请求的局面。

段间迁移:段内库存不均时的补偿

如果段 3 的库存被抢光了,但段 7 还有大量库存,段 3 的用户怎么办?

上面的代码中,deduct() 在自身段库存为 0 时遍历其他段,尝试窃取库存。但这里有死锁风险——A 线程去偷 B 的段,B 线程去偷 A 的段,双向等待。

解决:按段号排序加锁(需要同时锁两个段时,总是先锁编号小的,再锁编号大的——所有线程用同一顺序,打破循环等待)。或者更简单的:段间迁移用 CAS 无锁操作(库存是 int,用 AtomicInteger 数组替代同步块)。

想一想:为什么”A 偷 B、B 偷 A”会死锁?因为 A 持有锁 A 等锁 B、B 持有锁 B 等锁 A,构成环路等待;而”按段号排序加锁”让所有线程都先锁编号小的段,等待方向统一后环就闭合不了。

段数计算公式

段数 = CPU 核心数 × 2

推导:一个 CPU 核心在同一时刻只能执行一个线程。分段锁的目的就是让更多线程能同时工作——但线程数超过 CPU 核数时,它们只是”并发”而不是”并行”。2 倍的系数考虑了 IO 阻塞时 CPU 可以切换去服务其他线程。

这是经验公式,不是定理。如果你的扣减逻辑有大量 IO(如写 MySQL),段数可以更大(如 CPU × 4~8),因为线程在等 IO 时 CPU 空闲,可以服务更多段。

与 JDK 中分段锁的对比

ConcurrentHashMap(Java 7)

Java 7 的 ConcurrentHashMap 使用分段锁——16 个 Segment,每个 Segment 内部是一个小 HashMap + ReentrantLock。写操作只锁对应 Segment,不同 Segment 之间完全并发。

Java 8 抛弃了分段锁(改为 CAS + synchronized 锁桶的第一个节点)。原因:分段锁的并发度受限于 Segment 数量——即使 map 有 10000 个桶,也只有 16 个 Segment,并发度上限 16。Java 8 的桶级别锁天然支持”桶数量”级别的并发度。

LongAdder

LongAdder 的分段思想更彻底:一个 Cell[] 数组,每个 Cell 里面有一个 value。线程写 LongAdder 时先 ThreadLocalRandom.getProbe() 拿到自己的 probe 值 → 路由到对应 Cell → 只 CAS 这个 Cell。最终 sum() 时遍历所有 Cell 求和。

LongAdder 的分段是动态的——Cell[] 初始为 null,竞争激烈时动态扩容。这是分段锁的终极形态:无锁 + 按需分段。

分段锁的适用场景

分段锁不是万能药。适用场景必须满足:

条件满足不满足
操作可分片库存、计数器、积分全局唯一的操作(如生成全局唯一订单号)
分片均匀userId 均匀分布userId 全部集中在少数几个值
段数可控10-20 段500 段 → 内存浪费 + 段间迁移复杂

总结

分段锁的思想一句话:“如果你有 10 个不同的东西,就别让它们共用 1 把锁。”

实现三步走:

  1. 分片:把资源分成 N 段,每段独立
  2. 路由:用业务 ID 取模,确定性分配到某一段
  3. 补偿:段内资源耗尽时,安全地跨段迁移

这个思想超越了 Java 语言——Redis Cluster 的 16384 个槽(CRC16(key) % 16384)、Kafka 的分区(hash(key) % partitionCount),本质上都是分段锁在分布式系统中的投影。

章末提问

追问 1:分段锁为什么能把并发度从 1 提到 N?它的上限是什么?

回答思路:结论先行——因为它把”一个队伍”拆成”多个队伍”,不同分段独立加锁、完全并行,并发度上限 = 段数。因为扣减请求按 userId % segmentCount 路由到固定段、只锁 locks[seg],不同段互不影响;但同一段内仍是串行,所以最多同时有”段数”个线程真正并行。

追问 2:段间迁移为什么可能死锁?怎么解决?

回答思路:结论先行——因为 A 段线程持锁 A 去偷锁 B、B 段线程持锁 B 去偷锁 A,会双向等待成环;解法是”按段号排序加锁”或改用 AtomicInteger 无锁 CAS。因为排序加锁让所有线程都先锁编号小的段,打破环路等待这一死锁条件;无锁 CAS 则根本不持锁等待,从根上消除死锁。

追问 3:为什么 Java 8 的 ConcurrentHashMap 抛弃了分段锁?

回答思路:结论先行——因为分段锁的并发度被 Segment 数量(16)卡死,Java 8 的桶级锁(CAS + synchronized 锁桶头节点)能支持”桶数量”级别的并发。因为哪怕 map 有 10000 个桶,Java 7 也只有 16 个 Segment、并发上限 16;改成锁单桶后不同桶完全并行,扩展性更好。


Share this post on:

Previous Post
乐观锁与悲观锁——读多写少与写多的两种世界观
Next Post
RestTemplate连接池优化——每次new都要三次握手?