锁住一行以后谁还能继续只记住“InnoDB支持行锁”还不足以解释一次阻塞锁着的时候能不能读能不能更新另一行屏幕不动究竟是在等锁还是SQL本来就慢这次不先罗列锁的分类。我只建两条记录用A持有锁、B尝试更新再用一个观察会话查看等待关系。目标是把“等待”的证据与最终数据变化对应起来。学习事务时“一组修改一起提交”回答的是提交边界这里追问的是提交之前其他操作能不能继续。后文只讨论这一段并发过程不把一次行锁实验扩写成ACID、所有隔离级别和MVCC的完整证明。本轮环境为 Ubuntu MySQL8.0.46-0ubuntu0.24.04.4独立临时数据目录服务只监听回环地址不是用户原有数据库。A显式使用REPEATABLE READ与事务表使用InnoDB查询是已存在主键的唯一等值查询。这几个条件决定本文能说明什么。1. 先建立一个不会误伤现有数据的小场景连接自己的临时实例在任意一个会话初始化。数据库名已存在时换一个新名字不要先DROP原库SELECT VERSION(), port, datadir; CREATE DATABASE blog_lock_lab; USE blog_lock_lab; CREATE TABLE account ( id INT PRIMARY KEY, balance INT NOT NULL ) ENGINEInnoDB; INSERT INTO account VALUES (1,100), (2,200);三个会话必须连接同一个实例并分别执行USE blog_lock_lab;。很多“为什么没有阻塞”的实验其实是在两个不同端口上操作同名表。2. A读出记录但先不提交在A执行只执行到SELECT不要把后面的COMMIT提前贴进去USE blog_lock_lab; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM account WHERE id1 FOR UPDATE;A返回1,100仍处于事务中。FOR UPDATE是锁定读不是普通SELECT这里获取的锁由事务持有SELECT返回并不意味着锁已经释放。MySQL锁定读说明本轮自动程序等到A打印出明确的完成标记以后才启动B不靠固定sleep猜测A是否已经取到锁。人工操作时也要确认SELECT已经返回。3. 同一行的UPDATE等待普通读和另一行却能继续先在观察会话C执行USE blog_lock_lab; SET autocommit1; SELECT balance FROM account WHERE id1; UPDATE account SET balancebalance1 WHERE id2; SELECT balance FROM account WHERE id2;本轮普通读立即返回100id2更新后得到201。然后在B执行USE blog_lock_lab; SET autocommit1; SET SESSION innodb_lock_wait_timeout60; UPDATE account SET balancebalance-10 WHERE id1;B的UPDATE暂时没有完成。此时不能只凭终端“卡住”判定它在等锁还需要去C查询B和C在自己的新建连接中显式开启自动提交A则保留显式事务。不要在有未提交业务的连接里照抄SET autocommit1它可能提交该连接已有的事务。SELECT r.OBJECT_NAME, r.INDEX_NAME, r.LOCK_TYPE, r.LOCK_MODE, r.LOCK_DATA FROM performance_schema.data_lock_waits AS w JOIN performance_schema.data_locks AS r ON r.ENGINE w.ENGINE AND r.ENGINE_LOCK_ID w.REQUESTING_ENGINE_LOCK_ID;本轮在B尚未退出时读到一条请求中的锁OBJECT_NAME INDEX_NAME LOCK_TYPE LOCK_MODE LOCK_DATA account PRIMARY RECORD X,REC_NOT_GAP 1这把等待定位到了account的PRIMARY索引记录1而不是只证明数据库里存在某个锁。data_lock_waits描述请求锁与阻塞锁的关系需要具体阻塞者时可以再沿BLOCKING_ENGINE_LOCK_ID联到另一条data_locks记录。等待表文档4. A提交以后B不是重新开始而是等待结束回到ACOMMIT;B的UPDATE随后完成。再在B查询并在C看等待表SELECT balance FROM account WHERE id1; SELECT COUNT(*) FROM performance_schema.data_lock_waits;在本轮独立实例里前者是90后者是0。单独看最终90不能证明发生过等待单独看data_lock_waits也不能证明更新最后成功。把两个时刻连起来才形成完整链条。阶段同一行更新B观察结果A锁定id1后尚未发出普通读得到100id2更新得到201B发出UPDATE后等待进程未完成请求锁为PRIMARY上的记录1A提交后完成id1变90等待关系消失等待表是瞬时信息如果A提交太快查询可能已经看不到它。没有拍到等待并不自动意味着从未等待本轮脚本是在检测到等待关系以后才允许A提交而不是事后采一张图猜测先前发生了什么。5. 这个实验不能推出什么普通SELECT没有被这次锁定读挡住不等于“所有SELECT都不需要等待”锁定读、元数据锁与其他阻塞场景需要分别观察。这里也没有验证RR的整个多版本读取过程只观察了一次独立普通读。id2可以更新不等于WHERE里写一个id就永远只锁一条记录。是否为唯一索引、记录是否存在、是否扫描范围、隔离级别和执行计划都会影响实际锁范围。本例的REC_NOT_GAP有明确的前提不能拿它代替gap lock与next-key lock的分析。本轮没有制造死锁也没有让锁等待超时因此不会把“两个会话有冲突”写成“产生了死锁”。等待、超时、循环依赖是不同现象。6. 验证程序是怎么控制顺序的程序启动自己的临时MySQL进程为A保留连接和事务读到A_LOCKED标记后分别检查普通读与另一行更新再启动B。随后轮询等待表确认B尚未完成和请求锁存在才向A发送COMMIT最后检查B输出90、等待表清零在finally结束连接和服务器。手工版使用A、B、C三个会话。自动版保留A与B的连接观察者角色通过多次独立短连接执行查询它不依赖C的事务快照跨查询保持一致。因此不能用这个程序声称已经验证“同一观察事务中的可重复读”。数据库文件、输出和日志都在D盘。WSL挂载的D盘不能建立UNIX socket因此仅把运行时socket放在内存文件系统/dev/shm结束后清理这不改变数据库数据的位置。环境限制和实验结论分开记录避免把启动环境的问题误写成锁机制的问题。复现程序为experiments/2026-10-03-candidates/verify-mysql.py原始结果为mysql-results.json。完整准备和验证程序见下方附录手工SQL已完整列出但只能在自己的临时实例执行不能为了学习阻塞业务数据库。附录完整复现文件下面两个文件放在同一个新建实验目录。适用Ubuntu24.04 amd64、Python3和本篇MySQL8.0样例先运行bash prepare-mysql.sh核对显示的版本再运行python3 verify-mysql.py。准备脚本只下载、解包官方软件包到当前目录不安装系统服务仓库包版本会变化下载到不同版本时不应冒充本篇8.0.46原环境。本轮使用已有官方软件包缓存只复测解包后二进制和文章中的验证程序未重新证明未来在线下载可用。WSL中无需修改家中网络或已有MySQL脚本使用新数据目录和随机回环端口。初始化是不带密码的学习实例不能开放公网也不能用于生产。临时数据与日志保留服务、连接与运行时socket在finally结束。prepare-mysql.sh#!/usr/bin/env bash set -eu cd $(dirname $0) mkdir -p mysql-packages cd mysql-packages mkdir -p apt-lists/partial apt-cache/archives/partial if [ ! -f apt-lists/.ready ]; then apt-get -o Dir::State::Lists$PWD/apt-lists -o Dir::Cache$PWD/apt-cache update touch apt-lists/.ready fi apt-get -o Dir::State::Lists$PWD/apt-lists download mysql-server-core-8.0 mysql-client-core-8.0 libaio1t64 libmecab2 libnuma1 libevent-core-2.1-7t64 libevent-pthreads-2.1-7t64 apt-get -o Dir::State::Lists$PWD/apt-lists download libprotobuf-lite32t64 for f in *.deb; do dpkg-deb -x $f root; done export LD_LIBRARY_PATH$PWD/root/usr/lib/x86_64-linux-gnu ./root/usr/sbin/mysqld --no-defaults --versionverify-mysql.pyimport getpass import json import os from pathlib import Path import queue import socket import subprocess import threading import time base Path(__file__).resolve().parent root base / mysql-packages/root env dict(os.environ, LD_LIBRARY_PATHstr(root / usr/lib/x86_64-linux-gnu)) mysqld str(root / usr/sbin/mysqld) mysql str(root / usr/bin/mysql) run base / (mysql-run- str(time.time_ns())) run.mkdir() (run / data).mkdir() with socket.socket() as probe: probe.bind((127.0.0.1, 0)) port probe.getsockname()[1] record {run: str(run), port: port, commands: []} socket_path Path(/dev/shm) / (run.name .sock) args [mysqld, --no-defaults, --basedir str(root / usr), --datadir str(run / data), --lc-messages-dir str(root / usr/share/mysql), --user getpass.getuser(), --secure-file-privNULL, --log-error str(run / server.log)] init subprocess.run(args [--initialize-insecure], envenv, capture_outputTrue, textTrue, timeout90) assert init.returncode 0, init.stderr client_args [mysql, --no-defaults, --protocolTCP, -h, 127.0.0.1, -P, str(port), -u, root, --batch, --skip-column-names, --unbuffered, --connect-timeout2] def sql(statement): r subprocess.run(client_args [-e, statement], envenv, capture_outputTrue, textTrue, timeout15) assert r.returncode 0, r.stderr record[commands].append({sql: statement, output: r.stdout.strip()}) return r.stdout.strip() child subprocess.Popen(args [--bind-address127.0.0.1, --port str(port), --socket str(socket_path), --mysqlx0, --pid-file str(run / mysql.pid), --tmpdir str(run)], envenv, stdoutsubprocess.DEVNULL, stderrsubprocess.DEVNULL) a b None try: for _ in range(150): assert child.poll() is None, MySQL failed to start; inspect isolated server.log try: record[version] sql(SELECT VERSION();) break except AssertionError: time.sleep(0.1) else: raise RuntimeError(MySQL readiness timeout) sql(CREATE DATABASE blog_lock_lab; CREATE TABLE blog_lock_lab.account(id INT PRIMARY KEY,balance INT NOT NULL) ENGINEInnoDB; INSERT INTO blog_lock_lab.account VALUES(1,100),(2,200);) a subprocess.Popen(client_args, envenv, stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue, bufsize1) events queue.Queue() def collect(): for line in a.stdout: events.put(line.strip()) reader threading.Thread(targetcollect, daemonTrue) reader.start() a.stdin.write(SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM blog_lock_lab.account WHERE id1 FOR UPDATE; SELECT A_LOCKED;\n) a.stdin.flush() rows [] while True: line events.get(timeout5) rows.append(line) if line A_LOCKED: break record[aRows] rows record[ordinaryReadDuringLock] sql(SELECT balance FROM blog_lock_lab.account WHERE id1;) assert record[ordinaryReadDuringLock] 100 record[otherRowUpdate] sql(UPDATE blog_lock_lab.account SET balancebalance1 WHERE id2; SELECT balance FROM blog_lock_lab.account WHERE id2;) assert record[otherRowUpdate] 201 b subprocess.Popen(client_args [-e, SET SESSION innodb_lock_wait_timeout10; UPDATE blog_lock_lab.account SET balancebalance-10 WHERE id1; SELECT balance FROM blog_lock_lab.account WHERE id1;], envenv, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue) started time.monotonic() for _ in range(50): waiting sql(SELECT COUNT(*) FROM performance_schema.data_lock_waits;) if waiting 1: break assert b.poll() is None, B finished without waiting time.sleep(0.05) else: raise RuntimeError(No lock wait observed) record[lockWaits] sql(SELECT r.OBJECT_NAME,r.INDEX_NAME,r.LOCK_TYPE,r.LOCK_MODE,r.LOCK_DATA FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks r ON r.ENGINEw.ENGINE AND r.ENGINE_LOCK_IDw.REQUESTING_ENGINE_LOCK_ID;) assert account\tPRIMARY\tRECORD\tX,REC_NOT_GAP\t1 in record[lockWaits], record[lockWaits] assert b.poll() is None a.stdin.write(COMMIT; SELECT A_COMMITTED;\n) a.stdin.flush() stdout, stderr b.communicate(timeout5) assert b.returncode 0 and stdout.strip() 90, (stdout, stderr) record[bOutputAfterCommit] stdout.strip() record[observedWaitMs] round((time.monotonic() - started) * 1000) record[waitsAfterCommit] sql(SELECT COUNT(*) FROM performance_schema.data_lock_waits;) assert record[waitsAfterCommit] 0 record[status] passed finally: for client in (a, b): if client and client.poll() is None: client.terminate() client.wait(timeout5) if child.poll() is None: child.terminate() child.wait(timeout15) socket_path.unlink(missing_okTrue) Path(str(socket_path) .lock).unlink(missing_okTrue) record[serverStopped] child.poll() is not None (base / mysql-results.json).write_text(json.dumps(record, indent2), encodingutf-8) print(json.dumps(record))