MySQL 事务与隔离级别深度解析

📌 本文由 24 篇相关文章智能合并整理而成

容器与虚拟机的隔离级别深度对比

容器与虚拟机的隔离级别深度对比

隔离的本质不同

容器和虚拟机在隔离级别上有本质区别,这决定了它们各自的安全边界和使用场景。

graph LR
    subgraph 隔离级别光谱
        L0[弱隔离] --> L1[进程级]
        L1 --> L2[命名空间级]
        L2 --> L3[内核级]
        L3 --> L4[硬件级]
        L4 --> L5[强隔离]
    end

    subgraph 实际技术
        Containers[Docker 容器<br/>进程 + Namespace 隔离]
        Kata[Kata Containers<br/>轻量级 VM 隔离]
        VM[传统虚拟机<br/>硬件虚拟化隔离]
    end

    L1 --> Containers
    L3 --> Kata
    L4 --> VM

隔离能力对比矩阵

隔离维度 Docker 容器 虚拟机
文件系统 Namespace 隔离(可突破) 完全隔离
网络栈 Namespace 隔离 完全隔离
进程树 PID Namespace 完全隔离
IPC IPC Namespace 完全隔离
用户/组 User Namespace 完全隔离
操作系统内核 共享宿主机内核 ✅ 独立内核
硬件资源 cgroups 限制 硬件虚拟化
时间戳 Namespace 隔离(可选) 完全隔离
性能损耗 ~2-5% ~10-20%
安全边界 薄(内核攻击面大) 厚(Hypervisor 隔离)

详细隔离机制

容器隔离:Linux Namespace

# 容器使用 8 种 Namespace 实现隔离
# 查看容器的 Namespace
ls -la /proc/1/ns/
# 输出示例:
# lrwxrwxrwx 1 root root 0 ... cgroup -> cgroup:[4026531835]
# lrwxrwxrwx 1 root root 0 ... ipc    -> ipc:[4026531839]
# lrwxrwxrwx 1 root root 0 ... mnt    -> mnt:[4026531841]
# lrwxrwxrwx 1 root root 0 ... net    -> net:[4026531992]
# lrwxrwxrwx 1 root root 0 ... pid    -> pid:[4026531836]
# lrwxrwxrwx 1 root root 0 ... user   -> user:[4026531837]
# lrwxrwxrwx 1 root root 0 ... uts    -> uts:[4026531838]
# lrwxrwxrwx 1 root root 0 ... time   -> time:[4026531834]
Namespace 隔离内容 作用
PID 进程编号 容器内只能看到自己的进程
Network 网络设备、IP、端口 容器有独立的网络栈
Mount 挂载点 容器有独立的文件系统视图
UTS 主机名和域名 容器可独立设置 hostname
IPC 进程间通信资源 隔离信号量、消息队列
User 用户和组 ID 容器内 root ≠ 宿主机 root
Cgroup cgroups 视图 容器查看自己的资源限制
Time 系统时间(Linux 5.6+) 容器可有独立时间偏移

容器的”残缺陷阱”:共享内核

graph TB
    subgraph 安全风险
        Exploit[内核漏洞 CVE] -->|容器共享内核| AllContainers[所有容器受影响]
        Exploit -->|独立内核| VMOnly[仅该虚拟机受影响]
    end

    subgraph 实际案例
        CVE1[Dirty Pipe CVE-2022-0847<br/>Linux 内核提权漏洞]
        CVE2[Container Escape<br/>容器逃逸漏洞]
    end

    CVE1 -.->|影响所有| AllContainers
    CVE2 -.->|利用共享内核| AllContainers

如果一个容器中的恶意代码利用了 Linux 内核漏洞(如 Dirty Pipe),所有运行在同一宿主机上的容器都可能受影响

虚拟机隔离:硬件虚拟化

# 虚拟机通过硬件辅助虚拟化实现隔离
# CPU 的 VMX (Intel) / SVM (AMD) 指令集
# 每个 VM 运行独立的 Guest OS 内核

# VM 的进程隔离表现
vm1: PID 1 (systemd)  init  sshd
vm2: PID 1 (systemd)  init  sshd
# 它们的 PID 1 是完全不同的操作系统实例

隔离级别的现实影响

安全性

# 容器默认安全性较低
# 一个典型的容器逃逸攻击步骤
# Step 1: 获取容器控制权(通过应用漏洞)
# Step 2: 利用共享内核漏洞突破 Namespace
# Step 3: 访问宿主机和其他容器的数据

# 加固容器安全的方法:
# 1. 启用 User Namespace 重映射
# 2. 使用只读根文件系统
# 3. 使用 seccomp 限制系统调用
# 4. 禁止特权模式 --privileged
docker run --security-opt=no-new-privileges \
           --security-opt=seccomp=default.json \
           --read-only \
           myapp

性能

指标 容器 虚拟机
CPU 接近原生(~2-3%损耗) 5-15%损耗
内存 只消耗应用内存 需要 Guest OS 内存
磁盘 IO 接近原生 经过虚拟化层
网络 bridge/nat 有轻微损耗 虚拟网卡有一定损耗

混合场景最佳实践

graph TB
    subgraph 企业生产环境最佳隔离方案
        HW[物理服务器]
        VM1[虚拟机1<br/>强隔离边界]
        VM2[虚拟机2<br/>强隔离边界]

        subgraph VM1 内部
            C1[Docker 容器1]
            C2[Docker 容器2]
        end

        subgraph VM2 内部
            C3[Docker 容器3]
            C4[Docker 容器4]
        end
    end

    HW --> VM1 & VM2
    VM1 --> C1 & C2
    VM2 --> C3 & C4

最佳实践:在虚拟机内运行容器,兼顾安全性和灵活性。

总结

容器的隔离是”进程级 + Namespace”的软隔离,而虚拟机是”硬件级”的硬隔离。理解这个区别能帮你:
1. 正确评估安全风险(容器逃逸是真实存在的)
2. 合理选择部署方案(核心业务可考虑 VM + Container 嵌套)
3. 在面试中展现对 Docker 原理的深度理解


分布式事务:XA 与 TCC 方案详解

分布式事务:XA 与 TCC 方案详解

分布式事务的挑战

分库分表后,一个业务操作可能涉及多个数据库实例。传统数据库的本地事务无法跨库保证 ACID,这就引入了分布式事务的需求。

转账场景:用户 A 扣钱(user_db_0)→ 用户 B 加钱(user_db_1)
要么都成功,要么都回滚 → 需要分布式事务

方案一:XA 协议(两阶段提交)

工作原理

XA 是 X/Open 组织定义的分布式事务规范,MySQL InnoDB 原生支持。分为两个阶段:

阶段 1:准备(Prepare)

事务管理器(TM)通知所有资源管理器(RM)准备提交,各 RM 执行事务但不提交:

-- TM → RM1:准备好了吗?
XA START 'xid1';
UPDATE account SET balance = balance - 100 WHERE id = 1;
XA END 'xid1';
XA PREPARE 'xid1';  -- Prepare 完成,等待决策

-- TM → RM2:准备好了吗?
XA START 'xid1';
UPDATE account SET balance = balance + 100 WHERE id = 2;
XA END 'xid1';
XA PREPARE 'xid1';  -- Prepare 完成,等待决策

阶段 2:提交(Commit)或回滚(Rollback)

如果所有 RM 都 Prepare 成功,TM 通知所有 RM 提交:

XA COMMIT 'xid1';  -- RM1 提交
XA COMMIT 'xid1';  -- RM2 提交

如果有任何一个 Prepare 失败,TM 通知所有 RM 回滚:

XA ROLLBACK 'xid1';  -- RM1 回滚
XA ROLLBACK 'xid1';  -- RM2 回滚

问题:Mysql XA 的缺陷

  1. 性能差:Prepare 阶段需要持有锁直到 Commit,锁持有时间显著增加
  2. 阻塞:TM 宕机后,Prepare 完成但未 Commit 的事务会阻塞资源(直到 TM 恢复)
  3. 不支持在高可用场景:Prepare 阶段完成后如果 TM 宕机,恢复逻辑复杂

适用场景

对一致性要求极高的场景,且能接受性能损失(如金融核心交易)。在互联网业务中较少使用。

方案二:TCC(Try-Confirm-Cancel)

工作原理

TCC 是一种补偿型分布式事务方案,由业务层实现,不依赖数据库底层支持。分为三个阶段:

Try(预留资源)

尝试执行业务,锁定需要的资源但不实际提交:

// 账户服务
void tryDebit(Account a, BigDecimal amount) {
    // 检查余额是否足够
    if (a.balance >= amount) {
        // 冻结资金:冻结余额增加,实际余额不变
        a.frozenBalance += amount;
        // 状态:TRYING
    }
}

// 库存服务
void tryReserve(Stock s, int count) {
    if (s.available >= count) {
        // 冻结库存
        s.frozenCount += count;
        s.available -= count; // 先在可用库存中扣减
    }
}

Confirm(确认执行)

如果所有 Try 都成功,执行 Confirm:

void confirmDebit(Account a, BigDecimal amount) {
    // Try 中已经冻结,Confirm 真正扣减
    a.balance -= amount;
    a.frozenBalance -= amount;
    // 状态:CONFIRMED
}

void confirmReserve(Stock s, int count) {
    // Try 已经冻结,Confirm 确认扣减
    s.frozenCount -= count;
    // 库存已在 Try 中扣减,Confirm 只清理冻结
    // 状态:CONFIRMED
}

Cancel(取消回滚)

如果有任何一个 Try 失败,执行 Cancel,释放预留的资源:

void cancelDebit(Account a, BigDecimal amount) {
    // 释放冻结余额
    a.frozenBalance -= amount;
    // 余额不变
    // 状态:CANCELLED
}

void cancelReserve(Stock s, int count) {
    // 释放冻结库存
    s.available += count;
    s.frozenCount -= count;
}

TCC 的优势

  1. 性能好:没有全局锁,资源只在 Try 阶段短暂锁定
  2. 灵活:由业务层控制治理逻辑
  3. 不依赖数据库:不要求数据库支持 XA

TCC 的挑战

  1. 业务侵入性强:需要为每个操作实现 Try/Confirm/Cancel 三个接口
  2. 幂等性保障:Confirm 和 Cancel 可能被重复执行,需要幂等设计
  3. 空回滚:Try 失败但 Cancel 仍然被调用的情况
  4. 防悬挂:Cancel 在 Try 之前到达(超时等)的处理

XA 与 TCC 对比

维度 XA 事务 TCC
一致性级别 强一致 最终一致
性能 差(锁时间长) 较好
代码侵入 低(声明式) 高(三个方法)
数据库依赖 需要支持 XA 无特殊要求
适用场景 金融核心交易 互联网业务
回滚方式 自动回滚 补偿回滚
隔离性 READ COMMITTED 由业务保证

其他方案简介

Saga 模式

将长事务拆分为多个本地事务,每个本地事务提交后发布事件触发下一个。出现异常时通过补偿事务回滚。适用于业务流程明确的场景。

事务消息(RocketMQ)

利用 RocketMQ 的事务消息功能,将本地事务和消息发送放在一个事务中,下游消费者通过消息触发后续操作。

SEATA AT 模式

阿里开源的 SEATA 框架提供了自动化的事务模式,通过代理 SQL 自动生成回滚 SQL,对业务代码侵入性低于 TCC。

面试要点

  • XA 和 TCC 是两种完全不同的思路:XA 靠数据库底层协调,TCC 靠业务层补偿
  • 大多数互联网场景选择 TCC 或 SEATA AT 而非 XA
  • TCC 的关键难点是幂等性、空回滚和防悬挂
  • 分布式事务没有完美方案,都是在一致性和性能之间做 trade-off
  • 很多场景下”最终一致”是可以接受的,不需要强一致

GTID:全局事务标识符的作用与优势

GTID:全局事务标识符的作用与优势

概述

GTID(Global Transaction Identifier,全局事务标识符)是 MySQL 5.6.5 引入的特性,为每个提交的事务分配一个全局唯一的标识符。GTID 从根本上简化了复制配置、故障切换和恢复过程。

GTID 的格式

GTID = source_id:transaction_id
部分 格式 示例
source_id UUID(服务器唯一标识) 3e11fa47-61ca-11e8-9e70-00163e114761
transaction_id 序列号(单调递增) 1, 2, 3, …

完整的 GTID 示例:

3e11fa47-61ca-11e8-9e70-00163e114761:1
3e11fa47-61ca-11e8-9e70-00163e114761:2
3e11fa47-61ca-11e8-9e70-00163e114761:3

多个 GTID 的组合:

# 连续的 GTID 集
3e11fa47-61ca-11e8-9e70-00163e114761:1-100

# 不同源服务器的 GTID
3e11fa47-61ca-11e8-9e70-00163e114761:1-50,
a0f1b2c3-41ca-11e8-9e70-00163e114761:1-30

GTID 如何工作

事务执行过程

-- 主库执行事务
BEGIN;
UPDATE users SET balance = 100 WHERE id = 1;
COMMIT;

-- 该事务被分配 GTID
-- GTID = 3e11fa47-61ca-11e8-9e70-00163e114761:42

-- Binlog 中的记录
# GTID next transaction = 3e11fa47-61ca-11e8-9e70-00163e114761:42
# at 123456
UPDATE `test`.`users` SET balance = 100 WHERE id = 1

从库回放过程

从库收到 GTID 后:

  1. 检查该 GTID 是否已执行过
  2. 如果已执行 → 跳过(避免重复执行)
  3. 如果未执行 → 正常回放
-- 从库状态
SHOW VARIABLES LIKE 'gtid_executed';
-- +---------------+-----------------------------------------+
-- | Variable_name | Value                                   |
-- +---------------+-----------------------------------------+
-- | gtid_executed | 3e11fa47-61ca-11e8-9e70-00163e114761:1-100 |
-- +---------------+-----------------------------------------+

-- 当收到 GTID:101 时:
-- 101 在已执行集合中吗?否 → 执行 ✅
-- 如果又收到 GTID:50:50 在集合中 → 跳过 ✅

GTID 的优势

1. 无需指定 Binlog 位置

传统复制需要手动指定文件名和位置:

-- 传统复制(位置模式)
CHANGE MASTER TO
  MASTER_HOST='192.168.1.10',
  MASTER_LOG_FILE='mysql-bin.000045',
  MASTER_LOG_POS=123456;
-- 切换时必须去主库查 SHOW MASTER STATUS

GTID 复制自动追踪:

-- GTID 复制:只需指定主机
CHANGE MASTER TO
  MASTER_HOST='192.168.1.10',
  MASTER_USER='repl',
  MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION = 1;  -- 自动定位

2. 故障切换更简单

传统复制切换流程:

从库 → 提升为主库 → 查新主库的 Binlog 位置 → 通知其他从库 → 一个个设置

GTID 复制切换流程:

从库 → 提升为主库 → 其他从库自动连接(MASTER_AUTO_POSITION=1)

所有从库通过 MASTER_AUTO_POSITION=1 自动找到需要应用的事务。

3. 避免数据重复或丢失

GTID 保证每个事务在整个复制拓扑中只执行一次

-- 无论发生多少次故障切换
-- 事务 3e11fa47-61ca-11e8-9e70-00163e114761:42
-- 在整个集群中只会被应用一次

-- 即使某个从库收到两次相同的 GTID
-- 第二次也会被自动跳过(幂等性)

4. 更易排查问题

-- 查看已执行的 GTID
SHOW MASTER STATUS;
-- +----------+----------+-------------------------------------------+
-- | File     | Position | Executed_Gtid_Set                        |
-- +----------+----------+-------------------------------------------+
-- | bin.001  | 123456   | 3e11fa47:1-500                           |
-- +----------+----------+-------------------------------------------+

-- 从库 GTID
SHOW SLAVE STATUS\G
-- Retrieved_Gtid_Set: 3e11fa47:1-500    ← 已拉取
-- Executed_Gtid_Set:  3e11fa47:1-500    ← 已执行
-- 如果两个值不一致,说明有事务还未执行

配置 GTID

[mysqld]
# 开启 GTID 模式
gtid_mode = ON

# 强制 GTID 一致性(避免非 GTID 安全的操作)
enforce_gtid_consistency = ON

# 记录 GTID 到表(非必须,但推荐)
gtid_executed_compression_period = 1000
-- 从库使用 GTID 复制
CHANGE MASTER TO
  MASTER_HOST='192.168.1.10',
  MASTER_PORT=3306,
  MASTER_USER='repl',
  MASTER_PASSWORD='password',
  MASTER_AUTO_POSITION = 1;  -- 使用 GTID 自动定位

START SLAVE;

GTID 的限制

限制 说明
CREATE TABLE … SELECT 不允许(非 GTID 安全)
临时表 在事务中创建临时表受限
跨表跨库的非事务操作 需要特别处理
旧版工具兼容 旧版 mydumper/myloader 可能不支持

解决办法:MySQL 8.0 中大部分限制已解除。

面试要点

  1. GTID = 全局唯一事务 ID,格式为 UUID:序列号
  2. 核心优势:主从复制和故障切换不再需要手动指定 Binlog 文件名和位置
  3. MASTER_AUTO_POSITION=1:GTID 自动定位复制
  4. 事务幂等性:每个事务在集群中只执行一次
  5. 简化运维:切换时自动找到同步起点,大大降低人工操作风险
  6. MySQL 5.6 引入,5.7 成熟,8.0 完善——建议生产环境全量启用 GTID
  7. 注意 CREATE TABLE ... SELECT 限制(MySQL 8.0 已修复)

Undo Log 如何同时支持事务回滚与 MVCC

Undo Log 如何同时支持事务回滚与 MVCC

概述

Undo Log 是 InnoDB 中设计最巧妙的机制之一——它同时服务于两个看似不同的功能:事务回滚和 MVCC(多版本并发控制)。这篇深入讲解 Undo Log 如何一箭双雕。

两种场景的需求

场景 A:事务回滚

BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;
-- ... 发生错误 ...
ROLLBACK;
-- 需要:把 id=1 的 balance 恢复为旧值

需求:记录修改前的数据,用于撤销操作。

场景 B:MVCC 快照读

-- 事务 A(RR 隔离级别)
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 读到 balance=100
                                    -- 此时事务 B 修改了这行

-- 事务 B
BEGIN;
UPDATE users SET balance = 200 WHERE id = 1;
COMMIT;

-- 事务 A 再次读取
SELECT * FROM users WHERE id = 1;  -- 仍然读到 balance=100!

需求:事务 A 需要看到数据”过去的样子”,即使已经被其他事务修改了。

Undo Log 如何同时满足

版本链(Version Chain)

每行数据上有一个隐藏列 DB_ROLL_PTR(回滚指针),指向 Undo Log 中该行的上一个版本:

当前数据行(最新版)
    id=1, balance=200
    DB_ROLL_PTR → ──────┐
                         ↓
    Undo Log 版本 2
    balance: 100(旧值)
    DB_ROLL_PTR → ──────┐
                         ↓
    Undo Log 版本 1
    balance: 50(最初值)
    DB_ROLL_PTR → NULL(终极版本)

回滚:沿着版本链找到”自己之前的版本”

BEGIN;
UPDATE users SET balance = 200 WHERE id = 1;
-- 创建 Undo Log 版本: balance=100

-- 如果回滚 ROLLBACK
-- InnoDB 通过 DB_ROLL_PTR 找到自己创建的 Undo Log
-- 将数据还原为 balance=100

回滚时只看当前事务自己的 Undo Log

数据行 balance=200      ← 当前事务修改的
    │
    └── 指向 balance=100  ← 当前事务创建的这个 Undo Log
        (回滚:把 balance 改回 100,删除这个 Undo Log 节点)

MVCC:沿着版本链找到”对当前事务可见的版本”

MVCC 通过 ReadView(读视图)来判断哪个版本可见:

ReadView 创建时间
    │
    ├── 活跃事务列表:[T10, T12]
    │
    └── 最小活跃事务ID: T10,最大事务ID: T13

数据行版本链:
balance=200 (由 T12 修改) → T12 在活跃列表中 → 不可见 ❌
    ↓
balance=100 (由 T9 修改)  → T9 已提交 → 可见 ✅
    ↓
balance=50  (由 T5 修改)  → 初始值

MVCC 的判断逻辑:

读取当前行的 DB_TRX_ID(修改该行的事务 ID)
    │
    ├── 等于当前事务 ID → 可见(自己的修改)
    │
    ├── 小于 ReadView 的 min_trx_id → 可见(已提交的旧事务)
    │
    ├── 在活跃事务列表中 → 不可见,找 Undo Log 的上一个版本
    │
    └── 大于 ReadView 的 max_trx_id → 不可见,找 Undo Log 的上一个版本

两种功能的区别

维度 事务回滚 MVCC
触发时机 ROLLBACK 时 SELECT 或 START TRANSACTION 时
查找范围 只看当前事务的 Undo Log 遍历整个版本链
找到后操作 覆盖当前数据 返回历史版本(只读)
不删除的原因 不需要了 有查询还在用该版本
依赖的隐藏列 DB_ROLL_PTR DB_ROLL_PTR + DB_TRX_ID

长事务的影响

长事务对两种功能都有影响:

对 MVCC 的影响

-- 事务 A 开启后一直不提交
BEGIN;
SELECT * FROM users;  -- 创建 ReadView

-- 其他事务大量修改数据
-- 生成大量 Undo Log 版本链

-- 事务 A 一直不提交也一直不 ROLLBACK
-- 导致 Undo Log 不能清理
-- 版本链越来越长,查询越来越慢

对回滚的影响

  • 长事务的回滚需要重放大量 Undo Log,耗时很长
  • 回滚期间会持有很多锁,影响并发

面试要点

  1. Undo Log 一箭双雕:同一份数据,既服务回滚又服务 MVCC
  2. 版本链通过隐藏列 DB_ROLL_PTR 串联
  3. MVCC 通过 ReadView + 版本链决定可见性
  4. 长事务是 Undo Log 膨胀的罪魁祸首
  5. Purge 线程只清理不再被任何事务引用的 Undo Log 版本
  6. 一句话总结:回滚找自己的旧版本,MVCC 找可见的旧版本,底层都是 Undo Log 版本链

Undo Log(回滚日志):事务原子性的保障

Undo Log(回滚日志):事务原子性的保障

概述

Undo Log(回滚日志)是 InnoDB 存储引擎中保证事务原子性(Atomicity)的重要机制。当事务执行过程中发生错误或用户执行 ROLLBACK 时,Undo Log 记录了如何”撤销”已执行的操作,使数据恢复到事务开始前的状态。

Undo Log 的核心作用

1. 事务回滚

当事务需要回滚时,InnoDB 通过 Undo Log 中的记录反向操作:

BEGIN;

-- 修改数据
UPDATE users SET balance = balance - 100 WHERE id = 1;
-- Undo Log 记录:将 id=1 的 balance 改回旧值(旧 balance)

-- 插入数据
INSERT INTO users(id, name) VALUES(2, 'Alice');
-- Undo Log 记录:删除 id=2 的记录(INSERT 的逆操作是 DELETE)

-- 删除数据
DELETE FROM users WHERE id = 3;
-- Undo Log 记录:插入 id=3 的记录(DELETE 的逆操作是 INSERT)

-- 回滚
ROLLBACK;
-- InnoDB 按 Undo Log 逆序回放:
-- 1. 插入 id=3(撤销 DELETE)
-- 2. 删除 id=2(撤销 INSERT)
-- 3. 将 id=1 的 balance 改回旧值(撤销 UPDATE)

2. MVCC 快照读

Undo Log 的另一个重要用途是实现 MVCC(多版本并发控制)。详见下一篇文章。

Undo Log 的存储

存储位置

  • MySQL 5.6 之前:存储在系统表空间(ibdata1)
  • MySQL 5.6+:可以配置独立的 Undo 表空间
# 独立 Undo 表空间配置
innodb_undo_tablespaces = 2     -- 创建 2 个 Undo 表空间文件
innodb_undo_directory = /data/undolog/  -- Undo 表空间目录
innodb_max_undo_log_size = 1G   -- 单个 Undo 表空间最大大小
innodb_purge_rseg_truncate_frequency = 128

日志段(Rollback Segment)

Undo Log 存储在 Rollback Segment(回滚段)中:

Rollback Segment
    ├── Slot 0: Undo Log Header
    ├── Slot 1: 事务 T1 的 Undo Log
    ├── Slot 2: 事务 T2 的 Undo Log
    ├── ...
    └── Slot N: 空闲

事务开始时,从 Rollback Segment 中分配一个 Slot 用于记录 Undo Log。

Undo Log 的记录类型

INSERT Undo Log

INSERT INTO users(id, name) VALUES(10, 'Bob');

Undo Log 记录的内容:

type: INSERT
table_id: 105
主键: id=10

回滚时,InnoDB 根据主键删除这条记录即可。

UPDATE Undo Log

UPDATE users SET name = 'Bob' WHERE id = 5;
# 假设 old_name = 'Alice'

Undo Log 记录的内容:

type: UPDATE
table_id: 105
主键: id=5
old_name: 'Alice'
old_balance: 200

回滚时,InnoDB 将 id=5 的 name 和 balance 恢复为旧值。

DELETE Undo Log

DELETE FROM users WHERE id = 5;

Undo Log 记录的内容:

type: DELETE
table_id: 105
主键: id=5
完整旧数据: (5, 'Alice', 200, ...)

回滚时,InnoDB 插入一条新的记录。

Undo Log 的生命周期

事务开始
    │
    ├── 执行 DML,不断追加 Undo Log 记录
    │
    ├── 事务提交或回滚
    │       │
    │       ├── COMMIT:
    │       │   - Undo Log 标记为"可清理"
    │       │   - 但不会立即删除(MVCC 还有可能用到)
    │       │   - 等 Purge 线程回收
    │       │
    │       └── ROLLBACK:
    │           - 立即使用 Undo Log 回滚数据
    │           - 回滚完后标记为"可清理"
    │
    └── Purge 线程回收
        - 检查 Undo Log 是否不再被任何事务/快照引用
        - 定期清理不再需要的 Undo Log
        - 释放 Rollback Segment 空间

Purge 线程

Purge 线程负责清理不再需要的 Undo Log:

# Purge 线程配置
innodb_purge_threads = 4          -- Purge 线程数(MySQL 8.0 默认 4)
innodb_purge_batch_size = 300     -- 每次 purge 的批次大小
innodb_max_purge_lag = 0          -- 当 purge 延迟时的最大容忍度
innodb_max_purge_lag_delay = 0

如果 Purge 线程跟不上,Undo Log 会不断膨胀,可能导致:
– Undo 表空间过大
– MVCC 快照扫描变慢
– 长事务导致回滚段无法回收

面试要点

  1. Undo Log 是逻辑日志:记录的是”怎么做逆向操作”,不像 Redo Log 记录物理页改动
  2. Undo Log 同时服务于事务回滚和 MVCC 快照读
  3. INSERT 的 Undo 是 DELETE,DELETE 的 Undo 是 INSERT,UPDATE 的 Undo 是反向 UPDATE
  4. 事务提交后 Undo Log 不会立即删除,需等 Purge 线程清理
  5. 长事务会导致 Undo Log 膨胀,影响性能
  6. Undo Log 也写磁盘,但与 Redo Log 不同:Redo 是 WAL(先写),Undo 也是 WAL 的一部分

Redo Log(重做日志):保证事务持久性的核心机制

Redo Log(重做日志):保证事务持久性的核心机制

概述

Redo Log(重做日志)是 InnoDB 存储引擎中用于保证事务持久性(Durability)的关键机制。它记录了对数据页所做的物理修改,确保在系统崩溃后能够恢复已提交事务的数据。

为什么需要 Redo Log?

MySQL 不会每次提交事务都直接把数据写入磁盘数据文件,主要原因:

  1. 随机 I/O 慢:数据页在磁盘上分布随机,直接写磁盘性能极差
  2. 数据页大于日志:一个数据页通常是 16KB,而一次修改可能只改动几个字节,写整个页浪费
  3. Buffer Pool:数据先在内存(Buffer Pool)中修改,定期刷回磁盘(脏页刷盘)

核心矛盾:事务提交时必须保证数据不丢失,但又不能每次提交都刷数据页。

解决方案:先写 Redo Log,日志是顺序 I/O,比随机 I/O 快得多。

Redo Log 的工作方式

物理日志

Redo Log 是物理日志,记录的是”对哪个表空间、哪个页、哪个偏移位置,做了什么修改”。格式大致为:

[space_id] [page_no] [offset] [data]

例如:

REDO: space=5, page=100, offset=800, data='new_value'

循环写入

Redo Log 使用固定大小的文件组(默认 2 个文件,每个 512MB),采用循环写入的方式:

┌─────────────────────────────────────────────────────┐
│                 Redo Log 文件组                      │
├─────────────────────────────────────────────────────┤
│  [已覆盖] [未使用] [活跃日志] [已刷盘] [已覆盖]      │
│                    ↑ write_pos                      │
│             checkpoint ← ↓                          │
└─────────────────────────────────────────────────────┘
  • write_pos:当前写入位置
  • checkpoint:已持久化到数据文件的位置
  • 当 write_pos 追到 checkpoint 时,会触发脏页刷盘

配置参数

# Redo Log 文件大小,建议设置为 Buffer Pool 的 25%-50%
innodb_log_file_size = 512M

# Redo Log 文件组中文件数量(默认 2)
innodb_log_files_in_group = 2

# 控制 Redo Log 刷盘策略(关键配置!)
innodb_flush_log_at_trx_commit = 1

# Redo Log 写入缓冲区大小
innodb_log_buffer_size = 16M

刷盘策略

innodb_flush_log_at_trx_commit 的三种取值:

行为 安全性 性能
0 每秒刷一次 Redo Log 到磁盘 ⚠️ 提交时可能丢失 1 秒数据 🚀 最快
1 每次事务提交都刷盘 ✅ 不丢失(ACID 保证) 最慢
2 每次提交写 OS Cache,每秒刷盘 ⚠️ OS 崩溃时可能丢失 1 秒数据 ⚡ 较快

生产环境建议:innodb_flush_log_at_trx_commit = 1,以数据安全为前提。

Redo Log 崩溃恢复流程

系统崩溃 → 重启
    │
    ├── 检查 Redo Log 最后一个 checkpoint
    │
    ├── 从 checkpoint 位置开始扫描 Redo Log
    │
    ├── 对 Redo Log 中记录的操作进行重放(Redo)
    │   └── 已提交但未刷盘 → 恢复数据
    │
    └── 根据 Undo Log 回滚未提交的事务(Undo)
        └── 未提交但在 Redo Log 中有记录 → 回滚

面试要点

  1. Redo Log 解决的核心问题:事务提交后,已修改但未写回磁盘的数据不会丢失
  2. Redo Log 是物理日志,记录的是字节级别的变更,不是 SQL 语句
  3. Redo Log 是循环写的,不能无限增长,设计为固定大小
  4. Redo Log 刷盘策略是平衡安全与性能的关键配置
  5. 崩溃恢复时:Redo Log 前滚已提交事务 + Undo Log 回滚未提交事务
  6. WAL(Write-Ahead Logging) 的核心思想就是”先写日志,再写数据”

查看 InnoDB 锁信息和等待事务

查看 InnoDB 锁信息和等待事务

概述

当 MySQL 出现锁等待、锁冲突或性能问题时,快速定位锁的状态至关重要。InnoDB 提供了丰富的系统表和命令来查看当前的锁信息、等待事务和阻塞关系。掌握这些诊断工具,是 DBA 和高级开发人员的必备技能。

工具总览

工具 用途 MySQL 版本
SHOW ENGINE INNODB STATUS 查看 InnoDB 整体状态,含死锁和锁信息 所有版本
information_schema.innodb_trx 当前所有事务信息 5.x+
information_schema.innodb_locks 当前所有锁(5.x) 5.x
performance_schema.data_locks 当前所有锁的详细信息 8.0+
performance_schema.data_lock_waits 锁等待关系 8.0+
sys.innodb_lock_waits 锁等待的简化视图 5.7+
SHOW PROCESSLIST 查看 MySQL 连接状态 所有版本

1. SHOW ENGINE INNODB STATUS

这是最经典、最全面的诊断命令,输出包含大量 InnoDB 内部状态信息:

SHOW ENGINE INNODB STATUS\G

重点关注的部分

-------------------------
LATEST DETECTED DEADLOCK  -- 最近一次死锁信息
-------------------------
-------------------------
TRANSACTIONS              -- 当前事务和锁信息
-------------------------

死锁信息示例

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 10:30:45 0x7f1234
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 10 sec
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 100, OS thread handle 12345, query id 6789
UPDATE account SET balance = balance + 100 WHERE id = 2

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY
...

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 4 n bits 72 index PRIMARY
...

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 8 sec
...
*** (2) HOLDS THE LOCK(S):
...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
...
*** WE ROLL BACK TRANSACTION (1)

2. information_schema 表(MySQL 5.x + 8.x)

当前事务信息

SELECT * FROM information_schema.innodb_trx\G

关键字段

字段 含义
trx_id 事务 ID
trx_state 事务状态(RUNNING/LOCK WAIT/COMMITTING/ROLLING BACK)
trx_started 事务开始时间
trx_mysql_thread_id MySQL 连接线程 ID
trx_query 事务正在执行的 SQL(可能为 NULL)
trx_rows_locked 锁定的行数
trx_isolation_level 事务隔离级别

查找长时间运行的事务

-- 查找运行超过 10 秒的事务
SELECT trx_id, trx_state, trx_started, 
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds,
       trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10
ORDER BY duration_seconds DESC;

查找锁等待的事务

-- MySQL 5.x 使用 innodb_locks
SELECT * FROM information_schema.innodb_locks;
SELECT * FROM information_schema.innodb_lock_waits;

3. performance_schema 表(MySQL 8.0+ 推荐)

MySQL 8.0 使用 performance_schema 替代了部分 information_schema 的锁监控功能。

data_locks:查看所有锁

-- 查看当前所有锁
SELECT * FROM performance_schema.data_locks\G

-- 查看某个特定事务持有的锁
SELECT * FROM performance_schema.data_locks
WHERE ENGINE_TRANSACTION_ID = 12345;

-- 查看锁定的对象和类型
SELECT ENGINE_TRANSACTION_ID AS trx_id,
       OBJECT_SCHEMA AS db,
       OBJECT_NAME AS table_name,
       INDEX_NAME,
       LOCK_TYPE,       -- TABLE | RECORD
       LOCK_MODE,       -- S, X, IX, IS, GAP, REC_NOT_GAP
       LOCK_STATUS,     -- GRANTED | WAITING
       LOCK_DATA        -- 锁定的具体值(如主键值)
FROM performance_schema.data_locks;

data_lock_waits:查看锁等待

-- 查看哪些事务在等待,被谁阻塞
SELECT * FROM performance_schema.data_lock_waits;

-- 更清晰的查询
SELECT 
    req.ENGINE_TRANSACTION_ID AS waiting_trx_id,
    req.LOCK_TYPE AS waiting_lock_type,
    req.LOCK_MODE AS waiting_lock_mode,
    req.LOCK_STATUS AS waiting_status,
    blk.ENGINE_TRANSACTION_ID AS blocking_trx_id,
    blk.LOCK_TYPE AS blocking_lock_type,
    blk.LOCK_MODE AS blocking_lock_mode
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks req ON w.REQUESTING_ENGINE_LOCK_ID = req.ENGINE_LOCK_ID
JOIN performance_schema.data_locks blk ON w.BLOCKING_ENGINE_LOCK_ID = blk.ENGINE_LOCK_ID;

4. sys 系统模式视图(MySQL 5.7+)

sys 模式提供了更简化的视图:

-- 简单查看锁等待
SELECT * FROM sys.innodb_lock_waits;

-- 简化输出示例:
-- wait_started         | 2024-01-15 10:30:00
-- wait_age             | 00:00:05
-- waiting_trx_id       | 12345
-- waiting_thread       | 100
-- waiting_query        | UPDATE account SET ...
-- blocking_trx_id      | 12344
-- blocking_thread      | 99
-- blocking_query       | SELECT * FROM account ... FOR UPDATE

5. SHOW PROCESSLIST

-- 查看所有连接的状态
SHOW FULL PROCESSLIST;

-- 关注状态字段
-- 'Locked'         → 事务在等待锁
-- 'Sending data'   → 正在查询大量数据
-- 'Waiting for table metadata lock' → MDL 锁等待

6. 实战:锁问题排查流程

步骤 1:发现锁等待

SHOW PROCESSLIST;
-- 看到多个线程状态为 'Locked'

步骤 2:查看锁等待详情

-- 查询锁等待关系
SELECT * FROM sys.innodb_lock_waits\G

步骤 3:定位阻塞事务

-- 找到阻塞源头的线程 ID
SELECT 
    waiting_trx_id,
    waiting_thread,
    blocking_trx_id,
    blocking_thread
FROM sys.innodb_lock_waits;

-- 查看阻塞事务的详细信息
SELECT * FROM information_schema.innodb_trx
WHERE trx_mysql_thread_id = 99;  -- blocking_thread

步骤 4:终止阻塞事务

KILL 99;  -- 根据 blocking_thread 终止

步骤 5:验证恢复

-- 再次检查
SELECT * FROM sys.innodb_lock_waits;
-- 应该没有结果了

故障排除案例

案例:大量锁等待

问题:应用超时,数据库 CPU 升高

排查

-- 1. 查看是否有锁等待
SELECT COUNT(*) FROM performance_schema.data_lock_waits;

-- 2. 找到阻塞的源头
SELECT blocking_thread, COUNT(*) as blocks
FROM sys.innodb_lock_waits
GROUP BY blocking_thread
ORDER BY blocks DESC;

-- 3. 查看阻塞者的具体 SQL
SELECT * FROM information_schema.processlist
WHERE id = (SELECT blocking_thread FROM sys.innodb_lock_waits LIMIT 1);

-- 4. 确认是长事务后终止
KILL (SELECT blocking_thread FROM sys.innodb_lock_waits LIMIT 1);

面试要点

  1. 核心诊断命令SHOW ENGINE INNODB STATUS + information_schema.innodb_trx
  2. MySQL 8.0 新视图performance_schema.data_locksdata_lock_waits
  3. sys 简化视图sys.innodb_lock_waits 一行命令看清锁等待
  4. 终止阻塞KILL thread_id 杀掉阻塞事务
  5. 最佳实践:定期监控长时间运行的事务,提前预警

事务启动方式的优化

事务启动方式的优化

概述

MySQL 中事务的启动方式直接影响性能和数据的正确性。隐式提交、自动提交(AUTOCOMMIT)以及显式的 BEGIN/START TRANSACTION 等不同的启动方式,在不同的业务场景下各有优劣。优化事务启动方式是日常开发中容易忽视但影响重大的环节。

事务启动的几种方式

1. AUTOCOMMIT 模式(默认)

MySQL 默认 AUTOCOMMIT = 1,每条 SQL 语句自动作为一个独立事务执行:

-- 查看当前设置
SELECT @@autocommit;  -- → 1

-- 每条语句自动提交
UPDATE user SET name = '张三' WHERE id = 1;  -- 自动 BEGIN + COMMIT
UPDATE user SET name = '李四' WHERE id = 2;  -- 自动 BEGIN + COMMIT

优点:简单,每条语句都是完整事务,不会忘记提交
缺点:无法将多条语句组成一个原子操作

2. 显式 START TRANSACTION / BEGIN

-- 方式一:START TRANSACTION(推荐)
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 方式二:BEGIN(效果相同)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

3. 设置 AUTOCOMMIT = 0

SET AUTOCOMMIT = 0;
-- 从这之后,所有 SQL 在同一个事务中
UPDATE user SET name = '张三' WHERE id = 1;
UPDATE user SET name = '李四' WHERE id = 2;
COMMIT;  -- 提交后,自动开启下一个事务

不同方式的优劣对比

方式 适用场景 风险
AUTOCOMMIT=1 单条 SQL 操作 多条操作无法原子化
显式 BEGIN/COMMIT OLTP 事务 记得配对提交/回滚
AUTOCOMMIT=0 交互式操作 容易忘记提交导致长事务

常见问题:忘记提交

这是 AUTOCOMMIT=0 的最大隐患:

SET AUTOCOMMIT = 0;
UPDATE user SET name = '张三' WHERE id = 1;
-- 忘记 COMMIT!
-- 这个行锁一直持有,其他线程无法更新 id=1 这条记录
-- 直到会话断开连接,MySQL 自动回滚

最佳实践

1. 应用层建议

Java(Spring)

// ✅ 推荐:使用声明式事务
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    accountDao.decrease(from, amount);
    accountDao.increase(to, amount);
}
// Spring 自动管理 BEGIN 和 COMMIT/ROLLBACK

Python

# ✅ 推荐:使用上下文管理器
with connection.cursor() as cursor:
    connection.begin()
    try:
        cursor.execute("UPDATE account SET balance = balance - 100 WHERE id = 1")
        cursor.execute("UPDATE account SET balance = balance + 100 WHERE id = 2")
        connection.commit()
    except:
        connection.rollback()

2. 保持 AUTOCOMMIT=1(默认)

-- 连接池配置中不要轻易关闭 autocommit
-- 需要事务时显式 START TRANSACTION

-- ✅ 推荐做法
START TRANSACTION;
    UPDATE ...
    UPDATE ...
COMMIT;

-- ❌ 不推荐做法
SET AUTOCOMMIT = 0;
UPDATE ...
UPDATE ...
COMMIT;

3. SET AUTOCOMMIT 的坑

-- SET AUTOCOMMIT = 0 会有一个隐式 COMMIT
-- 这可能导致前面的未提交事务被意外提交

START TRANSACTION;
UPDATE user SET name = 'X';
SET AUTOCOMMIT = 0;  -- ❌ 隐式提交了前面的 UPDATE!

4. 使用 SAVEPOINT(大事务拆解)

START TRANSACTION;
INSERT INTO orders ...;
SAVEPOINT order_created;
INSERT INTO order_items ...;  -- 这里出错了
ROLLBACK TO SAVEPOINT order_created;
-- 只回滚订单项,订单保留
-- 可以重试或修复后继续
COMMIT;

监控和排查

查看当前事务状态

-- 查看未提交的事务
SELECT trx_id, trx_state, 
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration,
       trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING' AND trx_autocommit_non_locking = 0;

自动清理方案

MySQL 8.0 提供了闲置事务超时功能:

-- 设置闲置事务超时时间(秒)
-- 超过该时间的闲置事务自动回滚
SET GLOBAL idle_transaction_timeout = 60;
SET SESSION idle_transaction_timeout = 60;

面试要点

  1. 核心区别START TRANSACTION vs SET AUTOCOMMIT=0 —— 推荐用前者
  2. 隐式提交:了解哪些操作会导致隐式事务提交(DDL、锁相关语句等)
  3. 长事务关联:错误的启动方式是长事务的常见原因
  4. 实用技巧:SAVEPOINT 用于大事务失败后部分回滚

长事务的危害与避免策略

长事务的危害与避免策略

概述

长事务是指执行时间过长长时间未提交的事务。在 MySQL InnoDB 中,长事务是性能杀手,可能引发锁争用、Undo Log 膨胀、回滚代价高等一系列严重问题。了解其危害并掌握避免方法,是 DBA 和开发者的必备技能。

长事务的定义

一般来说,以下情况属于长事务:

  • 执行时间超过 1 秒的事务(OLTP 场景)
  • 跨网络 RPC 调用的数据库事务
  • 事务中包含了大量的更新操作
  • 事务中混入了用户交互(等用户点击提交)

长事务的危害

1. Undo Log 膨胀

InnoDB 的 MVCC 机制依赖 Undo Log 构建历史版本。长事务存在时:

-- 长事务 T1
BEGIN;
SELECT * FROM big_table WHERE ...;
-- 未提交,保持 Read View 活跃

-- 其他事务继续修改数据
UPDATE big_table SET ...;  -- 产生 Undo Log
DELETE FROM big_table ...;  -- 产生 Undo Log

-- 但这些 Undo Log 不能清除,因为 T1 可能还需要它们

后果
– Undo 表空间持续增长
– 磁盘空间可能被撑爆
– 版本链过长,查询时需要遍历大量版本,性能下降

2. 锁持有时间过长

-- 长事务 T1 持有锁
BEGIN;
SELECT * FROM order WHERE id = 1 FOR UPDATE;
-- ... 长时间业务处理,锁不释放
-- 调用外部 API、等待用户输入等

-- 事务 T2 被阻塞
UPDATE order SET status = 2 WHERE id = 1;
-- ❌ 被阻塞!等待 T1 释放锁

后果
– 系统并发能力大幅下降
– 大量请求堆积,连接池耗尽
– 可能引发死锁

3. 回滚代价高

-- 长事务涉及大量修改
BEGIN;
INSERT INTO t1 VALUES ...;     -- 第 1 条
INSERT INTO t1 VALUES ...;     -- 第 2 条
-- ... 第 3-10000 条
-- ... 报错或主动回滚
ROLLBACK;  -- 需要逐条回滚所有操作

后果
– 回滚时间可能比执行时间还长
– 回滚期间锁不会立即释放
– 占用大量 CPU 和 IO

4. 主从延迟

长事务在主库上长时间运行,从库可能需要同步同一个长事务产生的 Binlog,导致:

  • 从库复制延迟飙升
  • 如果主库崩溃,恢复时间长
  • 从库查询的数据滞后

5. Read View 堆积

长事务的 Read View 导致 InnoDB 的 Purge 线程无法清理旧版本数据:

  • information_schema.innodb_trx 中可以看到长时间未结束的事务
  • SHOW ENGINE INNODB STATUS 中显示大量历史列表(History list length)

如何避免长事务

1. 事务设计层面

-- ❌ 错误:大事务
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
UPDATE account SET balance = balance - 50 WHERE id = 3;
UPDATE account SET balance = balance + 50 WHERE id = 4;
-- ... 继续处理其他业务逻辑
COMMIT;

-- ✅ 正确:拆分小事务
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 3;
UPDATE account SET balance = balance + 50 WHERE id = 4;
COMMIT;

原则
– 事务尽可能短小
– 只在事务中做数据库操作
– 不要在网络调用中保持事务

2. 事务中不要混入非数据库操作

// ❌ 错误:事务中调用外部 API
@Transactional
public void processOrder(Order order) {
    orderDao.update(order);  // 数据库操作
    httpClient.sendNotification(order);  // 网络请求(耗时长)
    // 事务还未提交!
}

// ✅ 正确:先提交事务再调用外部 API
@Transactional
public void updateOrder(Order order) {
    orderDao.update(order);  // 数据库操作
}  // 事务提交

public void processOrder(Order order) {
    updateOrder(order);
    httpClient.sendNotification(order);  // 事务已提交
}

3. 监控和报警

-- 查看当前运行中的事务
SELECT * FROM information_schema.innodb_trx;

-- 查看运行时间超过 5 秒的事务
SELECT trx_id, trx_state, trx_started, 
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_duration_seconds,
       trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 5;

-- 强制杀掉长事务(根据 connection ID)
KILL 12345;

4. 应用层防御

  • 设置事务超时时间
  • 使用连接池的空闲回收机制
  • ORM框架中自动提交模式慎用
-- MySQL 5.x: 锁等待超时时间(单位秒)
SET innodb_lock_wait_timeout = 5;

-- MySQL 8.0: 增加空闲事务超时
SET session_idle_transaction_timeout = 10;

面试要点

  1. 核心危害:Undo 膨胀 + 锁竞争 + 回滚代价大 —— 三句话概括
  2. 避免原则:短事务 + 不混入 IO + 监控报警
  3. 排查方法information_schema.innodb_trx + KILL 命令
  4. 经典场景:事务中调用 HTTP API、事务中等待用户输入、批量操作未分批

REPEATABLE READ 如何解决幻读

REPEATABLE READ 如何解决幻读

概述

幻读是 REPEATABLE READ 隔离级别的定义问题。但在 MySQL InnoDB 的实现中,REPEATABLE READ 级别通过 MVCC 和 Gap Lock(间隙锁) 的组合机制,实际上能够防止幻读的发生。这是一种超出 SQL 标准定义的能力。

两种读路径的不同解法

InnoDB 对幻读的解决,需要区分快照读当前读两种路径,它们使用不同的机制:

幻读问题
        ├── 快照读(普通 SELECT       └── 解决方式:MVCC + Read View 事务级快照
        └── 当前读(SELECT FOR UPDATE / UPDATE / DELETE        └── 解决方式:Next-Key LockRecord Lock + Gap Lock

1. 快照读防止幻读

原理

REPEATABLE READ 下,事务的第一次 SELECT 会创建 Read View(快照),记录当前活跃的事务列表。后续所有普通 SELECT 都复用同一个 Read View。

作用机制

-- 事务 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;

-- 第一次 SELECT:创建 Read View(记录此时所有活跃事务)
SELECT * FROM user WHERE age > 20;
-- → 假设读到 3 行:[{id=1, age=25}, {id=2, age=30}, {id=3, age=35}]

-- 事务 B 插入一条 age=28 的新记录并提交
-- INSERT INTO user(age) VALUES(28); COMMIT;

-- 再次 SELECT:复用同一个 Read View
SELECT * FROM user WHERE age > 20;
-- → 仍然是 3 行!新插入的第 4 行对当前事务不可见

关键

  • 新插入的行,其 DB_TRX_ID 大于 Read View 记录的事务 ID 列表上限
  • 根据可见性规则,该行对当前事务不可见
  • 所以快照读下幻读不会发生

2. 当前读防止幻读

问题来源

如果事务中使用 当前读(加锁的 SELECT 或 DML),MVCC 的快照机制不再适用,因为当前读要读取最新的数据版本。

解法:Next-Key Lock

Next-Key Lock = Record Lock + Gap Lock

间隙锁(Gap Lock)的角色

间隙锁锁定的是索引记录之间的间隙,阻止其他事务在这个间隙中插入新记录

索引值:[10, 20, 30]

间隙范围:
(-∞, 10) → Gap Lock
(10, 20) → Gap Lock
(20, 30) → Gap Lock
(30, +∞) → Gap Lock

完整示例

-- 事务 A
BEGIN;
SELECT * FROM user WHERE age > 20 FOR UPDATE;
-- InnoDB 在 age 索引上加 Next-Key Lock
-- 锁住现有记录 + 记录之间的间隙

-- 事务 B
INSERT INTO user(age) VALUES(25);
-- ❌ 被阻塞!25 落在 (20, 30) 间隙中,Gap Lock 阻止了插入

Range 条件的加锁策略

不同条件会产生不同的加锁范围:

查询条件 加锁范围
age = 20(唯一索引) Record Lock + Gap Lock(仅该行前后)
age > 20 所有符合条件的记录 + 全部相关间隙
age BETWEEN 20 AND 30 (20,30] 范围内的记录和间隙
age < 20 (-∞, 20) 间隙 + 记录

3. 一个完整的防幻读流程

-- 场景:订单金额统计
-- 表结构:order(amount INT, INDEX idx_amount(amount))

-- 事务 A
BEGIN;
SELECT SUM(amount) FROM order WHERE amount > 100;
-- → 1000(使用 Read View 快照读)

-- 事务 B 插入 amount=150 的新订单并提交
-- 如果事务 A 之后执行当前读:

SELECT * FROM order WHERE amount > 100 FOR UPDATE;
-- → Next-Key Lock 锁住所有量 > 100 的记录及其间隙
-- → 其他事务无法插入新的大额订单
-- → 确保同一事务内的当前读不会看到新行(被阻塞了)

4. 边界情况:REPEATABLE READ 也不能完全防幻读

间隙锁的局限

间隙锁只在索引记录之间的间隙起作用。如果:

  1. 没有索引的列:WHERE 条件使用无索引的列,InnoDB 会锁全表
  2. 唯一索引等值查询:如果唯一索引查到记录,间隙降级为 Record Lock
  3. 间隙锁不阻塞 UPDATE:间隙锁只阻止 INSERT,不阻止 UPDATE

典型遗漏场景

-- 表 t(id INT PRIMARY KEY, name VARCHAR(10))

-- 事务 A
BEGIN;
SELECT * FROM t WHERE name = 'Alice' FOR UPDATE;
-- name 列无索引 → 锁全表

-- 事务 B 可以插入另一条 name='Bob' 的记录
INSERT INTO t(name) VALUES('Bob');  -- ✅ 可能成功

-- 这算不算幻读?取决于业务定义

5. 面试要点

  1. 经典问法:"REPEATABLE READ 能解决幻读吗?"
    - 答案:MySQL InnoDB 的 REPEATABLE READ 通过 MVCC + Gap Lock 能够防止幻读,这是超出 SQL 标准的能力
  2. 深度解析:快照读靠 Read View 防止幻读,当前读靠 Gap Lock 防止幻读
  3. 知识关联:常与 MVCC、Read View、Next-Key Lock 等概念一起考查
  4. 谨慎回答:注意区分"快照读"和"当前读"两种路径——只回答一种不够全面

脏读、不可重复读、幻读详解

脏读、不可重复读、幻读详解

概述

脏读(Dirty Read)、不可重复读(Non-Repeatable Read)、幻读(Phantom Read)是数据库并发事务中的三大经典问题。理解这三者的定义、区别和产生原因,是掌握事务隔离级别的基础。

一、脏读(Dirty Read)

定义

脏读是指一个事务读到了另一个尚未提交的事务修改的数据。如果那个事务最终回滚,读取的数据就是"脏"的。

产生条件

隔离级别 ≤ READ UNCOMMITTED

示例

事务 A:                       事务 B:
UPDATE account                  (B的其他操作)
SET balance = balance - 100
WHERE id = 1;
                                SELECT balance FROM account WHERE id = 1;
                                → 读到已减少的金额(脏数据!)
ROLLBACK;(余额恢复)
                                ↑ 事务 B 基于错误数据做了后续操作

比喻

好比你家装修工人还没贴完壁纸,你就跟朋友说"壁纸已经贴好了"。结果工人撕掉重新贴了另一种花色,你跟朋友说的就是"脏信息"。

二、不可重复读(Non-Repeatable Read)

定义

在一个事务内,两次读取同一条记录,得到的结果不一致(被其他已提交事务修改了)。

产生条件

隔离级别 ≤ READ COMMITTED

示例

-- 事务 A:查询同一个商品的库存
BEGIN;
SELECT stock FROM product WHERE id = 1;  -- → 100

-- 事务 B:提交了库存修改
UPDATE product SET stock = 90 WHERE id = 1;
COMMIT;

SELECT stock FROM product WHERE id = 1;  -- → 90(和第一次不一样!)
COMMIT;

关键点

  • 针对的是同一行数据的修改(UPDATE)
  • 读到的是其他事务已提交的数据(比脏读"干净"一些,但仍然有问题)

比喻

你在超市看中一箱牛奶,价格标签是 50 元。你去收银台问个问题回来,价格标签变成了 60 元(另一个工作人员更新了价格)。又去问了一下,回来变成了 55 元。

三、幻读(Phantom Read)

定义

在一个事务内,两次执行相同的范围查询,第二次查询多出了之前不存在的行(幻影行)。

产生条件

隔离级别 ≤ REPEATABLE READ(在 READ COMMITTED 和 REPEATABLE READ 中可能发生)

示例

-- 事务 A:查询所有符合条件的订单
BEGIN;
SELECT * FROM order WHERE amount > 100;  -- → 5 行

-- 事务 B:插入了一个新订单并提交
INSERT INTO order(amount) VALUES(200);
COMMIT;

SELECT * FROM order WHERE amount > 100;  -- → 6 行(多了一行幻影!)
COMMIT;

关键点

  • 针对的是新增(INSERT)操作,不是修改
  • 产生的是"原本不存在的记录"(幻影行)
  • 现象是结果集的行数变化

比喻

你数了教室里的学生,有 30 人。走出教室回头再看,变成了 31 人——不知从哪冒出来的。

四、三大问题的对比

维度 脏读 不可重复读 幻读
读取对象 未提交数据 已提交数据 新插入的数据
操作类型 任何修改 UPDATE INSERT
对比对象 同一条记录 同一条记录 同一范围结果集
隔离级别阈值 READ UNCOMMITTED 以下 READ COMMITTED 以下 REPEATABLE READ 以下
严重程度 ⭐⭐⭐ 最严重 ⭐⭐
是否涉及回滚 是(读回滚数据) 否(读已提交数据) 否(读新插入数据)

五、与隔离级别的对应关系

                   脏读    不可重复读    幻读
READ UNCOMMITTED   可能    可能         可能
READ COMMITTED     不会    可能         可能
REPEATABLE READ    不会    不会         可能(InnoDB 通过间隙锁可防)
SERIALIZABLE       不会    不会         不会

六、面试重点

常用话术

"脏读是读到了别人没提交的数据,不可重复读是读到了别人已提交的修改,幻读是读到了别人新增的行。三者的严重程度递减,解决的代价递增。"

容易混淆的点

  1. 不可重复读 vs 幻读:前者是同一行内容变了,后者是多了行或少了行
  2. 脏读 vs 不可重复读:前者是读未提交数据,后者是读已提交数据
  3. InnoDB 的特殊性:InnoDB 在 REPEATABLE READ 级别下通过 MVCC 和间隙锁可以防止幻读

REPEATABLE READ 实现原理

REPEATABLE READ 实现原理

概述

REPEATABLE READ(可重复读)是 MySQL InnoDB 的默认事务隔离级别。它的核心特征是:在同一个事务内,多次读取同一行数据,结果始终保持一致。这一能力的实现依赖于 MVCC(多版本并发控制)+ Read View(读视图) 两大机制。

整体架构

REPEATABLE READ 的实现由以下核心组件共同完成:

┌─────────────────────────────────────────┐
│         REPEATABLE READ 实现            │
├─────────────────────────────────────────┤
│  1. MVCC(多版本并发控制)               │
│     ├── 隐藏列:DB_TRX_ID、DB_ROLL_PTR  │
│     └── Undo Log 版本链                  │
├─────────────────────────────────────────┤
│  2. Read View(读视图)                  │
│     ├── 事务开始时创建                   │
│     └── 整个事务期间复用                 │
├─────────────────────────────────────────┤
│  3. 当前读使用 Next-Key Lock             │
│     ├── 防止幻读                        │
│     └── 保证范围内无新记录插入           │
└─────────────────────────────────────────┘

1. MVCC 多版本并发控制

隐藏列

InnoDB 的每行记录都有两个隐藏列:

  • DB_TRX_ID:最近修改该行的事务 ID
  • DB_ROLL_PTR:指向 Undo Log 的指针,用于构建历史版本

Undo Log 版本链

当一行被修改时,InnoDB 不会覆盖旧值,而是:
1. 将旧值写入 Undo Log
2. 通过 DB_ROLL_PTR 将新版本指向旧版本
3. 形成一条版本链

最新的版本 ← Undo ← 上一版本 ← Undo ← 更早版本

版本链的利用

REPEATABLE READ 通过版本链实现一致性读:
- 如果当前版本对当前事务不可见,就沿着版本链向前寻找可见的版本
- 这样,即使其他事务提交了修改,当前事务仍能看到自己快照时的数据

2. Read View(读视图)

Read View 是 REPEATABLE READ 实现可重复读的核心数据结构

创建时机

关键区别:REPEATABLE READ 在事务中第一次 SELECT 时创建 Read View,之后整个事务期间复用同一个

Read View 的结构

Read View 包含:
├── m_low_limit_id       → 下一个将被分配的事务 ID(最大 ID + 1)
├── m_up_limit_id        → 活跃事务的最小 ID
├── m_creator_trx_id     → 创建该 Read View 的事务 ID
└── m_ids                → 创建 Read View 时所有活跃事务的 ID 列表

可见性规则

通过 DB_TRX_ID 和 Read View 的对比,InnoDB 决定某个版本是否可见:

条件 可见性
DB_TRX_ID < m_up_limit_id ✅ 可见(事务已提交)
DB_TRX_ID >= m_low_limit_id ❌ 不可见(该事务在快照之后才启动)
m_up_limit_id ≤ DB_TRX_ID < m_low_limit_id 在 m_ids 中?→ ❌ 不可见;不在?→ ✅ 可见
DB_TRX_ID == m_creator_trx_id ✅ 可见(自己的修改)

为什么能"可重复读"?

因为整个事务期间的 Read View 始终不变

事务 T1:
第一次 SELECT → 创建 Read View(记录此刻活跃事务列表)
第二次 SELECT → 复用同一个 Read View
第三次 SELECT → 复用同一个 Read View

→ 所有 SELECT 都基于同一快照,结果自然一致

对比 READ COMMITTED:每条 SELECT 都创建的 Read View,所以能看到其他事务新提交的修改。

3. 当前读与 Next-Key Lock

对于快照读(普通 SELECT),MVCC + Read View 已经解决了可重复读问题。但对于当前读(SELECT ... FOR UPDATE/LOCK IN SHARE MODE、UPDATE、DELETE),则需要锁机制。

临键锁(Next-Key Lock)

REPEATABLE READ 下的当前读使用 Next-Key Lock,它包含:

  • Record Lock:锁定具体的记录行
  • Gap Lock:锁定记录之间的间隙

作用:防止其他事务在锁定范围内插入新记录,从而防止幻读

为什么需要两种机制?

读类型 机制 目的
快照读 MVCC + Read View 保证可重复读
当前读 Next-Key Lock 保证数据完整性,防止幻读

4. REPEATABLE READ 的完整流程

1. 事务 BEGIN
2. 第一次执行 SELECT快照读
   
3. 创建 Read View记录当前活跃事务列表
   
4. SELECT 根据 DB_TRX_ID  Read View 判断可见性
   沿着 Undo Log 版本链找到合适的版本
   
5. 后续所有 SELECT快照读复用同一个 Read View
   
6. 执行 UPDATE/DELETE当前读
   
7. 使用 Next-Key Lock 锁定相关记录和间隙
   
8. 事务 COMMIT  ROLLBACK

面试要点

  1. 高频考点:REPEATABLE READ 通过什么机制实现?——MVCC + Read View + Next-Key Lock
  2. 对比理解:与 READ COMMITTED 的本质区别是"Read View 的创建时机"
  3. 容易混淆:快照读解决可重复读,当前读用锁解决幻读——两者是互补关系
  4. 异常情况:REPEATABLE READ 下"完全"解决幻读了吗?——快照读已解决,但当前读靠间隙锁,间隙锁只对特定范围有效

READ UNCOMMITTED 隔离级别存在的问题

READ UNCOMMITTED 隔离级别存在的问题

概述

READ UNCOMMITTED(读未提交)是 MySQL InnoDB 提供的四种事务隔离级别中最低的一级。它允许一个事务读取到另一个尚未提交的事务所做的修改。虽然性能可能是最高的,但它会引发一系列严重的数据一致性问题。

核心问题:脏读(Dirty Read)

什么是脏读

脏读是 READ UNCOMMITTED 级别最典型的问题——一个事务读到了另一个事务未提交的中间数据

事务 A                       事务 B
BEGIN;                         BEGIN;
UPDATE account SET balance = 0 WHERE id = 1;
                                SELECT balance FROM account WHERE id = 1;
                                 读到 balance = 0(脏数据!)
ROLLBACK;(撤销了修改)
                                (事务 B 以为余额是 0,但实际还是 100

脏读的危害

  1. 业务决策错误:基于未最终确认的数据做判断
  2. 数据不可靠:读取的数据随时可能被回滚
  3. 级联问题:脏数据被传播到其他表或系统

其他问题

1. 不可重复读(Non-Repeatable Read)

同一事务中两次读取同一行数据,结果不一致:

事务 A(READ UNCOMMITTED):
SELECT balance FROM account WHERE id = 1;  → 100
SELECT balance FROM account WHERE id = 1;  → 50(另一个事务修改了)
SELECT balance FROM account WHERE id = 1;  → 100(另一个事务回滚了)

2. 幻读(Phantom Read)

同一事务中两次范围查询,结果集行数不一致:

事务 A:
SELECT * FROM account WHERE balance > 0;  → 2 行
SELECT * FROM account WHERE balance > 0;  → 3 行(多了一行,另一个事务插入了新记录)

小知识:在 READ UNCOMMITTED 级别下,上述三种并发问题全部可能发生。它是并发安全级别最低的一档。

为什么还有人用 READ UNCOMMITTED?

虽然不推荐,但在某些场景下仍有使用:

可能的用途

场景 说明
日志收集 允许少量脏数据,追求最低延迟
监控统计 不依赖精确值的粗略统计
只读副本 对一致性无要求的只读查询

性能优势解读

READ UNCOMMITTED 的"性能优势"主要来自:
- 不加任何锁:SELECT 语句不需要加读锁
- 无需版本控制:不需要 MVCC 的多版本可见性判断
- 写入不受阻塞:写入操作不会被读取操作阻塞

但这些优势在大多数场景下不值得——因为现代硬件和 InnoDB 的优化下,READ COMMITTED 的性能几乎与之持平,却安全得多。

MySQL 中的 READ UNCOMMITTED

InnoDB 的实际行为

虽然 READ UNCOMMITTED 理论上"读未提交数据",但在 InnoDB 的实现中

  • 对于未修改的数据页:直接读取
  • 对于被修改但未提交的数据:读 Undo Log 中的旧版本
  • 因此,InnoDB 下的 READ UNCOMMITTED 实际上不一定发生脏读

这是 InnoDB 的一个实现细节——它始终使用多版本控制机制,即使在 READ UNCOMMITTED 级别也是如此。但在官方定义中,READ UNCOMMITTED 级别仍然被标注为"可能发生脏读"。

设置方式

SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 或
SET @@session.tx_isolation = 'READ-UNCOMMITTED';

面试要点

  1. 烂大街的问题:"MySQL 的四种隔离级别分别是什么?READ UNCOMMITTED 有什么问题?"——脏读是必答项
  2. 进阶思考:解释 InnoDB 下 READ UNCOMMITTED 不一定发生脏读的原因(Undo Log 机制)
  3. 实际使用:业务系统一般不用 READ UNCOMMITTED,面试时可强调其不推荐性

READ COMMITTED 与 REPEATABLE READ 的区别

READ COMMITTED 与 REPEATABLE READ 的区别

概述

READ COMMITTED(读已提交)和 REPEATABLE READ(可重复读)是 MySQL 中最常用的两种事务隔离级别。MySQL InnoDB 的默认级别是 REPEATABLE READ,而 Oracle、PostgreSQL 等数据库默认使用 READ COMMITTED。理解两者的区别,是 MySQL 面试的高频考点。

核心区别总览

对比维度 READ COMMITTED REPEATABLE READ
脏读 ❌ 不会发生 ❌ 不会发生
不可重复读 ⚠️ 可能发生 ✅ 不会发生
幻读 ⚠️ 可能发生 ✅ 使用间隙锁可防止
默认隔离级别 Oracle/PostgreSQL 默认 MySQL InnoDB 默认
实现机制 语句级快照(Snapshots) 事务级快照
加锁范围 只在行上加锁 行锁 + 间隙锁

1. 不可重复读差异

READ COMMITTED:可能发生

同一事务中两次读取同一行,可能看到不同值:

-- 事务 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT name FROM user WHERE id = 1;  -- → '张三'

-- 此时事务 B 将 id=1 的 name 改为 '李四' 并提交

SELECT name FROM user WHERE id = 1;  -- → '李四'(值变了!)
COMMIT;

REPEATABLE READ:不会发生

同一事务中多次读取同一行,结果始终一致:

-- 事务 A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT name FROM user WHERE id = 1;  -- → '张三'

-- 此时事务 B 将 id=1 的 name 改为 '李四' 并提交

SELECT name FROM user WHERE id = 1;  -- → '张三'(值不变!)
COMMIT;

2. 幻读差异

READ COMMITTED:可能发生幻读

-- 事务 A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM user WHERE age > 20;  -- → 3 行

-- 此时事务 B 插入一条 age=25 的记录并提交

SELECT * FROM user WHERE age > 20;  -- → 4 行(多了一行幻影记录!)
COMMIT;

REPEATABLE READ:通过间隙锁防止幻读

在 REPEATABLE READ 级别下,InnoDB 使用 Next-Key Lock(临键锁) 来防止幻读。当对范围加锁时,不仅锁住现有记录,还锁住记录之间的间隙,阻止新记录插入。

3. 实现机制差异

快照读(Snapshot Read)

两者的本质差异在于快照创建时机

  • READ COMMITTED每条 SELECT 语句都创建新的快照(语句级快照)
  • REPEATABLE READ事务的第一个 SELECT 创建快照,整个事务期间复用(事务级快照)
-- READ COMMITTED 的 Read View:
-- SELECT 1 → 创建 Read View 1
-- SELECT 2 → 创建 Read View 2(可能不同)

-- REPEATABLE READ 的 Read View:
-- SELECT 1 → 创建 Read View 1
-- SELECT 2 → 复用 Read View 1(始终一致)

加锁区别

  • REPEATABLE READ:在加锁读(SELECT ... FOR UPDATE/LOCK IN SHARE MODE)时,使用 Next-Key Lock(记录锁 + 间隙锁),范围更大
  • READ COMMITTED:只使用 Record Lock(记录锁),不锁间隙

4. 性能差异

因素 READ COMMITTED REPEATABLE READ
锁冲突概率 较低(不加间隙锁) 较高(间隙锁范围大)
快照创建开销 较高(每条 SELECT 都创建) 较低(事务内只创建一次)
并发写入效率 更高 较低(间隙锁阻塞插入)

5. 实际选择建议

场景 推荐级别 原因
高并发写入系统 READ COMMITTED 减少锁冲突,提高吞吐
数据一致性要求高 REPEATABLE READ 避免不可重复读和幻读
金融交易系统 REPEATABLE READ 同一事务内多次读取必须一致
统计分析 READ COMMITTED 允许一点不一致,追求效率
报告类业务 REPEATABLE READ 报表生成过程中数据需稳定

面试要点

  1. 必问考点:两者的区别本质在于"快照创建时机"(语句级 vs 事务级)
  2. 进阶理解:REPEATABLE READ 并不完全解决幻读——只对快照读解决,当前读仍需间隙锁
  3. 搭配问题:常与 MVCC、Next-Key Lock、间隙锁一起考查
  4. 实际调优:高并发系统中有时会将级别降为 READ COMMITTED 以提升性能

事务 ACID 属性详解

事务 ACID 属性详解

什么是事务

事务是一组数据库操作,要么全部执行成功,要么全部不执行:

-- 转账事务
START TRANSACTION;

UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;

COMMIT;
-- 如果任何一步失败,ROLLBACK 回滚到初始状态

ACID 四大属性

事务的可靠性建立在 ACID 之上,分别是:

  • Atomicity(原子性)
  • Consistency(一致性)
  • Isolation(隔离性)
  • Durability(持久性)

原子性(Atomicity)

事务是一个不可分割的最小工作单元,要么全部成功,要么全部失败。

START TRANSACTION;

INSERT INTO orders(order_id, user_id, amount) VALUES(1001, 1, 200);
UPDATE inventory SET stock = stock - 1 WHERE product_id = 10;
INSERT INTO payment(order_id, status) VALUES(1001, 'paid');

-- 要么三条全部成功
COMMIT;

-- 要么全部回滚(比如第三条失败了)
ROLLBACK;  -- 前两条也被撤销

实现机制:通过 undo log 实现。

一致性(Consistency)

事务执行前后,数据库必须从一个一致状态转换到另一个一致状态

-- 一致性规则示例
-- 规则:账户余额不能为负数

-- ✅ 符合一致性
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 假设 balance 当前是 500 → 变成 400(>= 0,合法)

-- ❌ 违反一致性
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- 如果 balance 当前是 500 → 变成 -500(违反约束)

-- 如果约束没设,应用层要保证 consistency

一致性包括

1. 数据完整性约束(主键、唯一性、外键)
2. 业务规则约束(如余额不能为负)
3. 数据库内部一致性(索引与数据一致)

A + I + D 共同保障 C

隔离性(Isolation)

多个事务并发执行时,彼此之间应该相互隔离,一个事务的操作不会影响另一个事务。

-- 事务 A:扣减库存
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'ABC';
-- 还没提交

-- 事务 B:查询库存
SELECT stock FROM inventory WHERE sku = 'ABC';
-- 事务 B 看到的是 100 还是 99?
-- 答案取决于隔离级别

实现机制:通过 锁机制MVCC(多版本并发控制)

持久性(Durability)

一旦事务提交成功,其修改必须永久保存,即使系统崩溃也不会丢失。

START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 系统崩溃!重启后:
-- ✅ 余额变化仍然存在
-- ✅ 转账记录仍然在

实现机制:通过 redo log 实现。

ACID 的关系

       原子性 (undo log)
           │
           ▼
一致性 ───完整性───── 隔离性 (锁 + MVCC)
           │
           ▼
       持久性 (redo log)
一致性是最终目标:
- 原子性保证事务不中断(不会半途而废)
- 隔离性保证事务不互相干扰
- 持久性保证事务结果不丢失

这四者共同保证:数据库在并发和故障场景下依然可靠。

如果 ACID 缺失会怎样

-- 没有原子性:部分执行
-- 扣了钱没加钱!100 元凭空消失
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 系统崩溃!
-- UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 没执行

-- 没有隔离性:
-- 两个线程同时卖最后一张票
-- 事务 A:库存为 1,减为 0,提交
-- 事务 B:库存为 1(读到了 A 提交前的旧值),减为 -1 → 超卖!

-- 没有持久性:
-- 转账成功,用户看到余额变化
-- 系统崩溃重启后,数据回退到转账前 → 钱丢了!

面试要点

  • ACID 是事务的四大特性:原子性、一致性、隔离性、持久性
  • 原子性 → undo log:执行失败时回滚到事务开始前的状态
  • 一致性 → 业务规则:保持数据库约束和业务完整性
  • 隔离性 → 锁 + MVCC:解决并发事务之间的冲突
  • 持久性 → redo log:事务提交后数据永久保存
  • ACID 的权衡:严格的 ACID 会降低性能,所以有隔离级别的选择

一句话总结:ACID 保证了数据库在并发、异常、崩溃场景下的数据可靠性——原子性不让事务断成两截,隔离性不让事务"打架",持久性不让数据"白干",一致性是这它们的共同目标。


容器与虚拟机的隔离级别深度对比

容器与虚拟机的隔离级别深度对比

隔离的本质不同

容器和虚拟机在隔离级别上有本质区别,这决定了它们各自的安全边界和使用场景。

graph LR
    subgraph 隔离级别光谱
        L0[弱隔离] --> L1[进程级]
        L1 --> L2[命名空间级]
        L2 --> L3[内核级]
        L3 --> L4[硬件级]
        L4 --> L5[强隔离]
    end

    subgraph 实际技术
        Containers[Docker 容器<br/>进程 + Namespace 隔离]
        Kata[Kata Containers<br/>轻量级 VM 隔离]
        VM[传统虚拟机<br/>硬件虚拟化隔离]
    end

    L1 --> Containers
    L3 --> Kata
    L4 --> VM

隔离能力对比矩阵

隔离维度 Docker 容器 虚拟机
文件系统 Namespace 隔离(可突破) 完全隔离
网络栈 Namespace 隔离 完全隔离
进程树 PID Namespace 完全隔离
IPC IPC Namespace 完全隔离
用户/组 User Namespace 完全隔离
操作系统内核 共享宿主机内核 ✅ 独立内核
硬件资源 cgroups 限制 硬件虚拟化
时间戳 Namespace 隔离(可选) 完全隔离
性能损耗 ~2-5% ~10-20%
安全边界 薄(内核攻击面大) 厚(Hypervisor 隔离)

详细隔离机制

容器隔离:Linux Namespace

# 容器使用 8 种 Namespace 实现隔离
# 查看容器的 Namespace
ls -la /proc/1/ns/
# 输出示例:
# lrwxrwxrwx 1 root root 0 ... cgroup -> cgroup:[4026531835]
# lrwxrwxrwx 1 root root 0 ... ipc    -> ipc:[4026531839]
# lrwxrwxrwx 1 root root 0 ... mnt    -> mnt:[4026531841]
# lrwxrwxrwx 1 root root 0 ... net    -> net:[4026531992]
# lrwxrwxrwx 1 root root 0 ... pid    -> pid:[4026531836]
# lrwxrwxrwx 1 root root 0 ... user   -> user:[4026531837]
# lrwxrwxrwx 1 root root 0 ... uts    -> uts:[4026531838]
# lrwxrwxrwx 1 root root 0 ... time   -> time:[4026531834]
Namespace 隔离内容 作用
PID 进程编号 容器内只能看到自己的进程
Network 网络设备、IP、端口 容器有独立的网络栈
Mount 挂载点 容器有独立的文件系统视图
UTS 主机名和域名 容器可独立设置 hostname
IPC 进程间通信资源 隔离信号量、消息队列
User 用户和组 ID 容器内 root ≠ 宿主机 root
Cgroup cgroups 视图 容器查看自己的资源限制
Time 系统时间(Linux 5.6+) 容器可有独立时间偏移

容器的"残缺陷阱":共享内核

graph TB
    subgraph 安全风险
        Exploit[内核漏洞 CVE] -->|容器共享内核| AllContainers[所有容器受影响]
        Exploit -->|独立内核| VMOnly[仅该虚拟机受影响]
    end

    subgraph 实际案例
        CVE1[Dirty Pipe CVE-2022-0847<br/>Linux 内核提权漏洞]
        CVE2[Container Escape<br/>容器逃逸漏洞]
    end

    CVE1 -.->|影响所有| AllContainers
    CVE2 -.->|利用共享内核| AllContainers

如果一个容器中的恶意代码利用了 Linux 内核漏洞(如 Dirty Pipe),所有运行在同一宿主机上的容器都可能受影响

虚拟机隔离:硬件虚拟化

# 虚拟机通过硬件辅助虚拟化实现隔离
# CPU 的 VMX (Intel) / SVM (AMD) 指令集
# 每个 VM 运行独立的 Guest OS 内核

# VM 的进程隔离表现
vm1: PID 1 (systemd)  init  sshd
vm2: PID 1 (systemd)  init  sshd
# 它们的 PID 1 是完全不同的操作系统实例

隔离级别的现实影响

安全性

# 容器默认安全性较低
# 一个典型的容器逃逸攻击步骤
# Step 1: 获取容器控制权(通过应用漏洞)
# Step 2: 利用共享内核漏洞突破 Namespace
# Step 3: 访问宿主机和其他容器的数据

# 加固容器安全的方法:
# 1. 启用 User Namespace 重映射
# 2. 使用只读根文件系统
# 3. 使用 seccomp 限制系统调用
# 4. 禁止特权模式 --privileged
docker run --security-opt=no-new-privileges \
           --security-opt=seccomp=default.json \
           --read-only \
           myapp

性能

指标 容器 虚拟机
CPU 接近原生(~2-3%损耗) 5-15%损耗
内存 只消耗应用内存 需要 Guest OS 内存
磁盘 IO 接近原生 经过虚拟化层
网络 bridge/nat 有轻微损耗 虚拟网卡有一定损耗

混合场景最佳实践

graph TB
    subgraph 企业生产环境最佳隔离方案
        HW[物理服务器]
        VM1[虚拟机1<br/>强隔离边界]
        VM2[虚拟机2<br/>强隔离边界]

        subgraph VM1 内部
            C1[Docker 容器1]
            C2[Docker 容器2]
        end

        subgraph VM2 内部
            C3[Docker 容器3]
            C4[Docker 容器4]
        end
    end

    HW --> VM1 & VM2
    VM1 --> C1 & C2
    VM2 --> C3 & C4

最佳实践:在虚拟机内运行容器,兼顾安全性和灵活性。

总结

容器的隔离是"进程级 + Namespace"的软隔离,而虚拟机是"硬件级"的硬隔离。理解这个区别能帮你:
1. 正确评估安全风险(容器逃逸是真实存在的)
2. 合理选择部署方案(核心业务可考虑 VM + Container 嵌套)
3. 在面试中展现对 Docker 原理的深度理解


Pipeline 与事务的区别:批量操作的两种姿势

Pipeline 与事务的区别:批量操作的两种姿势

两个容易混淆的概念

很多 Redis 初学者把 Pipeline 和事务搞混,因为它们看起来都在"把多条命令打包一起发"。但实际上,Pipeline 解决的是网络效率问题,事务解决的是原子性和隔离性问题

关键区别一览

对比维度 Pipeline 事务 (MULTI/EXEC)
核心目的 减少网络往返 保证原子性和隔离性
原子性 不保证 保证(但不支持回滚)
隔离性 不保证 保证
执行时机 发送到服务端即执行 入队,EXEC 时执行
错误处理 各命令独立 语法错误全拒绝,运行时错误继续
命令依赖 不能依赖前一条结果 不能依赖前一条结果(未执行)
Redis Cluster 支持 不支持跨 slot
性能优化 显著减少网络开销 主要提供语义保证

Pipeline 的工作模式

# Pipeline:每条命令立即写入缓冲区
# 发送到服务端后,服务端逐条执行并返回
pipe = r.pipeline()
pipe.set('a', 1)      # 先加入缓冲区
pipe.get('a')         # 再加入缓冲区
pipe.incr('b')        # 再加入缓冲区
pipe.execute()        # 一次性发送所有命令到服务端

服务端处理方式:接到命令包后,逐条立即执行,执行一条返回一条结果。

事务的工作模式

# 事务:命令先入队,不执行
pipe = r.pipeline(transaction=True)
# 或
pipe.multi()          # 相当于开启 MULTI
pipe.set('a', 1)      # QUEUED
pipe.get('a')         # QUEUED
pipe.incr('b')        # QUEUED
pipe.execute()        # 一次性执行所有命令

服务端处理方式:命令先进入队列,执行 EXEC 时一次性执行所有命令,期间不会被其他客户端的命令插入。

错误处理对比

Pipeline 错误处理

pipe = r.pipeline()
pipe.set('a', 1)
pipe.lpush('a', 2)    # a 是 string 类型,LPUSH 会失败
pipe.set('b', 2)
pipe.execute()        # 每条命令各自返回结果
# [True, ResponseError, True]  -- SET a 和 SET b 都成功了

Pipeline 中,各条命令独立处理结果,一条失败不影响其他。

事务错误处理

MULTI
SET a 1
LPUSH a 2            -- 运行时错误
SET b 2
EXEC                 -- 返回 [OK, WRONGTYPE, OK]
                     -- SET a  SET b 都成功执行了!

Redis 事务不支持回滚。运行时错误不影响其他命令。只有语法错误(如命令名错误)才会导致整个事务被拒绝。

混合使用:Pipeline + 事务

# 既减少网络往返,又保证隔离性
pipe = r.pipeline(transaction=True)
pipe.watch('balance')           # 乐观锁
pipe.multi()                    # 开启事务
pipe.decrby('balance', 10)
pipe.incrby('other', 10)
result = pipe.execute()         # 使用 Pipeline 机制发送

性能对比

import time

# 10000 条命令
# 普通模式
t1 = time.time()
for i in range(10000):
    r.set(f'k:{i}', i)
print(f"普通: {time.time()-t1:.2f}s")

# 纯 Pipeline
t2 = time.time()
p = r.pipeline(transaction=False)
for i in range(10000):
    p.set(f'p:{i}', i)
p.execute()
print(f"Pipeline: {time.time()-t2:.2f}s")

# 事务 + Pipeline
t3 = time.time()
p = r.pipeline(transaction=True)
for i in range(10000):
    p.set(f't:{i}', i)
p.execute()
print(f"事务+Pipeline: {time.time()-t3:.2f}s")

# 实测结果(内网):
# 普通: 2.85s
# Pipeline: 0.08s
# 事务+Pipeline: 0.09s

Pipeline 和事务在性能上差异不大,因为事务也会利用 Pipeline 的批处理机制。

选择指南

你的需求 推荐方案
只关心速度,不需要原子性 Pipeline
需要多条命令原子执行 Lua 脚本(比事务更灵活)
需要隔离性,逻辑简单 事务 (MULTI/EXEC)
混合需求 Lua 脚本
用到 Redis Cluster Pipeline 或 Lua 脚本

面试要点

  • Pipeline 优化网络,事务保证隔离
  • Pipeline 中的命令立即执行,事务中的命令入队后在 EXEC 时执行
  • Lua 脚本可以替代大部分事务场景,且更灵活
  • Pipeline 可以禁用事务transaction=False)获得更高性能,但在 Cluster 模式下要注意
  • 事务 + Pipeline 可以同时获得隔离性 + 批量发送的好处

Lua 脚本的原子性保证:Redis 如何确保脚本像事务一样执行

Lua 脚本的原子性保证:Redis 如何确保脚本像事务一样执行

什么是原子性

原子性是指一个操作要么全部完成,要么完全不执行,不存在中间状态。对于数据库系统而言,原子性是保证数据一致性的重要基础。

Redis Lua 脚本的原子性原理

Redis 使用单线程事件循环模型处理命令。当执行 Lua 脚本时:

  1. 脚本在 Redis 主线程中直接运行
  2. 在脚本执行完成之前,Redis 不会处理任何新的客户端请求
  3. 也不会处理任何其他命令、定时任务或持久化操作

这意味着 Lua 脚本的整个执行过程是完全串行化的。

时间轴:
客户端A: |--- EVAL 脚本 ---|
客户端B:                    |--- SET a 10 ---|    
                     ^ 客户端B必须等待脚本执行完毕

原子性的具体表现

1. 读-改-写 的原子性

-- 检查余额并扣减,在高并发下也不会发生超卖
local balance = redis.call('GET', KEYS[1])
if tonumber(balance) >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
return 0

如果没有 Lua 脚本,GET 和 DECRBY 之间会有一个时间窗口,其他请求可能在此期间修改数据,导致超卖。

2. 多个 key 操作的原子性

-- 转账操作:扣减 A 账户,增加 B 账户
redis.call('DECRBY', KEYS[1], ARGV[1])
redis.call('INCRBY', KEYS[2], ARGV[1])

这两个操作在脚本中一起执行,不会出现"扣了没加"或"加了没扣"的情况。

原子性的边界情况

运行时错误

Lua 脚本在执行过程中如果发生运行时错误(如对非数字类型进行加减),已经执行的操作不会回滚

redis.call('SET', 'a', 1)
redis.call('INCR', 'b')     -- 如果 b 不是数字类型,这里报错
redis.call('SET', 'c', 3)   -- 不会执行
-- 此时 a 已经被设置为 1,不会回滚

面试考点:Lua 脚本的原子性保证的是"中间不会被其他命令插入",而不是"错误时自动回滚"。这点经常被问到。

超时中断

如果 Lua 脚本执行时间超过 lua-time-limit(默认 5 秒),Redis 会:

  1. 记录日志提示脚本执行过慢
  2. 不会自动终止脚本
  3. 后续客户端可以发送 SCRIPT KILL 命令尝试终止
  4. 如果脚本已经执行了写操作,则 SCRIPT KILL 无效,只能 SHUTDOWN NOSAVE
CONFIG SET lua-time-limit 10000  -- 设置脚本超时限制为 10 秒

与 MULTI/EXEC 事务的原子性对比

特性 MULTI/EXEC事务 Lua脚本
执行隔离 完整隔离 完整隔离
可编程性 仅命令队列 完整的逻辑语言
运行时错误处理 继续执行其他命令 运行时错误中断脚本
回滚 不支持 不支持(但已执行的不回滚)
WATCH 重试 需要 不需要
复杂逻辑 不支持 支持条件/循环

面试常问问题

Q: Lua 脚本能保证 ACID 中的原子性吗?

A: 只能保证 A(原子性)I(隔离性)。A 方面,脚本执行期间不会被中断(不像 MULTI/EXEC 事务中可能发生服务器崩溃导致部分执行),但执行中的错误不会自动回滚。D(持久性)依赖于 Redis 持久化配置。

Q: 为什么 Lua 脚本不需要 WATCH?

A: 因为脚本是在单线程中一次性执行的,整个执行过程中没有其他命令插入的窗口,天然不存在竞态条件。

总结

Lua 脚本的原子性保证源于 Redis 的单线程执行模型。它提供了比 MULTI/EXEC 事务更强的原子性保证(不会被客户端连接中断),但开发者仍需注意运行时错误不回滚的特性。在需要复杂业务逻辑且要求原子操作的场景下,Lua 脚本是首选方案。


Redis 事务与关系型数据库事务的对比:你需要知道的 6 个关键区别

Redis 事务与关系型数据库事务的对比:你需要知道的 6 个关键区别

关系型数据库事务的 ACID 特性

关系型数据库(如 MySQL)的事务遵循 ACID 原则:

  • 原子性(Atomicity):全部成功或全部回滚
  • 一致性(Consistency):事务前后数据完整性一致
  • 隔离性(Isolation):并发事务互不干扰(通过 MVCC 或锁实现)
  • 持久性(Durability):提交后数据持久保存

Redis 事务的核心机制

Redis 事务通过 MULTIEXECDISCARDWATCH 四个命令实现:

MULTI          -- 开启事务,后续命令进入队列
SET key1 val1  -- 入队,不执行
SET key2 val2  -- 入队,不执行
EXEC           -- 一次性执行队列中的所有命令

六大关键区别

1. 原子性:Redis 不支持回滚

关系型 DB:事务中任何一条语句失败,所有修改都会回滚到事务开始前的状态。

Redis:事务中的某条命令执行失败时,其他命令照常执行,不会回滚。Redis 只在语法错误(如命令不存在)时拒绝整个事务,但在运行时错误(如对 string 类型执行 LPUSH)时,错误命令继续执行,不影响其他命令。

MULTI
SET a 1
LPUSH a x     -- 运行时错误:a  string 类型,LPUSH 失败
SET b 2
EXEC          -- SET a 1  SET b 2 都会成功执行

Redis 设计者认为回滚会增加复杂性,而 Redis 的简单内核设计哲学决定不引入回滚。

2. 隔离性:Redis 事务默认是串行化的

关系型 DB:通过隔离级别(Read Uncommitted 到 Serializable)控制并发行为。

Redis:由于 Redis 是单线程处理命令,事务中的命令在 EXEC 之前只是入队,真正执行时是连续的、互不干扰的,不需要传统意义上的隔离级别。其他客户端的命令不会插入到正在执行的事务中间。

3. 持久性:Redis 有更灵活的选择

关系型 DB:通常 WAL(Write-Ahead Logging)保证持久化。

Redis:依赖 RDB 或 AOF 持久化策略。如果 AOF 配置为 always 模式,则每个写入 fsync 同步磁盘,接近关系型 DB 的持久性;但如果配置为 everysec,宕机可能丢失 1 秒数据。

4. 一致性:Redis 依赖开发者

关系型 DB:通过约束、外键、触发器保证数据一致性。

Redis:没有约束机制,一致性完全依赖应用程序逻辑和 Lua 脚本。

5. 事务模式:Redis 不是"立即执行"

关系型 DB:语句发送后立即执行,逐条检查。

Redis:MULTI 之后的命令仅仅进入队列,EXEC 时才统一提交执行。这种设计减少了网络往返,但让报错检查时机延后到 EXEC 时。

6. 锁机制

关系型 DB:行锁、表锁、间隙锁,悲观锁机制完善。

Redis:通过 WATCH 命令实现乐观锁(CAS),没有内置悲观锁。

对比一览表

特性 MySQL/PostgreSQL Redis
原子性 支持回滚 不支持回滚
隔离性 多级隔离 串行化执行
持久性 WAL 保证 依赖 AOF/RDB 配置
一致性 约束保证 无约束,靠代码保证
执行模式 逐条执行 队列批量执行
行锁/表锁等 WATCH 乐观锁
嵌套事务 支持 不支持

面试要点

  • Redis 事务没有回滚机制,这是考点
  • Redis 事务隔离性天然高(单线程执行)
  • 不要用 Redis 事务代替关系型 DB 的事务
  • 在需要强一致性和回滚的场景,应使用关系型数据库
  • Redis 事务适合简单、高性能的批量操作

事务支持回滚吗——Redis 为什么选择不回滚

事务支持回滚吗——Redis 为什么选择不回滚

直接回答

Redis 事务不支持回滚(Rollback)。

这是一个面试高频题——直接问"Redis 事务是否支持回滚",很多人下意识回答"支持",因为其他数据库都支持,但 Redis 确实不支持。

Redis 事务遇到错误时的行为

Redis 事务中有两种错误,处理方式不同:

情况一:执行 EXEC 前发现错误(入队时)

> MULTI
OK
> SET user:1001 "Alice"
QUEUED
> EVAL "return 1+1" 0     不存在的命令
(error) ERR unknown command 'EVAL' with that number of args
> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

结果:整个事务被丢弃,所有命令都不执行

情况二:执行 EXEC 后发生错误(运行时错误)

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key "hello"
QUEUED
127.0.0.1:6379(TX)> LPUSH key "world"
QUEUED
127.0.0.1:6379(TX)> INCR counter
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1

结果:运行错误的命令返回错误,但其他命令成功执行,不回滚
- SET key "hello" → OK ✅
- LPUSH key "world" → Error ❌(key 是 String 不是 List)
- INCR counter → 1 ✅

为什么 Redis 不支持回滚

这是 Redis 作者 Antirez 的设计哲学。他给出的几个理由:

理由一:Redis 命令通常不会失败

Redis 内的命令错误通常是编程错误,而不是运行时错误:

# 这类错误通常是开发者写错了
LPUSH key "value"     如果 key 不是 List 类型  运行时错误
# 但这通常是开发者逻辑错误,不是应该回滚的正常情况

理由二:回滚需要复杂的事务管理器

  • 回滚意味着需要记录"回滚日志"或"undo 日志"
  • 这会增加 Redis 内核的复杂度
  • 会降低 Redis 的核心性能优势

理由三:保持 Redis 的简单高效

# Antirez 的原话翻译:
"如果开发者写了错误的代码导致运行时错误,
那么回滚不会修复这个问题
让事务尽可能简单和高效才是首要目标"

如果确实需要回滚怎么办

方案一:在应用层实现补偿

public class RedisTransactionWithRollback {

    // 记录操作日志,用于人工补偿
    private final List<Runnable> undoLog = new ArrayList<>();

    public void transfer(String from, String to, int amount) {
        try {
            redisTemplate.execute(new SessionCallback<Void>() {
                @Override
                public Void execute(RedisOperations ops) {
                    ops.multi();
                    ops.opsForValue().decrement(from, amount);
                    ops.opsForValue().increment(to, amount);
                    List<Object> results = ops.exec();

                    if (results == null || results.contains(null)) {
                        // 执行失败,记录日志
                        log.error("转账事务执行失败,需要人工处理");
                    }
                    return null;
                }
            });
        } catch (Exception e) {
            // 执行异常,记录补偿日志
            log.error("转账异常,需要补偿: from={}, to={}, amount={}", from, to, amount);
            compensationLog.save(from, to, amount, "PENDING");
        }
    }
}

方案二:使用 Lua 脚本做条件控制

Lua 脚本可以检查条件,条件不满足时不做更新:

-- Lua 脚本:只有在库存充足时才扣减
if redis.call('GET', KEYS[1]) >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    redis.call('INCR', KEYS[2])
    return 1
end
return 0  -- 不满足条件,不做任何事

方案三:结合数据库事务

如果对数据一致性要求非常高,用数据库事务兜底:

@Transactional  // 数据库事务
public void transferMoney(String from, String to, int amount) {
    // Redis 做性能优化
    redisTemplate.opsForValue().decrement(fromKey, amount);
    redisTemplate.opsForValue().increment(toKey, amount);

    // 数据库做最终一致性保证
    accountDao.transfer(from, to, amount);
}

面试回答框架

Q:Redis 事务支持回滚吗?

分三层回答:

  1. 直接回答:Redis 事务不支持回滚
  2. 解释区别:Redis 事务遇到语法错误整批不执行,遇到运行时错误不回滚
  3. 深入原因:这是 Redis 的 设计取舍——保持简单高效,因为 Redis 命令通常不会失败,运行时的错误大多是编程错误

如果面试官继续追问"那你觉得这样合理吗":

  • 合理之处:保持了 Redis 的高性能和简洁性
  • 不合理之处:在复杂业务场景中缺乏容错能力
  • 应对方式:通过应用层补偿或 Lua 脚本替代

Redis 事务:MULTI / EXEC——一次打包,原子执行

Redis 事务:MULTI / EXEC——一次打包,原子执行

Redis 事务是什么

Redis 的事务(Transaction)通过 MULTIEXECDISCARDWATCH 四个命令实现。它的核心机制是:将一组命令打包成一个队列,一次性地、按顺序地执行,中间不会被其他客户端的命令插入

MULTI             开启事务
SET key1 value1   入队
SET key2 value2   入队
GET key1          入队
EXEC              批量执行

Redis 事务与传统数据库事务的差异

这是面试中常被问到的问题。Redis 的事务不是传统的关系型数据库事务

特性 Redis 事务 MySQL/传统事务
ACID 原子性 ✅ 全部执行或都不执行
ACID 一致性 ✅ 保持数据类型约束
ACID 隔离性 ✅ 串行执行,无干扰 多种隔离级别
ACID 持久性 取决于持久化配置
回滚 不支持
乐观锁 ✅ WATCH 命令 MVCC
执行模式 一次性批量执行 逐行逐步

事务流程详解

1. MULTI:开启事务

> MULTI
OK

从此刻开始,后续命令不会立即执行,而是进入一个队列。

2. 命令入队

> SET user:1001 "Alice"
QUEUED
> INCR counter:orders
QUEUED
> EXPIRE session:abc 3600
QUEUED

每个命令都返回 QUEUED,表示已经排入队列。

3. EXEC:执行事务

> EXEC
1) OK                 SET 的结果
2) (integer) 1        INCR 的结果
3) (integer) 1        EXPIRE 的结果

所有命令按入队顺序依次执行,返回所有命令的结果列表。

4. DISCARD:取消事务

> MULTI
OK
> SET user:1001 "Bob"
QUEUED
> DISCARD            清除队列,放弃事务
OK

事务的两种错误情况

情况一:入队时报错(语法错误)

> MULTI
OK
> SET user:1001 "Alice"
QUEUED
> SET user:1002      缺少参数,语法错误
(error) ERR wrong number of arguments for 'SET' command
> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

语法错误会导致 整个事务被丢弃,所有命令都不执行。

情况二:执行时报错(运行时错误)

> MULTI
OK
> SET key "hello"
QUEUED
> LPUSH key "world"      key  String 类型,LPUSH 需要 List 类型
QUEUED
> INCR counter
QUEUED
> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) (integer) 1

关键区别:运行时错误 不会回滚。前一条 SET 成功执行了,第三条 INCR 也成功执行了,只有第二条失败了。其他命令不受影响。

这也是 Redis 事务与传统事务的最大不同:不支持回滚

Java 中使用 Redis 事务

使用 Jedis

Jedis jedis = new Jedis("localhost");
try {
    String result = jedis.watch("stock:1001");  // 乐观锁

    Transaction t = jedis.multi();
    t.decrBy("stock:1001", 1);           // 扣减库存(入队)
    t.incr("order:count");               // 增加订单数(入队)

    List<Object> results = t.exec();     // 批量执行
    if (results == null) {
        // WATCH 检测到 key 被修改,事务执行失败
        throw new OptimisticLockException("库存已被修改");
    }
} finally {
    jedis.close();
}

使用 Spring Data Redis

@Autowired
private RedisTemplate<String, String> redisTemplate;

public List<Object> executeInTransaction(String key1, String key2) {
    return redisTemplate.execute(new SessionCallback<List<Object>>() {
        @Override
        public List<Object> execute(RedisOperations operations) {
            operations.multi();                    // 开启事务
            operations.opsForValue().set(key1, "value1");
            operations.opsForValue().set(key2, "value2");
            operations.opsForValue().get(key1);
            return operations.exec();              // 执行
        }
    });
}

事务的应用场景

适合使用事务的场景

  1. 批量更新多个 key:需要保证多个 key 的更新要么全部成功,要么全部失败
  2. 计数器组合操作:如商品详情页访问统计,需要更新多个计数
  3. 确保操作顺序不被干扰:中间不会有其他命令插入

不适合使用事务的场景

  1. 需要回滚的业务:Redis 不支持回滚,需要回滚请用数据库
  2. 复杂业务逻辑:需要在执行时根据前一步结果决定下一步
  3. 长事务:事务中命令太多,阻塞 Redis 时间太长

面试要点

  • Redis 事务的核心是 将多个命令打包一次性执行,保证中间不被插队
  • 和 MySQL 事务的核心区别:Redis 不支持回滚
  • 记住两种错误处理方式的区别:语法错误→全部回滚,运行时错误→不回滚
  • WATCH 提供了乐观锁机制,与事务搭配可以实现 CAS(Compare And Set)
  • 面试中可以说:"Redis 事务不是传统意义上的事务,它更像是一个命令批处理 + 隔离保证"

集群跨槽事务

集群跨槽事务

问题背景

Redis 事务(MULTI/EXEC)和 Lua 脚本的核心要求是:所有操作的 Key 必须在同一个节点上。在 Redis Cluster 中,这意味着这些 Key 必须属于同一个哈希槽。

然而,实际业务中经常需要原子性地操作多个 Key,而这些 Key 天然分布在不同槽中。这就是"跨槽事务"需要解决的问题。

为什么 Cluster 限制跨槽事务

Redis Cluster 的设计哲学是线性扩展 + 自动分片。如果一个事务涉及多个槽:

MULTI
SET user:1001:name "Alice"      7256(节点 BSET user:1002:name "Bob"        8890(节点 CEXEC

节点 B 执行了第一条,但第二条需要在节点 C 上执行。事务的原子性要求"要么全部执行,要么全部不执行",但两个节点之间没有分布式事务协调机制。因此,Redis Cluster 直接禁止跨槽事务,在执行前检测同一事务中所有 Key 是否都在同一个槽。

Redis Cluster 中事务的支持范围

支持的操作(同一槽内)

// 同一槽:使用 Hash Tag
MULTI
SET {user}:name "Alice"
SET {user}:age 30
EXEC
//  成功执行

不支持的操作(不同槽)

// 不同槽:未使用 Hash Tag
MULTI
SET user:1001:name "Alice"   // 槽 7256
SET user:1002:name "Bob"     // 槽 8890
EXEC
// ❌ 报错:CROSSSLOT Keys in request don't hash to the same slot

解决方案

方案一:Hash Tag

使用 {} 将相关 Key 强制分配到同一个槽:

user:{1001}:name  CRC16("1001") % 16384
user:{1001}:age   CRC16("1001") % 16384   同一槽

优点:简单、原生支持
缺点:如果使用不当,所有 Key 集中到少数槽,可能导致数据倾斜

方案二:应用层分布式事务

使用 TCC(Try-Confirm-Cancel)或 Saga 模式在应用层实现最终一致性:

try:
    节点 B: SET user:1001:locked "1" NX
    节点 C: SET user:1002:locked "1" NX
confirm:
    节点 B: MULTI; SET user:1001:name "Alice"; DEL user:1001:locked; EXEC
    节点 C: MULTI; SET user:1002:name "Bob"; DEL user:1002:locked; EXEC
cancel:
    节点 B: DEL user:1001:locked
    节点 C: DEL user:1002:locked

优点:可以跨节点保证最终一致性
缺点:实现复杂,有性能开销

方案三:使用 Redis 集群代理

代理层(如 Twemproxy、Codis、阿里云 Redis Proxy)在后端将业务拆分为多组操作:

客户端 → [代理层] → 节点 B(执行部分事务)
                 → 节点 C(执行部分操作)

优点:对客户端透明
缺点:代理本身没有分布式事务能力,最终还是需要在代理层实现协调逻辑

方案四:避免跨槽操作(最推荐)

在业务层面进行合理的数据建模:

  • 按业务实体组织 Key:同实体的数据放在同一槽
  • 使用 Hash Tag 精心设计 Key 的分布
  • 能通过单 Key 操作解决的,就不要使用多 Key 事务

Lua 脚本的跨槽限制

Lua 脚本同样受跨槽限制:

// ❌ 失败
EVAL "redis.call('SET', KEYS[1], 'A'); redis.call('SET', KEYS[2], 'B');" 2 user:1001 user:1002
// CROSSSLOT 错误
//  成功:KEYS[1]  KEYS[2] 必须同槽
EVAL "redis.call('SET', KEYS[1], 'A'); redis.call('SET', KEYS[2], 'B');" 2 {user}:1001:name {user}:1001:age

面试要点

  • Redis Cluster 不支持跨槽事务,这是分片架构的固有限制
  • 检测时机:在命令执行前检查 Key 是否在同一槽
  • 主要解决方案:Hash Tag + 合理的数据建模
  • Lua 脚本的同种限制来自相同的原因
  • 跨节点分布式事务可以通过应用层实现(TCC/Saga),但会增加复杂度

总结

跨槽事务的限制是 Redis Cluster 为了实现分布式可扩展性而做出的设计取舍。理解这个限制不是"缺陷",而是分布式系统 CAP 定理在实践中的体现——Redis 选择了 AP(可用性 + 分区容忍性),放弃了跨节点的强一致性。在实际业务中,合理利用 Hash Tag + 良好的数据模型设计,可以最大程度地降低这个限制带来的影响。


脏读 / 不可重复读 / 幻读:三大并发问题的区别与解决方案

脏读 / 不可重复读 / 幻读:三大并发问题的区别与解决方案

一、定义

脏读、不可重复读和幻读是数据库并发事务中出现的三种常见数据一致性问题,不同的事务隔离级别对不同问题的解决程度不同。

二、三大问题详解

1. 脏读(Dirty Read)

定义:一个事务读取了另一个事务未提交的修改。如果该事务回滚,则读取到的数据是无效的。

sequenceDiagram
    participant TA as 事务A(转账)
    participant DB as 数据库
    participant TB as 事务B(查询)

    TA->>DB: UPDATE 余额 +200  1200(未提交)
    TB->>DB: SELECT 余额  1200(脏读!)
    TA->>DB: ROLLBACK 回滚  余额恢复1000
    Note over TB: 基于"余额=1200"的后续操作全是错的
-- 准备数据
INSERT INTO accounts VALUES (1, 'Alice', 1000);

-- 事务 A(转账,未提交)
BEGIN;
UPDATE accounts SET balance = balance + 200 WHERE id = 1;  -- 余额变为 1200

-- 事务 B(脏读)
BEGIN;
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1200(脏数据!)

-- 事务 A 回滚
ROLLBACK;  -- 余额恢复为 1000

危害:读到可能被回滚的中间数据,完全不可信赖。

解决方案:提升至 READ COMMITTED 即可避免。

2. 不可重复读(Non-Repeatable Read)

定义:同一事务内两次读取同一行数据,结果不同(其他事务提交了修改)。

sequenceDiagram
    participant TA as 事务A(查询两次)
    participant DB as 数据库
    participant TB as 事务B(修改)

    TA->>DB: SELECT 余额  1000(第一次)
    TB->>DB: UPDATE 余额=500
    TB->>DB: COMMIT
    TA->>DB: SELECT 余额  500(第二次,不一致!)
-- 事务 A(隔离级别:READ COMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 第一次:1000

-- 事务 B(修改并提交)
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- 事务 A 再次查询
SELECT balance FROM accounts WHERE id = 1;  -- 第二次:500 ❌
COMMIT;

危害:同一事务读到不同值,可能导致业务逻辑前后不一致。

解决方案:提升至 REPEATABLE READ 即可避免。

3. 幻读(Phantom Read)

定义:同一事务内两次执行相同范围查询,结果条数不同(其他事务插入了新记录)。

sequenceDiagram
    participant TA as 事务A
    participant DB as 数据库
    participant TB as 事务B

    TA->>DB: SELECT COUNT(*)  1
    TB->>DB: INSERT 新记录
    TB->>DB: COMMIT
    TA->>DB: SELECT COUNT(*) FOR UPDATE  2条(幻读!)
    Note over TA: 注意:快照读不受影响<br>当前读才会出现幻读
-- 事务 A(隔离级别:REPEATABLE READ)
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'PAID';  -- 第一次:1条

-- 事务 B(插入新记录并提交)
BEGIN;
INSERT INTO orders VALUES (2, 'PAID', '2024-01-02');
COMMIT;

-- 事务 A 快照读(MVCC 保障不变)
SELECT COUNT(*) FROM orders WHERE status = 'PAID';  -- 仍是 1条 ✅

-- 但当前读:
SELECT COUNT(*) FROM orders WHERE status = 'PAID' FOR UPDATE;  -- 可能 2条(幻读)

三、三大问题对比

对比维度 脏读 不可重复读 幻读
读取什么 未提交数据 已提交数据(修改) 新插入数据
涉及操作 UPDATE UPDATE INSERT
影响范围 单行 同一行多次读取 范围查询结果集
严重程度 最严重 较严重 相对轻微
解决最低级别 READ COMMITTED REPEATABLE READ SERIALIZABLE

四、隔离级别对三大问题的解决

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ (MySQL) ✅*
SERIALIZABLE

*MySQL InnoDB 在 RR 级别通过 MVCC + Next-Key Lock 解决了大部分幻读问题

五、MySQL InnoDB 防止幻读的机制

flowchart TD
    A[防止幻读] --> B[快照读 - MVCC]
    A --> C[当前读 - Next-Key Lock]
    B --> D[普通SELECT<br>读取事务开始时快照]
    B --> E[不受其他事务INSERT影响]
    C --> F[SELECT...FOR UPDATE<br>UPDATE/DELETE]
    C --> G[行锁 + 间隙锁<br>阻止在范围内插入]
    G --> H[锁定索引记录之间的间隙]
    H --> I[其他事务无法在间隙插入新记录]

六、面试常见问题

Q1:不可重复读和幻读的区别是什么?
A:不可重复读指同一事务内两次读取同一行数据,值不同(其他事务 UPDATE 了该行)。幻读指同一事务内两次范围查询,结果条数不同(其他事务 INSERT 了新行)。简单说:改行 vs 增行。

Q2:MySQL InnoDB 在 REPEATABLE READ 级别下,如何避免幻读?
A:快照读通过 MVCC 读取事务开始时的快照,不受插入影响;当前读通过 Next-Key Lock 锁定扫描到的行及其间隙,阻止其他事务在范围内插入新记录。

Q3:脏读为什么比不可重复读更严重?
A:脏读读到的是未提交的中间数据,可能被回滚(完全不可信赖);不可重复读读到的是已提交的最终数据(至少是真实发生了的修改)。因此脏读的危害更严重。

Q4:如果应用需要避免幻读,但不想用 SERIALIZABLE,怎么办?
A:使用 REPEATABLE READ 级别即可。在需要避免幻读的语句上显式加锁(SELECT ... FOR UPDATE),利用 MySQL 的间隙锁机制防止幻读。


相关文章事务隔离级别详解 | 事务四大特性 ACID | MySQL 锁机制 | Spring 事务隔离级别


事务四大特性 ACID:原子性 / 一致性 / 隔离性 / 持久性 原理与实现

事务四大特性 ACID:原子性 / 一致性 / 隔离性 / 持久性 原理与实现

一、定义

ACID 是数据库事务正确执行的四个基本特性的缩写。事务是一组不可分割的操作单元,要么全部成功,要么全部失败。ACID 确保了数据库在并发访问和系统故障情况下的数据一致性。

二、四大特性详解

A(Atomicity)——原子性

定义:事务中的所有操作要么全部成功提交,要么全部失败回滚,不存在中间状态。

实现原理:通过 undo log(回滚日志)实现。

flowchart LR
    A[事务开始] --> B[修改数据]
    B --> C[修改前将旧值<br>写入undo log]
    C --> D{执行成功?}
    D -->|| E[提交事务]
    D -->|| F[读取undo log<br>恢复旧值]
    F --> G[回滚完成]
-- 示例:转账操作
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 如果第二步失败,第一步的操作通过 undo log 回滚
-- undo log 还用于 MVCC 实现一致性非锁定读

C(Consistency)——一致性

定义:事务执行前后,数据库必须保持一致性状态,不能破坏完整性约束。

实现原理:由原子性、隔离性、持久性共同保证,加上应用层的业务规则和数据库的约束条件。

flowchart TD
    A[一致性] --> B[由三个特性共同保证]
    B --> C[原子性 - 全部成功或全部回滚]
    B --> D[隔离性 - 并发不干扰]
    B --> E[持久性 - 提交不丢失]
    C --> F[事务前后<br>总金额不变等业务规则]
    D --> F
    E --> F
-- 一致性约束:转账前后总余额不变
-- 事务前:A=1000, B=1000, 总和=2000
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 1100 WHERE id = 2;
COMMIT;
-- 事务后:A=900, B=1100, 总和=2000 ✅

I(Isolation)——隔离性

定义:多个事务并发执行时,一个事务的执行不应被其他事务干扰。

实现原理:通过锁机制(行锁、表锁、间隙锁等)和 MVCC(多版本并发控制)实现。

flowchart LR
    A[隔离性实现] --> B[锁机制]
    A --> C[MVCC]
    B --> D[共享锁-S锁]
    B --> E[排他锁-X锁]
    B --> F[间隙锁-Gap Lock]
    B --> G[意向锁]
    C --> H[多版本并发控制]
    C --> I[基于undo log
的快照读
] H --> J[四种隔离级别]
-- 隔离级别(由弱到强):
-- READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE

D(Durability)——持久性

定义:一旦事务提交成功,对数据的修改就是永久性的,即使系统崩溃也不会丢失。

实现原理:通过 redo log(重做日志)实现,采用 WAL(Write-Ahead Logging) 机制。

sequenceDiagram
    participant App as 应用
    participant Buffer as redo log buffer
    participant File as redo log file (磁盘)
    participant DB as 数据文件

    App->>Buffer: 1. 写入 redo log buffer
    App->>Buffer: 2. COMMIT 命令
    Buffer->>File: 3. fsync 刷盘
    File->>App: 4. 返回 COMMIT 成功
    Note over Buffer,DB: 5. 后台异步刷 dirty page
    Buffer->>DB: 6. 脏页写入数据文件
-- 持久化参数
-- innodb_flush_log_at_trx_commit:
-- 0: 每秒刷一次(性能最高,最多丢 1 秒数据)
-- 1: 每次提交都 fsync(最安全,默认值)
-- 2: 每次提交写入 OS cache,每秒刷盘

三、ACID 的实现依赖关系总结表

特性 实现机制 性能影响
原子性 undo log 额外写入开销
隔离性 锁 + MVCC 锁竞争减少并发
持久性 redo log + WAL 每次提交需 fsync
一致性 以上三者综合 整体性能约束

四、redo log vs undo log 对比

对比维度 redo log undo log
记录内容 修改后的值 修改前的值
主要作用 保证持久性(崩溃恢复) 保证原子性(回滚)+ MVCC
写入时机 事务提交时 事务执行中(每修改前)
文件类型 物理日志(页级别) 逻辑日志(行级别)

五、面试常见问题

Q1:InnoDB 如何保证原子性?
A:通过 undo log。事务开启时,修改前的数据写入 undo log,回滚时读取 undo log 恢复数据。undo log 还用于 MVCC 实现一致性非锁定读。

Q2:redo log 和 undo log 的区别?
A:redo log 记录"修改后的值",用于保证持久性(崩溃恢复重放已提交事务)。undo log 记录"修改前的值",用于保证原子性(回滚未完成事务)和 MVCC。

Q3:MySQL 持久化参数 innodb_flush_log_at_trx_commit 的含义?
A:0 - 每秒刷一次(最高性能,最多丢 1 秒数据);1 - 每次事务提交都刷盘(最安全,默认值);2 - 每次提交写入 OS cache,每秒刷盘。

Q4:事务隔离级别为什么不能默认用串行化?
A:串行化通过强制事务串行执行来保证最高隔离性,但严重降低并发性能。MySQL 默认使用可重复读(RR),在大多数场景下已足够满足一致性要求,同时保持较好的并发性能。


相关文章事务隔离级别详解 | 脏读/不可重复读/幻读 | MySQL 锁机制 | Spring 事务隔离级别

© 版权声明
THE END
喜欢就支持一下吧
点赞14 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容