-- 若第 9 课的演示表还在,先清理(它们带着指向 cf_user 的外键)
DROP TABLE IF EXISTS cf_comment, cf_feedback;
USE sql_practice;
TRUNCATE TABLE cf_submission;
TRUNCATE TABLE cf_practice_record;
TRUNCATE TABLE cf_user;
TRUNCATE TABLE cf_problem;
-- 执行第 1 课步骤 4 的四段 INSERT 恢复初始数据
CREATE TABLE IF NOT EXISTS cf_wallet (
user_id BIGINT UNSIGNED PRIMARY KEY,
points INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '积分余额',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
TRUNCATE TABLE cf_wallet;
INSERT INTO cf_wallet VALUES (1, 1000, DEFAULT), (2, 500, DEFAULT);
lin 有 1000 积分,zhao 有 500。下文所有预期都基于此。
步骤 1:认识 autocommit
SELECT @@autocommit; -- 1:每条语句独立成事务,执行完自动提交
UPDATE cf_wallet SET points = points + 10 WHERE user_id = 1;
-- 语句一结束,改动立刻持久,另一个会话马上可见
单条语句天然原子;多条语句想绑成一个单元,必须显式开事务。
步骤 2:终端 A 发起转账(先不提交)
打开第一个 mysql 客户端窗口(终端 A):
START TRANSACTION;
UPDATE cf_wallet SET points = points - 200 WHERE user_id = 1;
UPDATE cf_wallet SET points = points + 200 WHERE user_id = 2;
SELECT user_id, points FROM cf_wallet; -- 终端 A 自己看:800 / 700
步骤 3:终端 B 观察隔离性
打开第二个 mysql 客户端窗口(终端 B):
SELECT user_id, points FROM cf_wallet; -- 结果:1000 / 500
终端 A 明明已经改了,终端 B 读到的还是旧值——InnoDB 默认隔离级别 REPEATABLE READ 下的快照读,未提交数据对任何人不可见(杜绝脏读)。
步骤 4:两种结局
-- 终端 A 结局一:回滚
ROLLBACK;
-- 终端 B 再查:仍是 1000 / 500,两次 UPDATE 彻底消失
-- 终端 A 结局二:重新转账后提交
START TRANSACTION;
UPDATE cf_wallet SET points = points - 200 WHERE user_id = 1;
UPDATE cf_wallet SET points = points + 200 WHERE user_id = 2;
COMMIT;
-- 终端 B 再查:800 / 700,落定且持久(D)
步骤 5:SAVEPOINT——事务内的后悔药
START TRANSACTION;
UPDATE cf_wallet SET points = points - 50 WHERE user_id = 1; -- 扣手续费
SAVEPOINT after_fee;
UPDATE cf_wallet SET points = points + 50 WHERE user_id = 2; -- 这步写错了
ROLLBACK TO after_fee; -- 只撤销 SAVEPOINT 之后的语句
COMMIT; -- 手续费的扣减保留生效
步骤 6:复现不可重复读(RC vs RR)
终端 B(切到 READ COMMITTED):
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT @@transaction_isolation; -- READ-COMMITTED
START TRANSACTION;
SELECT points FROM cf_wallet WHERE user_id = 1; -- 第一次读:800
终端 A 修改并提交:
UPDATE cf_wallet SET points = points + 100 WHERE user_id = 1;
COMMIT;
终端 B 再读:
SELECT points FROM cf_wallet WHERE user_id = 1; -- RC 下:900(两次读不一致!)
COMMIT;
-- 把隔离级别改回默认 RR 重复一遍实验:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT points FROM cf_wallet WHERE user_id = 1; -- 900
-- 终端 A 再改 +100 并 COMMIT
SELECT points FROM cf_wallet WHERE user_id = 1; -- 仍是 900(可重复读)
COMMIT; -- 事务结束后再读才是 1000
RR 的事务在第一次快照读时「拍照」,之后整个事务都看这张照片;RC 每次快照读都重新拍照。
步骤 7:当前读——SELECT ... FOR UPDATE
-- 终端 A:锁定读取,打算马上扣减
START TRANSACTION;
SELECT points FROM cf_wallet WHERE user_id = 1 FOR UPDATE; -- 1000,行锁持有中
-- 终端 B:同一条 FOR UPDATE 会阻塞等待,直到 A 提交或回滚
-- SELECT points FROM cf_wallet WHERE user_id = 1 FOR UPDATE; ← 卡住
-- 终端 A:
UPDATE cf_wallet SET points = points - 100 WHERE user_id = 1;
COMMIT;
-- 终端 B 解除阻塞,读到 900
FOR UPDATE 是当前读(读最新已提交值并加行锁),与普通 SELECT 的快照读是两套机制——「查了再改」的竞态场景必须用它(或把判断放进一条 UPDATE ... WHERE points >= 100 里)。
步骤 8:账号与权限——最小权限原则
-- 只读报表角色与账号
CREATE ROLE IF NOT EXISTS 'r_report';
GRANT SELECT ON sql_practice.* TO 'r_report';
CREATE USER 'cf_ro'@'localhost' IDENTIFIED BY 'Cf_ro#2025pwd';
GRANT 'r_report' TO 'cf_ro'@'localhost';
SET DEFAULT ROLE 'r_report' TO 'cf_ro'@'localhost';
SHOW GRANTS FOR 'cf_ro'@'localhost';
-- 收权
REVOKE SELECT ON sql_practice.* FROM 'r_report';