欢迎光临
我们一直在努力

锁住一行以后,谁还能继续?用三个会话观察InnoDB锁等待

锁住一行以后,谁还能继续?

只记住“InnoDB支持行锁”还不足以解释一次阻塞:锁着的时候能不能读?能不能更新另一行?屏幕不动究竟是在等锁,还是SQL本来就慢?

这次不先罗列锁的分类。我只建两条记录,用A持有锁、B尝试更新,再用一个观察会话查看等待关系。目标是把“等待”的证据与最终数据变化对应起来。

学习事务时,“一组修改一起提交”回答的是提交边界;这里追问的是提交之前其他操作能不能继续。后文只讨论这一段并发过程,不把一次行锁实验扩写成ACID、所有隔离级别和MVCC的完整证明。

本轮环境为 Ubuntu MySQL 8.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
) ENGINE=InnoDB;
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 id=1 FOR UPDATE;

A返回 1,100,仍处于事务中。FOR UPDATE 是锁定读,不是普通SELECT;这里获取的锁由事务持有,SELECT返回并不意味着锁已经释放。MySQL锁定读说明

本轮自动程序等到A打印出明确的完成标记以后才启动B,不靠固定sleep猜测A是否已经取到锁。人工操作时也要确认SELECT已经返回。

3. 同一行的UPDATE等待,普通读和另一行却能继续

先在观察会话C执行:

USE blog_lock_lab;
SET autocommit=1;
SELECT balance FROM account WHERE id=1;
UPDATE account SET balance=balance+1 WHERE id=2;
SELECT balance FROM account WHERE id=2;

本轮普通读立即返回100,id=2更新后得到201。然后在B执行:

USE blog_lock_lab;
SET autocommit=1;
SET SESSION innodb_lock_wait_timeout=60;
UPDATE account SET balance=balance-10 WHERE id=1;

B的UPDATE暂时没有完成。此时不能只凭终端“卡住”判定它在等锁,还需要去C查询:

B和C在自己的新建连接中显式开启自动提交,A则保留显式事务。不要在有未提交业务的连接里照抄 SET autocommit=1,它可能提交该连接已有的事务。

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记录。等待表文档

A持有id=1记录锁,B等待更新;普通读id=1和更新id=2仍可进行,提交后B继续

4. A提交以后,B不是重新开始,而是等待结束

回到A:

COMMIT;

B的UPDATE随后完成。再在B查询,并在C看等待表:

SELECT balance FROM account WHERE id=1;
SELECT COUNT(*) FROM performance_schema.data_lock_waits;

在本轮独立实例里,前者是90,后者是0。单独看最终90不能证明发生过等待;单独看data_lock_waits也不能证明更新最后成功。把两个时刻连起来,才形成完整链条。

阶段同一行更新B观察结果
A锁定id=1后 尚未发出 普通读得到100,id=2更新得到201
B发出UPDATE后 等待,进程未完成 请求锁为PRIMARY上的记录1
A提交后 完成 id=1变90,等待关系消失

等待表是瞬时信息:如果A提交太快,查询可能已经看不到它。没有拍到等待,并不自动意味着从未等待;本轮脚本是在检测到等待关系以后才允许A提交,而不是事后采一张图猜测先前发生了什么。

5. 这个实验不能推出什么

普通SELECT没有被这次锁定读挡住,不等于“所有SELECT都不需要等待”;锁定读、元数据锁与其他阻塞场景需要分别观察。这里也没有验证RR的整个多版本读取过程,只观察了一次独立普通读。

id=2可以更新,不等于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 –version

verify-mysql.py

import 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_PATH=str(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-priv=NULL", "–log-error=" + str(run / "server.log")]
init = subprocess.run(args + ["–initialize-insecure"], env=env, capture_output=True, text=True, timeout=90)
assert init.returncode == 0, init.stderr
client_args = [mysql, "–no-defaults", "–protocol=TCP", "-h", "127.0.0.1", "-P", str(port),
"-u", "root", "–batch", "–skip-column-names", "–unbuffered", "–connect-timeout=2"]

def sql(statement):
r = subprocess.run(client_args + ["-e", statement], env=env, capture_output=True, text=True, timeout=15)
assert r.returncode == 0, r.stderr
record["commands"].append({"sql": statement, "output": r.stdout.strip()})
return r.stdout.strip()

child = subprocess.Popen(args + ["–bind-address=127.0.0.1", "–port=" + str(port),
"–socket=" + str(socket_path), "–mysqlx=0",
"–pid-file=" + str(run / "mysql.pid"), "–tmpdir=" + str(run)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.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) ENGINE=InnoDB; INSERT INTO blog_lock_lab.account VALUES(1,100),(2,200);")
a = subprocess.Popen(client_args, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1)
events = queue.Queue()
def collect():
for line in a.stdout:
events.put(line.strip())
reader = threading.Thread(target=collect, daemon=True)
reader.start()
a.stdin.write("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM blog_lock_lab.account WHERE id=1 FOR UPDATE; SELECT 'A_LOCKED';\\n")
a.stdin.flush()
rows = []
while True:
line = events.get(timeout=5)
rows.append(line)
if line == "A_LOCKED":
break
record["aRows"] = rows
record["ordinaryReadDuringLock"] = sql("SELECT balance FROM blog_lock_lab.account WHERE id=1;")
assert record["ordinaryReadDuringLock"] == "100"
record["otherRowUpdate"] = sql("UPDATE blog_lock_lab.account SET balance=balance+1 WHERE id=2; SELECT balance FROM blog_lock_lab.account WHERE id=2;")
assert record["otherRowUpdate"] == "201"
b = subprocess.Popen(client_args + ["-e", "SET SESSION innodb_lock_wait_timeout=10; UPDATE blog_lock_lab.account SET balance=balance-10 WHERE id=1; SELECT balance FROM blog_lock_lab.account WHERE id=1;"],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
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.ENGINE=w.ENGINE AND r.ENGINE_LOCK_ID=w.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(timeout=5)
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(timeout=5)
if child.poll() is None:
child.terminate()
child.wait(timeout=15)
socket_path.unlink(missing_ok=True)
Path(str(socket_path) + '.lock').unlink(missing_ok=True)
record["serverStopped"] = child.poll() is not None
(base / "mysql-results.json").write_text(json.dumps(record, indent=2), encoding="utf-8")
print(json.dumps(record))

赞(0)
未经允许不得转载:171主机测评 » 锁住一行以后,谁还能继续?用三个会话观察InnoDB锁等待
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址