《Java 并发编程·性能诊断与事故》11.1 数据库行锁与 Java 锁的叠加

Java 侧锁顺序正确,事务里的 SQL 顺序却反了;或者 Java 锁与连接池交叉等待,JVM 死锁检测完全看不见。本节用纯 JDK 的行锁替身与连接池复现两类叠加死锁,给出真实线程转储判据、未在本机验证的 pg_locks 与 innodb status 读法,以及三者的耦合边界。

承接的问题

4.3 处理的是 JVM 内部的锁顺序,前提是所有参与者共享同一张等待图。这一节把边界往外推一层:业务代码里同时存在三种资源——Java 锁、数据库行锁、连接池连接。它们各自都可能有正确的顺序,合起来却仍然成环。

三类构造症状:

  • 接口超时,CPU 不高、GC 正常,重启后能好一阵;两个实例同时卡住,Thread.print 里一个线程在 SocketRead,其余在 BLOCKED 或 parking;
  • Java 侧明明按会员 ID 排了序,数据库还是报死锁,因为真正被争用的是两张图书行,Java 锁根本没覆盖到;
  • 连接池调小之后死锁消失了,调大又出现;或者反过来,池越小死锁越频繁。

先给结论:Java 锁顺序正确,只保证 JVM 内的等待图无环。数据库行锁、连接池许可都是 JVM 看不见的资源,跨资源的环不会被 findDeadlockedThreads() 报出来,也不会在转储里出现 Found one Java-level deadlock。诊断必须把三张图拼起来看。

数据口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;实验期间 load average 在 70–170,计时为单次运行、随调度波动、不作基准。本机没有安装 PostgreSQL / MySQL / Docker,本节所有 SQL 片段与数据库输出格式均未在本机执行,涉及数据库的判据只写“看哪些列”,不贴任何 pg_locks 或 innodb status 的输出。Java 侧的行为用纯 JDK 模拟并真实跑过。

症状:JVM 说没有死锁

最小复现只需要一把 Semaphore 冒充连接池,不需要真实数据库。池大小为 1:tx-M1 先拿 Java 锁 A,再拿唯一连接,然后去拿 B 锁;tx-M2 先拿 B 锁,再去拿连接。

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;

public final class PoolOverlapLab {
    static final ReentrantLock A = new ReentrantLock();
    static final ReentrantLock B = new ReentrantLock();
    static final Semaphore POOL = new Semaphore(1);      // 池里只有一个连接

    public static void main(String[] args) throws Exception {
        CountDownLatch m1HasConn = new CountDownLatch(1);
        CountDownLatch m2HasLock = new CountDownLatch(1);
        Thread.ofPlatform().name("tx-M1").start(() -> {
            A.lock();
            try {
                POOL.acquire();                          // 持有 A + 唯一连接
                m1HasConn.countDown();
                m2HasLock.await();
                B.lock();                                // 被 tx-M2 挡住
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                A.unlock();
            }
        });
        m1HasConn.await();
        Thread.ofPlatform().name("tx-M2").start(() -> {
            B.lock();
            try {
                m2HasLock.countDown();
                POOL.acquire();                          // 池已空,被 tx-M1 挡住
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                B.unlock();
            }
        });
        Thread.sleep(1500);
        long[] dead = java.lang.management.ManagementFactory.getThreadMXBean()
                .findDeadlockedThreads();
        System.out.println("findDeadlockedThreads=" + (dead == null ? "null" : java.util.Arrays.toString(dead)));
        System.out.println("pid=" + ProcessHandle.current().pid());
        Thread.sleep(12_000);
    }
}

本机实跑输出:

tx-M1 holds A + connection, now wants B
tx-M2 holds B, now wants a connection
findDeadlockedThreads=null
pid=98333

两条资源图各自无环:Java 锁图里 M1 等 B、M2 持有 B 就结束了;连接池图里只有 M2 在等。合起来才是环。JVM 的死锁检测只看 Java 对象之间的等待关系,所以返回 null;同一时刻抓的 jcmd Thread.print -l 里也没有任何 deadlock 段落。

这就是最危险的一类叠加:监控显示“无死锁”,系统实际已经停摆。值班时只看 deadlock 告警会漏掉它。

证据:SocketRead 与 BLOCKED 同时出现

第二个实验把“等数据库”换成真实的阻塞 socket。服务端读一行后停 4 秒再回,模拟一条持锁很久的 SQL;svc-hold-guard 持有 Java 锁期间做这次读,svc-wait-guard 随后请求同一把锁。

Thread.ofPlatform().name("svc-hold-guard").start(() -> {
    GUARD.lock();
    try (Socket s = new Socket("127.0.0.1", port)) {
        s.getOutputStream().write("SELECT stock FROM book WHERE isbn='B-1'\n".getBytes());
        s.getOutputStream().flush();
        BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream()));
        System.out.println("svc-hold-guard got: " + in.readLine());
    } catch (IOException e) {
        System.out.println("svc-hold-guard: " + e);
    } finally {
        GUARD.unlock();
    }
});

运行 2.5 秒后抓的转储(本机真实输出,节选):

"svc-hold-guard" #26 ... runnable
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.SocketDispatcher.read0(java.base@25.0.5/Native Method)
	at sun.nio.ch.NioSocketImpl.implRead(java.base@25.0.5/NioSocketImpl.java:305)
	at java.net.Socket$SocketInputStream.read(java.base@25.0.5/Socket.java:964)
	at java.io.BufferedReader.readLine(java.base@25.0.5/BufferedReader.java:400)
	at DumpLab.lambda$main$1(DumpLab.java:38)

   Locked ownable synchronizers:
	- <0x000000061fc19878> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
	- <0x000000061ffd7450> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

"svc-wait-guard" #27 ...
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@25.0.5/Native Method)
	- parking to wait for  <0x000000061fc19878> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

读法有三层:

  1. RUNNABLE + SocketDispatcher.read0 说明线程在等网络,不是在算,也不是被锁住。它可能是健康的 IO 等待,也可能是数据库侧行锁导致的慢查询——转储本身分不出来,必须去数据库侧确认。
  2. 同一个线程的 Locked ownable synchronizers 里有业务锁,说明它“拿着锁等 IO”。这正是叠加的源头:锁的持有时间被网络和数据库拉长到不可控。
  3. 另一个线程 parking to wait for 的地址与持有者完全一致,说明它被这把锁挡住。BLOCKED(synchronized)和 parking(ReentrantLock)只是锁类型不同,等待关系一样。

有一个容易误读的细节:上面持有者列了两个 ownable synchronizer,其中 0x...7450 不是业务锁,而是 sun.nio.ch.NioSocketImpl 内部的 readLock(javap -p sun.nio.ch.NioSocketImpl 可见 private final ReentrantLock readLock)。写检测脚本时若不按类名过滤,会把每个正在读 socket 的线程都算成“多持一把锁”,反向边统计立刻失真。

判据归纳:看到 SocketRead/socketRead0 的 RUNNABLE 线程,同时它的 ownable synchronizers 非空,就去查这个线程手里的锁是谁在等。如果等它的是业务线程,且数据库侧存在行锁等待或慢查询,基本可以判定为跨资源叠加。

根因一:SQL 顺序反了,Java 侧顺序正确也没用

真实系统里最常见的形态:Java 锁按 memberId 加,SQL 更新按 isbn 走,两套键空间不重合。两个会员同时预约同一对书,Java 锁互不相干,数据库行锁成环。

// tx-M1:会员 M-1 预约 B-A、B-B
tx.lock("B-A");
tx.lock("B-B");
// tx-M2:会员 M-2 预约 B-B、B-A(请求参数顺序相反)
tx.lock("B-B");
tx.lock("B-A");

为了能真实复现,我用约 140 行纯 JDK 写了一个“行锁替身” SimDb:ServerSocket 收发文本协议,按 key 维护 owner,用 waitingFor 维护等待图,请求第二把锁前先沿等待图找环,找到就中止请求方事务。它不是 MySQL/InnoDB,判据是我自己实现的等待图,输出不能当作任何数据库的日志。核心方法:

private String lock(String tx, String key) {
    synchronized (MON) {
        String holder = owner.get(key);
        if (holder == null || holder.equals(tx)) {
            owner.put(key, tx);
            held.computeIfAbsent(tx, k -> new HashSet<>()).add(key);
            return "OK " + key;
        }
        List<String> cycle = cyclePath(holder, tx);   // 先判环,再决定等不等
        if (cycle != null) {
            String text = String.join(" -> ", cycle) + " -> " + cycle.get(0);
            release(tx);                              // 真实数据库会回滚牺牲者
            return "DEADLOCK " + text;
        }
        waitingFor.put(tx, holder);
        long deadline = System.nanoTime() + 2_000_000_000L;
        while (owner.get(key) != null && !owner.get(key).equals(tx)) {
            long left = (deadline - System.nanoTime()) / 1_000_000L;
            if (left <= 0) {
                waitingFor.remove(tx);
                return "WAIT_TIMEOUT " + key;
            }
            try {
                MON.wait(left);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                waitingFor.remove(tx);
                return "WAIT_TIMEOUT " + key;
            }
        }
        waitingFor.remove(tx);
        owner.put(key, tx);
        held.computeIfAbsent(tx, k -> new HashSet<>()).add(key);
        return "OK " + key;
    }
}

private List<String> cyclePath(String holder, String tx) {
    List<String> path = new ArrayList<>();
    String cur = holder;
    Set<String> seen = new HashSet<>();
    while (cur != null && seen.add(cur)) {
        path.add(cur);
        if (cur.equals(tx)) return path;
        cur = waitingFor.get(cur);
    }
    return null;
}

两个会员各跑 5 轮,每轮同时发起、两个 SQL 顺序相反;再把 SQL 改成按 ISBN 排序跑同样 5 轮。本机真实输出(两个版本各一次独立运行,省略号处为重复行):

=== 未排序 SQL ===
[simdb] DEADLOCK victim=tx-26 cycle=tx-27 -> tx-26 -> tx-27
tx-M1 -> DEADLOCK tx-27 -> tx-26 -> tx-27
...
sortedSql=false rounds=5 commits=5 deadlocks=5 simdbAborts=5
=== 按 ISBN 排序 SQL ===
sortedSql=true rounds=5 commits=10 deadlocks=0 simdbAborts=0

未排序 5 轮全部死锁、每轮牺牲一个事务;排序后 10 个事务全部提交、0 次中止。对照 4.3 的本地锁实验,结论一致:顺序是唯一低成本的预防手段,区别只是这里的顺序要落到 SQL 语句上,而不是 ReentrantLock 上。

为什么 Java 锁顺序救不了?因为锁保护的键和事务修改的行不是同一个集合。会员锁保证“同一个会员不会并发改自己的记录”,但两个不同会员完全可能并发改同一批图书行——数据库的行锁冲突不在 Java 锁的覆盖范围内。跨实例时更彻底:两个 JVM 的 Java 锁根本不互斥,只有数据库行锁在起作用。

根因二:连接池是第三种资源

连接池不是“透明的资源管理器”,它是一个独立的互斥量。池大小决定了同时能有多少个事务在跑,也决定了 Java 锁被持有的最长时间:一个线程拿到 Java 锁之后如果还要排队等连接,锁的持有时间就包含了池排队时间。

三者的耦合可以写成一个近似:

持有 Java 锁的时间 ≈ 池排队 + 网络往返 + 数据库行锁等待 + 事务内计算
池排队 ≈ (并发事务数 / 池大小 - 1) × 单事务时长   (当并发事务数 > 池大小时)

这条关系解释了三个反直觉现象:

现象机制
池调小,接口变慢但死锁变少并发事务数被池限制,行锁碰撞窗口变小;代价是排队变长
池调大,吞吐没涨,死锁变多更多事务同时持有行锁,环的概率上升;瓶颈在数据库侧
池耗尽时健康检查失败健康检查也要连接,拿不到就超时;表现为“服务假死”
Java 锁 + 小池 = 硬死锁持锁线程等连接,持连接的线程等锁,见上一节实验

所以“线程池大小”和“连接池大小”必须分开算:线程池按等待/计算比(5.2)算,连接池按数据库能承受的并发事务数算,两者没有固定比例。把连接池设成和线程池一样大,是最常见的放大器。

排查步骤

第一步,抓线程转储,找“持锁等 IO”的线程。

jcmd <pid> Thread.print -l > dump-$(date +%s).txt
grep -n -B 3 -A 20 "SocketDispatcher.read0" dump-*.txt

判据:RUNNABLE + socketRead0 + 非空 Locked ownable synchronizers。记下这些线程等了多久、等的是哪把锁;再找 parking to wait for 同一地址的线程。

第二步,看连接池水位。 active 是否等于上限、waiting(或 pending)是否持续大于 0、获取连接的 p99 是否已经接近超时。连接池等待在转储里通常表现为 Semaphore.acquire 或池实现内部的 park,不在 BLOCKED 里。

第三步,到数据库侧确认行锁。 本机无数据库,未执行,下列 SQL 与输出格式未在本机验证,只作为排查方向:

-- PostgreSQL:谁在等谁(pg_locks + pg_stat_activity)
SELECT blocked.pid   AS blocked_pid,
       blocked.query AS blocked_query,
       blocking.pid  AS blocking_pid,
       blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks kl ON kl.locktype = bl.locktype
  AND kl.database IS NOT DISTINCT FROM bl.database
  AND kl.relation IS NOT DISTINCT FROM bl.relation
  AND kl.page     IS NOT DISTINCT FROM bl.page
  AND kl.tuple    IS NOT DISTINCT FROM bl.tuple
  AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid
  AND kl.granted
JOIN pg_stat_activity blocking ON blocking.pid = kl.pid
WHERE blocked.pid <> blocking.pid;

看四列:locktype(等待行锁时通常是 transactionid)、mode(是否 ExclusiveLock)、granted(是否为 false)、两个 query 里的表名与 WHERE 条件是否指向同一组主键。注意 PostgreSQL 的行锁本身不直接出现在 pg_locks 里(行锁记在元组头),等待方是对阻塞事务的 XID 加 transactionid 锁,所以在 pg_locks 里看到的是一条事务到事务的等待边,不是“第几行被锁住”——这一点和很多人的直觉不同。

MySQL 侧看 SHOW ENGINE INNODB STATUS\G 的 LATEST DETECTED DEADLOCK 段:TRANSACTION 块里的 HOLDS THE LOCK(S) 与 WAITING FOR THIS LOCK TO BE GRANTED 分别对应持有和等待,WE ROLL BACK TRANSACTION 标明牺牲者;MySQL 8 也可以用 performance_schema.data_locks 与 data_lock_waits。相关参数:innodb_lock_wait_timeout 默认 50 秒,innodb_deadlock_detect 默认开启;PostgreSQL 的 deadlock_timeout 默认 1 秒。以上参数值与输出格式均为文档口径,未在本机验证。

第四步,把三张图对齐。 Java 锁的持有者 → 它在等哪个连接 → 那个连接在等哪一行 → 那一行被哪个事务持有 → 那个事务的线程在等哪把 Java 锁。任意一条边落在 JVM 之外,JVM 都不会报死锁。

修复与验证

按代价从低到高:

  1. 事务内 SQL 按主键排序。 和 4.3 的锁顺序同构,只是排序键换成主键或唯一索引列。批量操作先去重再排序。
  2. 不要在事务里做 RPC、发消息、等缓存。 事务边界只包住数据库操作;外部调用移到事务提交之后(配合 outbox 或补偿)。
  3. 不要在持有 Java 锁时获取连接或做 IO。 正确顺序是“先拿连接,再在事务内做需要原子性的判断”,或者把 Java 锁缩到纯内存计算;持锁等 IO 是叠加死锁的第一大来源。
  4. 连接池按数据库容量定,不按线程池定。 用 active、waiting、获取等待 p99 三个指标定容;池满时快速失败而不是无限等待。
  5. 给跨资源操作加超时。 Java 锁用 tryLock(timeout)(4.3),连接获取用带超时的 borrow,SQL 用语句超时;三层都要有,且超时值从内到外递增。

验证方式与 4.3 相同,但要看两组证据:重跑上面的 SimDb 实验,未排序版本应稳定出现 deadlocks=N,排序版本应为 0;然后在 staging 用 64 并发压 10 分钟,每 100 ms 抓一份转储,检查“持锁等 IO”的线程数是否为 0。连接池侧看 waiting 是否长期为 0。

修复前:rounds=5 commits=5 deadlocks=5 simdbAborts=5
修复后:rounds=5 commits=10 deadlocks=0 simdbAborts=0

常见错误与边界

  • 以为 JVM 没报死锁就没有死锁。 跨资源环对 JVM 不可见,必须自己拼图。
  • 把 parking 当成“没在等锁”。 ReentrantLock、Semaphore、连接池都用 park;地址和持有者才是判据。
  • 按类名统计 ownable synchronizer 时不排除 JDK 内部锁。 NioSocketImpl 的读写锁会让统计虚高。
  • 连接池和线程池设成同大小。 两者约束不同,同大小只是让排队从池转移到数据库。
  • 在事务内重试死锁。 重试必须整事务重来,且要有次数上限和退避;只重试单条 SQL 会放大冲突。
  • 用“调小连接池”掩盖死锁。 它只是降低并发、拉长排队,冲突概率下降但环还在。

什么时候不要用数据库行锁做互斥: 临界区包含远程调用、文件写入或长时间计算时,行锁会把整个数据库拖进等待;改用幂等键 + 唯一索引,或状态机 CAS。什么时候不要用 Java 锁包住事务: 多实例部署时它完全不互斥,只会给单机内的线程制造假安全感;跨实例的互斥要么交给数据库(唯一索引、SELECT ... FOR UPDATE),要么用 11.2 的分布式租约。

小结

Java 锁顺序正确只是必要条件。数据库行锁、连接池许可都是 JVM 之外的资源,跨资源的环不会被 findDeadlockedThreads() 报出来:本机的连接池实验里它返回 null,而系统已经停摆。

两类根因各有判据:SQL 顺序反了,看事务里的主键顺序与数据库侧的等待对;Java 锁与连接池交叉,看转储里“持锁等 SocketRead”的线程和等同一地址的线程。本机的行锁替身实验给出干净对照:SQL 顺序相反 5 轮 5 次死锁,按 ISBN 排序后 10 个事务 0 次中止。

修复的优先级是缩短事务、不在锁内做 IO、按主键排序、池按数据库容量定容。下一节换到跨进程:分布式锁的租约为什么会在“锁还有效”的时候失效,以及 fencing token 怎么把这个问题变成可检测的写入失败。

阅读导航:上一节 10.3 线程泄漏与上下文类加载器 | 下一节 11.2 分布式锁的租约与续期

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收