MySQL MVCC与锁机制

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

悲观锁与 SELECT FOR UPDATE

悲观锁与 SELECT FOR UPDATE

概述

悲观锁(Pessimistic Locking)是一种保守的并发控制策略,它假设数据在操作过程中很可能会被其他事务修改,因此在操作数据之前就先加锁,确保自己独占访问权。在 MySQL 中,悲观锁主要通过 SELECT FOR UPDATE 语句实现。

核心思想

乐观锁:贴个标签(版本号),走的时候检查
悲观锁:直接锁门,别人进不来

SELECT FOR UPDATE 语法

基本语法

-- MySQL 5.x 语法
SELECT ... FOR UPDATE;                    -- 加排他锁(X 锁)

-- MySQL 8.0 新增语法
SELECT ... FOR SHARE;                     -- 加共享锁(等同于 LOCK IN SHARE MODE)
SELECT ... FOR UPDATE NOWAIT;            -- 不等待,立即返回错误
SELECT ... FOR UPDATE SKIP LOCKED;       -- 跳过被锁的行,只处理未锁定的行
SELECT ... FOR UPDATE WAIT 5;            -- 等待 5 秒(需在 MySQL 8.0 + 某些存储引擎中)

示例

-- 给 id=1 的账户加排他锁
SELECT * FROM account WHERE id = 1 FOR UPDATE;

-- 这时其他事务:
-- ❌ 不能 SELECT FOR UPDATE id=1
-- ❌ 不能 UPDATE/DELETE id=1
-- ✅ 可以普通 SELECT(快照读,不加锁)

典型应用场景

场景 1:库存扣减

-- 电商扣库存(防止超卖)
BEGIN;

-- 1. 查到库存并锁定该行
SELECT stock FROM inventory WHERE product_id = 100 FOR UPDATE;
-- → stock = 5

-- 2. 检查库存
-- 5 > 0,可以扣减

-- 3. 扣减库存
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;

COMMIT;
-- 事务提交后才释放锁,其他事务才能继续操作

场景 2:转账

-- 转账操作
BEGIN;

-- 1. 同时锁定转出和转入账户
SELECT balance FROM account WHERE id = 1 FOR UPDATE;  -- 锁转出账户
SELECT balance FROM account WHERE id = 2 FOR UPDATE;  -- 锁转入账户

-- 2. 检查余额
-- 余额足够

-- 3. 更新
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;

COMMIT;

不同隔离级别下的行为

REPEATABLE READ(默认)

-- 在 RR 下,FOR UPDATE 使用 Next-Key Lock
SELECT * FROM order WHERE amount > 100 FOR UPDATE;
-- 锁住 amount > 100 的所有记录及其间隙
-- 其他事务无法在间隙中插入新记录

READ COMMITTED

-- 在 RC 下,FOR UPDATE 使用 Record Lock
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT * FROM order WHERE amount > 100 FOR UPDATE;
-- 只锁住满足条件的记录
-- 间隙不锁,其他事务可以插入新记录

MySQL 8.0 新特性

NOWAIT

-- 如果锁被占用,立即返回错误,不等待
SELECT * FROM account WHERE id = 1 FOR UPDATE NOWAIT;
-- 行未被锁定 → 正常返回
-- 行被锁定 → 立即报错,不阻塞

适用场景:高并发场景下避免排队等待。

SKIP LOCKED

-- 跳过被锁定的行,只返回未锁定的行
SELECT * FROM order WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED;
-- 假设 id=1 的行被其他事务锁定
-- 本查询返回 id=2,3,5... 等未锁定的行

适用场景:任务队列、消息队列——多个消费者各自取走未被处理的任务。

乐观锁 vs 悲观锁

对比维度 悲观锁(FOR UPDATE) 乐观锁(版本号)
加锁时机 操作前 操作后(提交时检查)
冲突处理 阻塞等待 重试
适用场景 写冲突多 写冲突少
性能 锁竞争加剧时下降 低冲突时优,高冲突时大量重试
死锁风险
数据库实现 原生支持 应用层实现

注意事项和陷阱

1. 必须配合事务

-- ❌ 错误:不开启事务,FOR UPDATE 的锁立即释放
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 锁只在语句执行期间存在

-- ✅ 正确:开启事务
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 事务提交前锁一直持有
COMMIT;

2. 无索引陷阱

-- ❌ name 列没有索引
SELECT * FROM user WHERE name = 'Alice' FOR UPDATE;
-- InnoDB 会锁全表(聚簇索引所有行)

-- ✅ 加上索引
CREATE INDEX idx_name ON user(name);
SELECT * FROM user WHERE name = 'Alice' FOR UPDATE;
-- 只锁 Alice 对应的行

3. 死锁风险

-- 两个事务互相等待 → 死锁
-- 解决方法:固定加锁顺序(见上一篇文章)

4. 锁等待超时

-- 设置锁等待超时时间(默认 50 秒)
SET innodb_lock_wait_timeout = 5;

面试要点

  1. 定义:悲观锁是通过数据库锁机制实现的并发控制,先加锁再操作,防止并发冲突
  2. 核心 SQLSELECT ... FOR UPDATE(X 锁)、SELECT ... FOR SHARE(S 锁)
  3. 新特性:NOWAIT、SKIP LOCKED 用于高并发场景避免等待
  4. 优缺点:保证数据一致性,但降低并发性能,有死锁风险
  5. 与乐观锁的对比:悲观锁写入强一致但并发差,乐观锁并发好但需处理冲突

乐观锁的实现方式

乐观锁的实现方式

概述

乐观锁(Optimistic Locking)是一种并发控制策略,它假设数据在大多数情况下不会发生冲突,因此不加锁,只在更新时检查冲突。乐观锁在”读多写少”的场景下性能优异,因为它避免了锁的开销和阻塞。

与悲观锁不同,乐观锁不是数据库原生的锁机制,而是基于应用程序层面的逻辑实现。

核心思想

悲观锁:先加锁再操作 → "防止别人改"
乐观锁:直接操作,提交时检查 → "你改我就放弃/重试"

实现方式一:版本号(Version)

原理

在表中增加一个 version 字段,每次更新时 version 加 1,更新时检查 version 是否匹配。

表结构

CREATE TABLE account (
    id INT PRIMARY KEY,
    balance DECIMAL(10,2),
    version INT DEFAULT 0  -- 版本号
);

工作流程

-- 1. 读取数据,获取 version
SELECT id, balance, version FROM account WHERE id = 1;
-- → id=1, balance=100, version=5

-- 2. 业务计算
-- 新余额 = 100 - 20 = 80

-- 3. 更新时检查 version
UPDATE account 
SET balance = 80, version = version + 1
WHERE id = 1 AND version = 5;  -- ⚠️ 关键:version 必须匹配
-- 
-- ┌─ 如果成功(影响行数 = 1)→ 更新成功
-- └─ 如果失败(影响行数 = 0)→ 数据已被其他事务修改,重试或放弃

完整示例

# Python 乐观锁示例
def update_balance(account_id, amount, max_retries=3):
    for retry in range(max_retries):
        # 1. 读取数据
        cursor.execute(
            "SELECT id, balance, version FROM account WHERE id = %s",
            (account_id,)
        )
        row = cursor.fetchone()
        current_balance = row['balance']
        current_version = row['version']

        new_balance = current_balance - amount

        # 2. 尝试更新(带 version 条件)
        cursor.execute(
            "UPDATE account SET balance = %s, version = version + 1 "
            "WHERE id = %s AND version = %s",
            (new_balance, account_id, current_version)
        )

        # 3. 检查是否更新成功
        if cursor.rowcount > 0:
            connection.commit()
            return True  # 更新成功
        else:
            # 版本冲突,重试
            connection.rollback()
            if retry == max_retries - 1:
                raise Exception("更新失败,重试次数用完")

实现方式二:时间戳(Timestamp)

原理

类似于版本号,但使用时间戳字段来判断数据是否被修改过。

CREATE TABLE article (
    id INT PRIMARY KEY,
    title VARCHAR(200),
    content TEXT,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

更新检查

-- 1. 读取
SELECT id, title, content, updated_at FROM article WHERE id = 1;
-- → title='MySQL入门', updated_at='2024-01-01 10:00:00'

-- 2. 编辑、更新
UPDATE article 
SET title = 'MySQL进阶', content = '新内容', updated_at = NOW()
WHERE id = 1 AND updated_at = '2024-01-01 10:00:00';
-- 如果其他事务先更新了,updated_at 不一样,本更新会失败

实现方式三:条件字段(CAS)

原理

更新时带上期望的旧值作为条件,利用 WHERE 子句天然实现 CAS(Compare And Swap)。

-- 库存扣减(不超卖)
UPDATE inventory 
SET stock = stock - 1 
WHERE id = 1 AND stock >= 1;
-- 如果 stock = 0,影响行数为 0,说明扣减失败

-- 余额扣减
UPDATE account 
SET balance = balance - 100 
WHERE id = 1 AND balance >= 100;
-- 余额不足时更新失败

无需额外的字段

这是 CAS 方式的优点,不需要 version 或时间戳列。

局限

只能检查单个列的简单条件,不适合多个字段同时变化的场景。

三种实现方式对比

方式 优点 缺点 适用场景
版本号 通用、可靠 需要额外字段、需要手动管理 大多数业务场景
时间戳 无需额外维护逻辑 时间精度问题(毫秒级冲突) 更新时间有业务意义
条件字段(CAS) 无额外字段、简单 只能检查简单条件 库存、余额等数值场景

乐观锁的适用场景

✅ 适合使用

  • 读多写少:大部分读操作,偶尔有写冲突
  • 冲突概率低:数据很少被并发修改
  • 可接受重试:业务允许失败后重试
  • 如:CMS 文章编辑、用户资料修改、配置管理

❌ 不适合使用

  • 写冲突激烈:大量并发写同一个数据(→ 大量重试,性能差)
  • 不允许失败:扣库存、支付等必须成功的操作
  • 长事务:冲突窗口大

面试要点

  1. 乐观锁的本质:不加锁,提交时检查冲突
  2. 三种实现:版本号(最推荐)、时间戳、条件字段
  3. 与悲观锁对比:乐观锁适合读多写少,悲观锁适合写冲突多
  4. 经典问题:乐观锁怎么实现?→ 立即回答”版本号 CAS”,并给出 SQL 示例
  5. 重试必须:使用乐观锁时应用层必须实现重试逻辑

如何避免和减少死锁

如何避免和减少死锁

概述

死锁是数据库并发控制中不可避免的风险,但通过合理的设计和编码规范,可以大幅降低死锁发生的概率。掌握避免死锁的策略,是每个后端工程师的重要技能。

死锁的四个必要条件

回顾死锁必须同时满足的四个条件:

  1. 互斥:资源每次只能被一个事务使用
  2. 持有并等待:事务持有资源的同时等待其他资源
  3. 不可剥夺:资源只能由持有者主动释放
  4. 循环等待:事务之间形成环形等待链

打破任何一个条件,就能避免死锁。

策略一:固定访问顺序

原理

打破”循环等待”条件。如果所有事务按相同的顺序访问资源,就不会形成环路。

反面示例

-- ❌ 事务 A:先改 id=1,再改 id=2
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- ❌ 事务 B:先改 id=2,再改 id=1
BEGIN;
UPDATE account SET balance = balance - 50 WHERE id = 2;
UPDATE account SET balance = balance + 50 WHERE id = 1;
COMMIT;
-- ⚠️ 锁获取顺序相反,容易死锁

正确做法

-- ✅ 约定:所有事务按 ID 升序获取锁
-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 事务 B(也按升序)
BEGIN;
UPDATE account SET balance = balance + 50 WHERE id = 1;
UPDATE account SET balance = balance - 50 WHERE id = 2;
COMMIT;
-- ✅ 总是先锁 id=1,再锁 id=2,不会形成环路

代码实现

// Java 示例:按升序锁定账户
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 按 ID 排序,确保加锁顺序一致
    Long first = Math.min(fromId, toId);
    Long second = Math.max(fromId, toId);

    accountDao.update(first, -amount);   // 先锁小的
    accountDao.update(second, +amount);  // 再锁大的
}

策略二:减少锁持有时间

原理

打破”持有并等待”条件。让事务尽快释放锁,减少与其他事务产生等待的机会。

做法

-- ❌ 错误:事务中混入非数据库操作
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
// ... 调用 HTTP API 获取外部信息(耗时很长!)
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- ✅ 正确:先准备好所有数据再开启事务
// 先调用外部 API(事务外)
externalData = httpClient.getExternalData();

BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;

策略三:使用较低的隔离级别

原理

降低隔离级别可以减少锁的持有范围,从而减少锁冲突。

-- REPEATABLE READ(默认):当前读使用 Next-Key Lock,锁范围大
-- READ COMMITTED:当前读使用 Record Lock,锁范围小

-- 如果业务允许,使用 READ COMMITTED
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

适用场景

  • 不需要间隙锁保护的场景
  • 高并发、对一点数据不一致不敏感的系统
  • 如:日志系统、点赞计数、浏览记录等

策略四:缩小事务范围

原理

事务越小,持有锁的时间越短,锁冲突的概率越低。

-- ❌ 错误:一个大事务包含了所有操作
BEGIN;
INSERT INTO order ...;
INSERT INTO order_item ...;   -- 多行
UPDATE inventory ...;          -- 多个 SKU
UPDATE user_coupon ...;
UPDATE account ...;
COMMIT;

-- ✅ 正确:分解为多个小事务
BEGIN;
INSERT INTO order ...;          -- 小事务
INSERT INTO order_item ...;
COMMIT;

BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE id = 123;  -- 小事务
COMMIT;

BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 小事务
COMMIT;

策略五:使用索引减少锁范围

原理

没有索引时,InnoDB 会锁全表。通过创建合适的索引,可以精确锁定需要的记录。

-- ❌ 没有索引:会扫描全表,所有行都加锁
SELECT * FROM user WHERE name = 'Alice' FOR UPDATE;

-- ✅ 有索引:只锁住匹配的行
CREATE INDEX idx_name ON user(name);
SELECT * FROM user WHERE name = 'Alice' FOR UPDATE;

策略六:使用等值查询避免间隙锁

原理

在 REPEATABLE READ 级别下,范围查询会使用间隙锁,增加死锁风险。可以通过等值查询缩小锁定范围。

-- ❌ 范围查询:使用间隙锁,锁定的范围大
SELECT * FROM order WHERE amount BETWEEN 100 AND 200 FOR UPDATE;

-- ✅ 等值查询+单独处理:逐条锁定,范围小
SELECT * FROM order WHERE amount = 100 FOR UPDATE;

策略七:合理设置超时

即使做了各种预防,死锁仍可能发生。需要设置合理的超时机制作为”最后防线”:

-- 锁等待超时(默认 50 秒)
SET innodb_lock_wait_timeout = 5;  -- 降低到 5 秒

-- 应用层重试(见上一篇文章的代码示例)

最佳实践总结

策略 解释 优先级
固定访问顺序 按相同顺序访问资源 ⭐⭐⭐ 最重要
缩小事务范围 事务越小,死锁概率越低 ⭐⭐⭐
减少锁持有时间 事务中不要做非数据库操作 ⭐⭐⭐
使用索引 索引缩小锁范围 ⭐⭐
降低隔离级别 READ COMMITTED 替代 RR ⭐⭐
应用层重试 死锁发生后自动重试 ⭐⭐

面试要点

  1. 核心原则:打破死锁四个条件中的任意一个
  2. 最有效的方法:固定资源访问顺序 + 缩小事务范围
  3. 经典反问:面试官问”如何避免死锁”时,从事务编码、索引设计、应用重试三个层面回答
  4. 补充:强调死锁无法完全避免,应用层必须有重试机制

InnoDB 如何检测和解决死锁

InnoDB 如何检测和解决死锁

概述

死锁(Deadlock)是两个或多个事务互相持有对方需要的锁资源,导致彼此永久阻塞的状态。InnoDB 提供了自动的死锁检测机制和解决策略,能够在大多数场景下自动处理死锁,不需要人工干预。

死锁的产生

经典示例

假设 account 表中有 id=1 和 id=2 两条记录:

事务 A                     事务 B
BEGIN;                       BEGIN;

UPDATE account SET balance   UPDATE account SET balance
= balance - 100              = balance - 50
WHERE id = 1;                WHERE id = 2;
-- 获得 id=1  Record Lock  -- 获得 id=2  Record Lock

UPDATE account SET balance   UPDATE account SET balance
= balance + 100              = balance + 50
WHERE id = 2;                WHERE id = 1;
-- 等待事务 B 释放 id=2      -- 等待事务 A 释放 id=1

         ⚠️ 互相等待  死锁!

InnoDB 的死锁检测机制

1. 自动检测:等待图(Wait-For Graph)

InnoDB 使用等待图(Wait-For Graph,WFG)算法来检测死锁:

等待图结构:
- 节点:每个事务
- 边:事务 T1 等待事务 T2 释放的锁资源

检测算法:
- 在等待图中查找"环"
- 如果存在环 → 发生死锁

检测过程

-- 当 InnoDB 检测到事务等待锁时
-- 它会构建当前的等待图:
-- T1 → 等待锁资源 R2 → T2
-- T2 → 等待锁资源 R1 → T1
-- 发现环:T1 → T2 → T1
-- → 判断存在死锁

2. 检测触发时机

InnoDB 在以下情况触发死锁检测:
– 每次事务请求锁并且需要等待时
– 默认情况下,InnoDB 每当事务被锁阻塞时就会检查

3. 检测开关

-- 查看死锁检测是否开启(默认 ON)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
-- → ON

-- 关闭死锁检测(高并发场景可能用于提升性能,但风险极大)
SET GLOBAL innodb_deadlock_detect = OFF;

⚠️ 关闭死锁检测后,需要使用 innodb_lock_wait_timeout 来避免无限等待。

InnoDB 的死锁解决策略

1. 选择”牺牲品”

当检测到死锁时,InnoDB 会选择一个事务作为”牺牲品”来回滚,从而打破死锁:

-- 选择规则:
-- 1. 选择"回滚代价最小"的事务
-- 2. 代价计算基于事务修改的行数、持有的锁数量等

-- 被选中的事务会收到错误:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

2. 回滚范围

  • 不一定是整个事务:如果事务使用了 SAVEPOINT,InnoDB 可以只回滚到某个 SAVEPOINT
  • 但通常是整个回滚:大多数应用没有设置 SAVEPOINT,所以会回滚整个事务

3. 另一个事务正常执行

被选中的事务回滚后,释放其持有的所有锁。另一个事务获得需要的锁,继续执行。

实际处理示例

-- 事务 A(被选为牺牲品)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 获得锁
-- 事务 B 开始执行
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 等待事务 B 的锁
-- InnoDB 检测到死锁,选择回滚事务 A

-- 事务 A 收到:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

-- 事务 A 自动回滚,释放 id=1 的锁
-- 事务 B 获得 id=1 的锁,继续执行

应用层处理

死锁发生时,应用程序应该做好重试处理:

// Java 示例:死锁重试
int retryCount = 0;
int maxRetries = 3;

while (retryCount < maxRetries) {
    try {
        // 执行事务操作
        transferMoney(fromAccount, toAccount, amount);
        break;  // 成功,跳出循环
    } catch (DeadlockLoserDataAccessException e) {
        retryCount++;
        if (retryCount == maxRetries) {
            throw e;  // 重试次数耗尽,抛出异常
        }
        Thread.sleep(100);  // 等待一段时间后重试
    }
}
# Python 示例
import time
from pymysql.err import OperationalError

for retry in range(3):
    try:
        with connection.cursor() as cursor:
            cursor.execute("BEGIN")
            cursor.execute("UPDATE account SET balance = balance - 100 WHERE id = 1")
            cursor.execute("UPDATE account SET balance = balance + 100 WHERE id = 2")
            connection.commit()
        break
    except OperationalError as e:
        if e.args[0] == 1213:  # Deadlock error code
            if retry == 2:
                raise
            time.sleep(0.1 * (retry + 1))
            connection.rollback()
        else:
            raise

查看死锁信息

1. 查看最近一次死锁

SHOW ENGINE INNODB STATUS\G
-- 在输出中查找 LATEST DETECTED DEADLOCK 部分

2. 查看死锁记录(MySQL 8.0+)

-- Performance Schema 中的死锁记录
SELECT * FROM performance_schema.events_transactions_current;

面试要点

  1. 检测机制:等待图算法(Wait-For Graph),寻找环
  2. 解决策略:选择代价最小的事务回滚
  3. 错误码:1213(40001),应用层应做好重试
  4. 配置项innodb_deadlock_detect 开关
  5. 与锁等待超时的区别:死锁检测是主动发现并解决;锁等待超时是被动等待超时

自增锁机制详解

自增锁机制详解

概述

自增锁(AUTO-INC Lock)是 InnoDB 为了处理自增列(AUTO_INCREMENT)的并发插入而设计的一种特殊表级锁。它确保多个事务并发插入时,自增 ID 分配的唯一性和连续性。

自增锁的历史

MySQL 5.1 之前

自增锁是表级锁。每个 INSERT 操作在获取自增值之前,都需要获取该表上的自增锁,插入完成后立即释放(注意:不是事务提交时释放)。

问题:高并发插入时,自增锁成为瓶颈。

MySQL 5.1+(innodb_autoinc_lock_mode 引入)

MySQL 5.1 引入了 innodb_autoinc_lock_mode 参数,提供了三种锁模式:

模式 说明
0 traditional 传统模式,始终使用表级自增锁
1 consecutive 连续模式,对批量插入使用表锁,对单行插入使用轻量互斥量(默认值)
2 interleaved 交错模式,始终使用轻量互斥量

MySQL 8.0 的变革

MySQL 8.0 中 innodb_autoinc_lock_mode默认值仍然是 1(consecutive),但自增 ID 的持久化机制发生了重要变化。

MySQL 8.0 之前

自增计数器保存在内存中:

MySQL 重启后,自增 ID 的恢复方式:
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;

MySQL 8.0

自增计数器的变化被持久化到 redo log,每次修改都会写入磁盘:

  • 不需要在重启时重新计算 MAX(auto_inc_col)
  • 自增值更可靠、恢复更快
  • 但每次自增变化都要写 redo log,产生额外开销

三种锁模式的详细分析

模式 0:traditional(传统模式)

INSERT INTO users(name) VALUES('Tom');
-- 会话层级:获取 AUTO-INC 表锁 → 分配 ID → 释放 AUTO-INC 表锁
-- 对于所有 INSERT 都使用表级锁

性能最差,在 MySQL 8.0 中不建议使用。

模式 1:consecutive(连续模式,默认)

-- 简答的 INSERT(行数可预知)
INSERT INTO users(name) VALUES('Tom');
INSERT INTO users(name) VALUES('Jerry'), ('Bob');
-- 使用轻量互斥量(mutex),性能很好

-- 批量插入(行数不可预知)
INSERT INTO users(name) SELECT name FROM old_users;
-- 使用表级自增锁,保证连续 ID

为什么对 INSERT ... SELECT 要使用表锁?
– 这种插入的行数在执行前无法确定
– 如果不能一次性分配连续的 ID 范围,可能导致 ID 分配错误

模式 2:interleaved(交错模式)

INSERT INTO users(name) VALUES('Tom');
INSERT INTO users(name) SELECT name FROM old_users;
-- 全部使用轻量互斥量
-- 不保证 ID 连续性

优点:并发性能最高
缺点
– 批量插入的 ID 可能和并发插入的 ID 交错
– 基于语句的复制(statement-based replication)可能不兼容

自增锁的加锁与释放时机

加锁时机

在所有 INSERT 操作获取自增值之前。

释放时机(重要!)

  • 对于表级自增锁:获取自增值后立即释放(不是事务提交时)
  • 对于互斥量:获取自增值后立即释放
BEGIN;
INSERT INTO users(name) VALUES('Tom');
  -- 1. 获取自增锁(或互斥量)
  -- 2. 分配 ID=100
  -- 3. 释放自增锁  此时锁已经释放了
  -- 4. 插入数据行(持有行锁)
COMMIT;

这个设计很重要:自增锁不参与事务锁,不会因为长事务而被其他事务等待。

自增锁带来的问题

1. ID 不连续

自增 ID 不连续是正常现象,原因包括:

-- 原因 1:事务回滚
BEGIN;
INSERT INTO users(name) VALUES('Tom');  -- 分配 ID=100
ROLLBACK;  -- 行被回滚,但 ID=100 已被分配
INSERT INTO users(name) VALUES('Jerry');  -- ID=101,而不是 100

-- 原因 2:批量插入预留过多
INSERT INTO users(name) VALUES('A'),('B'),('C');  -- 预留 3 个 ID
-- 实际只用了 3 个,但如果预留发生在并发环境下,可能有些 ID 被浪费

-- 原因 3:MySQL 重启
-- MySQL 重启后,自增起始值可能改变

2. 并发插入的间隙

在高并发下如果同时进行批量插入和普通插入:

事务 A:INSERT INTO users(name) SELECT name FROM big_table;  -- 预留 100 个 ID
事务 B:INSERT INTO users(name) VALUES('New');  -- 得到 ID=101,而不是在 A 预留的 ID 之间

3. 复制环境的 ID 不一致

在 statement-based 复制中,如果使用 interleaved 模式:

-- 主库
INSERT INTO t VALUES(NULL, 'a');  -- ID=1
INSERT INTO t VALUES(NULL, 'b');  -- ID=2
-- 由于交错,可能 ID 顺序不同

建议:使用 binlog_format=ROW 避免此类问题。

如何查看和设置自增锁模式

-- 查看当前模式
SELECT @@innodb_autoinc_lock_mode;
-- MySQL 8.0 默认:1(consecutive)

-- 设置模式(修改配置文件 my.cnf)
-- [mysqld]
-- innodb_autoinc_lock_mode = 2

-- 查看当前自增值
SELECT AUTO_INCREMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'dbname' AND TABLE_NAME = 'users';

-- 修改自增值
ALTER TABLE users AUTO_INCREMENT = 1000;

最佳实践

  1. 保持默认的 consecutive 模式(innodb_autoinc_lock_mode=1)
  2. 不要依赖自增 ID 的连续性
  3. INSERT ... SELECT 批量插入时,注意自增锁会退化为表锁
  4. 考虑使用 binlog_format=ROW 避免复制问题
  5. 如果对 ID 顺序有严格要求,考虑使用其他非自增 ID 方案

面试要点

  1. 自增锁的模式:traditional(0)、consecutive(1,默认)、interleaved(2)
  2. 自增锁是表级锁,但立即释放——不参与事务锁
  3. 自增 ID 不保证连续性,这是设计如此
  4. 批量插入(INSERT ... SELECT)在默认模式下会退化为表锁
  5. MySQL 8.0 将自增计数器持久化到 redo log,不再需要重启后计算 MAX(id)

死锁的四个必要条件

死锁的四个必要条件

概述

死锁(Deadlock)是指两个或多个事务互相持有对方需要的资源,互相等待对方释放,导致所有事务都无法继续执行的状态。死锁的产生必须同时满足四个必要条件,了解这些条件是预防和排查死锁的基础。

四个必要条件

条件一:互斥(Mutual Exclusion)

资源在同一时间只能被一个事务占用。

在 MySQL 中:
– 排他锁(X 锁)同一时间只能被一个事务持有
– 共享锁(S 锁)可以被多个事务持有,但 S 锁与 X 锁互斥

-- 事务 A 持有 id=1 的 X 锁
UPDATE users SET name = 'A' WHERE id = 1;

-- 事务 B 不能同时持有 id=1 的 X 锁
UPDATE users SET name = 'B' WHERE id = 1;  -- 等待 A

这是死锁的前提条件——如果资源可以共享使用,就不会发生死锁。数据库中的行锁、间隙锁都是互斥资源。

条件二:持有并等待(Hold and Wait)

事务已经持有了某些资源,同时又在等待获取其他资源。

-- 事务 A:持有 id=1 的锁,等待 id=2 的锁
UPDATE users SET balance = 100 WHERE id = 1;
UPDATE users SET balance = 200 WHERE id = 2;  -- 等待 B 释放

-- 事务 B:持有 id=2 的锁,等待 id=1 的锁
UPDATE users SET balance = 300 WHERE id = 2;
UPDATE users SET balance = 400 WHERE id = 1;  -- 等待 A 释放

这是死锁出现的必要阶段——如果事务不等待(直接报错),或者不持有其他锁,就不会死锁。

条件三:不可剥夺(No Preemption)

已获得的资源在未使用完之前不能强制剥夺,只能由持有者主动释放。

在 InnoDB 中:
– 锁只能通过 COMMIT 或 ROLLBACK 释放
– 其他事务不能强制让持有者释放锁
– 除非检测到死锁后,MySQL 选择回滚某个事务(这是例外)

-- 事务 A 持有 id=1 的锁
-- 无论事务 B 愿意付出什么代价,都不能让 A 交出锁
-- A 只能在 COMMIT 或 ROLLBACK 后释放

这意味着竞争的事务必须等待,直到锁被主动释放。

条件四:循环等待(Circular Wait)

存在一个事务等待环,每个事务都在等待下一个事务占用的资源。

事务 A → 等待 → 事务 B → 等待 → 事务 C → 等待 → 事务 A
             ↑                               |
             └───────────────────────────────┘
-- 经典的两事务循环等待
-- 事务 A 持有 id=1,等待 id=2
-- 事务 B 持有 id=2,等待 id=1

-- 图表示:
-- A → (等待 id=2) → B
-- B → (等待 id=1) → A
-- A ↔ B 形成循环

循环等待不一定只有两个事务:

三个事务的循环等待:
事务 A:持有 id=1,等待 id=2
事务 B:持有 id=2,等待 id=3  
事务 C:持有 id=3,等待 id=1

等待环:A → B → C → A

死锁产生的完整过程

经典两事务死锁

-- 初始数据
-- users 表中有 id=1 和 id=2 两条记录

-- 时间点 1:事务 A 执行
BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;  -- 锁 id=1

-- 时间点 2:事务 B 执行
BEGIN;
UPDATE users SET balance = balance - 200 WHERE id = 2;  -- 锁 id=2

-- 时间点 3:事务 A 执行
UPDATE users SET balance = balance + 100 WHERE id = 2;
-- 等待事务 B 释放 id=2 ← Hold and Wait

-- 时间点 4:事务 B 执行
UPDATE users SET balance = balance + 200 WHERE id = 1;
-- 等待事务 A 释放 id=1 ← 循环等待形成

-- 此时:死锁!InnoDB 会检测到并处理

死锁条件检查表

时间 互斥 持有并等待 不可剥夺 循环等待
T1 ✅ id=1 被 A 独占
T2 ✅ id=2 被 B 独占
T3 ✅ A 持有 id=1,等待 id=2
T4 ✅ B 持有 id=2,等待 id=1

死锁与间隙锁

间隙锁也会导致死锁,而且更隐蔽:

-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- id=5 不存在,锁间隙(假设已有 id=3, id=7)

-- 事务 B
BEGIN;
SELECT * FROM users WHERE id = 6 FOR UPDATE;
-- id=6 不存在,也锁在这个间隙(假设在 id=5 和 id=7 之间的间隙)

-- 事务 A
INSERT INTO users(id) VALUES(6);  
-- 需要等 B 释放间隙锁

-- 事务 B
INSERT INTO users(id) VALUES(5);  
-- 需要等 A 释放间隙锁
-- 死锁!

破坏死锁的突破口

要避免死锁,只需破坏四个条件中的任意一个

条件 破坏方法
互斥 ❌ 数据库锁本质是互斥的,不能破坏(除非改为不使用锁的并发控制)
持有并等待 ✅ 一次性获取所有需要的锁,或使用锁超时
不可剥夺 ✅ InnoDB 的死锁检测就是”剥夺”机制(回滚一个事务)
循环等待 ✅ 固定资源访问顺序

面试要点

  1. 死锁的四个条件缺一不可:互斥 + 持有并等待 + 不可剥夺 + 循环等待
  2. InnoDB 通过死锁检测自动打破”不可剥夺”,回滚代价最小的事务
  3. 最常见的原因是程序逻辑导致的循环等待——同一组资源的不同访问顺序
  4. 间隙锁使死锁更隐蔽——锁住不存在的行也会导致死锁
  5. 防止死锁的最佳实践是固定资源访问顺序缩短事务时间

表锁与 MDL 锁的区别

表锁与 MDL 锁的区别

概述

在 MySQL 中,表锁(Table Lock)和 MDL 锁(Metadata Lock,元数据锁)都是表级别的锁,但它们的用途、触发条件和行为截然不同。很多开发者容易混淆这两个概念,面试中也经常被问到。

表锁(Table Lock)

什么是表锁

表锁是 MySQL 中最粗粒度的锁,锁定整张表。在 MyISAM 存储引擎中,表锁是唯一的锁机制。在 InnoDB 中,虽然以行锁为主,但某些场景下仍然会使用表锁。

表锁的触发场景

1. 手动 LOCK TABLES

LOCK TABLES users READ;    -- 加表级读锁
LOCK TABLES users WRITE;   -- 加表级写锁
UNLOCK TABLES;             -- 释放锁

2. ALTER TABLE / DDL 操作

ALTER TABLE users ADD COLUMN age INT;
-- InnoDB 在 DDL 过程中需要使用表锁

3. 某些查询优化器选择

当查询不走索引时,InnoDB 可能会退化为表锁:

-- name 没有索引的场景
DELETE FROM users WHERE name = 'Tom';
-- 可能锁住整张表(取决于 MySQL 版本和优化器决策)

4. 全文索引查询

SELECT * FROM articles WHERE MATCH(title) AGAINST('database');
-- 全文索引搜索需要表级锁

表锁的类型

类型 写锁(WRITE) 读锁(READ)
自己 可读写 只能读
其他事务 阻塞读写 只能读

MDL 锁(Metadata Lock)

什么是 MDL 锁

MDL 锁是 MySQL 5.5 引入的机制,用于在并发环境下保护表结构(元数据)的一致性。它不是传统意义上的数据锁,而是”元数据锁”——防止在执行 DML 时表结构被 DDL 修改。

MDL 锁的关键特点

  • 自动加锁:MDL 锁完全由 MySQL 自动管理,不需要人工干预
  • 默认语义:在 MySQL 5.5+ 中默认启用
  • 表级元数据保护:保护表结构不被意外修改
  • MDL 阻塞可能导致严重后果

MDL 的类型

MDL 类型 获取场景 兼容性
MDL_SHARED (S) 查询语句 SELECT 和其他 S 兼容
MDL_SHARED_READ (SR) SELECT 等读语句 兼容多个 SR
MDL_SHARED_WRITE (SW) INSERT/UPDATE/DELETE 兼容多个 SW
MDL_SHARED_UPGRADABLE (SU) ALTER TABLE 的起始阶段 兼容 SR/SW,不兼容 SU
MDL_EXCLUSIVE (X) ALTER TABLE 的最终阶段 独占,阻塞所有

MDL 锁的典型问题:MDL 阻塞

MDL 最常见的问题是长查询阻塞 DDL

-- 会话 A:长查询
SELECT COUNT(*) FROM big_table;  -- 持有 MDL_SHARED_READ

-- 会话 B:DDL 操作
ALTER TABLE big_table ADD INDEX idx_name(name);  
-- 需要 MDL_EXCLUSIVE → 等待会话 A 释放

-- 会话 C:新的查询
SELECT * FROM big_table WHERE id = 1;
-- 需要 MDL_SHARED_READ → 被会话 B 的 MDL_EXCLUSIVE 阻塞

这就是经典的”MDL 阻塞链”——一个长查询阻塞了 DDL,DDL 又阻塞了所有后续请求。

如何排查 MDL 阻塞

-- MySQL 8.0
SELECT * FROM performance_schema.metadata_locks;

-- 查看哪些语句在等待 MDL 锁
SELECT * FROM sys.schema_table_lock_waits;

-- 找到阻塞的线程并处理
SHOW PROCESSLIST;

-- 必要时 kill 阻塞连接
KILL [connection_id];

表锁与 MDL 的核心区别

对比维度 表锁(Table Lock) MDL 锁(Metadata Lock)
保护对象 数据行(数据安全) 表结构(元数据安全)
锁粒度 整张表 整张表
管理方式 手动或特定场景自动 完全自动
阻塞影响 阻塞 DML 操作 主要阻塞 DDL,间接阻塞 DML
常见场景 LOCK TABLES、无索引 UPDATE DDL 操作期间
是否可避免 避免手动 LOCK TABLES 不可避免(内置机制)
性能影响 高(锁整表) 正常场景很低,但 MDL 链式阻塞影响大

实际案例分析

案例 1:MDL 阻塞导致服务不可用

-- 1. 会话 A 开始长事务
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 获得 MDL_SHARED_READ

-- 2. 会话 B 尝试 DDL
ALTER TABLE users ADD INDEX idx_name(name);  
-- 等待 MDL_EXCLUSIVE,被会话 A 阻塞

-- 3. 其他所有会话
SELECT * FROM users WHERE id = 2;  
-- 等待 MDL_SHARED_READ,被会话 B 阻塞
-- 全部挂起!服务不可用

解决方案
– 在低峰期执行 DDL
– 使用 pt-online-schema-change 工具
– 设置 DDL 超时:lock_wait_timeout

案例 2:手动 LOCK TABLES 的影响

-- 会话 A
LOCK TABLES users WRITE;  -- 加写表锁
UPDATE users SET name = 'Tom' WHERE id = 1;  -- ✓

-- 会话 B(被阻塞)
SELECT * FROM users WHERE id = 2;  -- 等待表锁
UPDATE users SET name = 'Jerry' WHERE id = 1;  -- 等待表锁

面试要点

  1. 表锁保护数据,MDL 保护结构——两者用途完全不同
  2. 表锁可以手动加(LOCK TABLES),MDL 自动管理
  3. MDL 阻塞链是生产环境常见问题——一个慢查询阻塞 DDL,DDL 阻塞所有后续请求
  4. 使用 performance_schema.metadata_locks 排查 MDL 问题
  5. InnoDB 中尽量避免手动 LOCK TABLES
  6. 执行 DDL 前确保没有长事务在运行

意向锁机制详解

意向锁机制详解

概述

意向锁(Intention Lock)是 InnoDB 存储引擎为了更好地兼容表级锁行级锁而引入的一种锁机制。它本身不锁定具体数据,而是向后续的加锁请求”预告”当前事务对某个表的加锁意图。

为什么需要意向锁?

问题场景

想象一个场景:事务 A 正在修改表 users 中的某一行(持有该行的 X 锁)。这时事务 B 想要对整个表加表锁(LOCK TABLES WRITE)。

  • 如果不做检查就加表锁,可能破坏行级锁的数据安全性
  • 逐一检查表中的每一行是否被锁,性能极差

意向锁的解法

意向锁是一种”预告机制”:

  • 当事务对某一行加 X 锁之前,在表级别加上意向排他锁(IX)
  • 当事务对某一行加 S 锁之前,在表级别加上意向共享锁(IS)
  • 事务 B 要加表锁时,只需检查表上是否有 IX/IS 锁,有就等待,无需逐行检查

意向锁的类型

InnoDB 支持两种意向锁:

意向锁类型 缩写 含义
意向共享锁 IS 事务准备在各个行上加共享锁(S 锁)
意向排他锁 IX 事务准备在各个行上加排他锁(X 锁)

意向锁的兼容性

当前锁\请求锁 IS IX S X
IS
IX
S
X

关键规则

  1. 意向锁之间是兼容的:IS 和 IS、IS 和 IX、IX 和 IX 都可以同时存在
  2. 表级 S 锁与 IX 锁互斥:说明有事务要写行级数据,不能被共享锁阻塞
  3. 表级 X 锁与一切互斥:独占整张表
  4. 意向锁只阻塞表级锁,不阻塞行级锁

意向锁的工作原理

加锁顺序

事务 A:对行加 X 锁

1. 先在表 users 上加 IX 锁(表级)
2. 再对行 id=1 加 X 锁(行级)

事务 B:对行加 S 锁

1. 先在表 users 上加 IS 锁(表级)   ← ✅ 兼容 IX
2. 再对行 id=1 加 S 锁(行级)      ← ❌ 与 X 锁互斥,等待
事务 C:锁整张表

尝试加表级 S 锁 → 检查表上是否有 IX 锁 → 有 → 互斥 → 等待
                    无需检查任何行级锁!

意向锁是自动的

开发人员不需要手动管理意向锁。InnoDB 在以下情况下自动添加:

  • 执行 SELECT ... FOR UPDATE → 先在表上加 IX
  • 执行 SELECT ... FOR SHARE / LOCK IN SHARE MODE → 先在表上加 IS
  • 执行 UPDATE / DELETE → 先在表上加 IX
  • 执行 INSERT → 先在表上加 IX

意向锁与手动表锁

-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 自动:users 表上加 IX 锁,行上加 X 锁

-- 事务 B(另一个会话)
LOCK TABLES users WRITE;
-- 需要加 X 表锁
-- 检查到 users 表上有 IX 锁 → 互斥 → 等待事务 A 释放
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- 事务 C
LOCK TABLES users READ;
-- 需要加 S 表锁
-- 检查到 users 表上有 IX 锁 → 互斥 → 等待事务 A 释放

意向锁与 MDL 锁的区别

对比维度 意向锁(Intention Lock) MDL(元数据锁)
作用对象 数据行所在的表 表结构/元数据
锁粒度 表级 表级
作用 预告下一步的行级锁 保护表结构不被 DDL 修改
触发 行锁操作自动触发 任何 SQL 自动获取对应类型的 MDL
是否互斥 IX/IS 兼容 MDL 类型不同互斥

常见问题

意向锁会死锁吗?

意向锁本身不会导致死锁。因为意向锁之间是兼容的(IS 和 IX 兼容),且意向锁的加锁顺序是固定的(先表级再行级)。死锁通常发生在行级锁的竞争上。

意向锁影响性能吗?

意向锁的加锁操作非常轻量,仅需在表级别的锁结构中标记一下。对性能的影响可以忽略不计。

如何查看意向锁?

-- MySQL 8.0
SELECT * FROM performance_schema.data_locks;

-- 查看 LOCK_MODE 列:
-- LOCK_MODE = 'IS' 表示意向共享锁(表级)
-- LOCK_MODE = 'IX' 表示意向排他锁(表级)

面试要点

  1. 意向锁是表级锁,但用于保护行级锁
  2. 核心作用:高效判断表级锁是否能立即授予,无需逐行检查
  3. IS/IX 之间兼容,只与表级 S/X 锁冲突
  4. 自动管理:InnoDB 自动加,开发人员无需关心
  5. 加锁顺序:先加意向锁(表级),再加行锁(行级)
  6. 性能影响极小:轻量标记操作

MVCC 多版本并发控制详解

MVCC 多版本并发控制详解

概述

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 MySQL InnoDB 实现高并发的核心技术。它允许多个事务同时读取和修改同一行数据而不互相阻塞,通过保留数据的多个历史版本,让每个事务都能看到自己”应该看到”的数据版本。

核心思想

传统锁的痛点

在传统的锁定机制下:

事务 A 读取 id=1 的数据 → 加读锁
事务 B 要修改 id=1 的数据 → ❌ 被阻塞,等 A 释放锁

这种”读-写互斥”在高并发场景下严重影响性能。

MVCC 的解法

MVCC 不再使用”锁住数据”的方式来保证一致性,而是:

事务 A 读取 id=1 的数据 → 看到快照版本(不加锁)
事务 B 修改 id=1 的数据 → 创建新版本(也不阻塞写)
事务 A 后续再读 id=1 → 仍然看到旧版本(一致性不变)

读不阻塞写,写不阻塞读。这就是 MVCC 的魔力。

MVCC 的核心组件

MVCC 实现
│
├── 1. 隐藏列(Hidden Columns)
│   ├── DB_TRX_ID:最后修改该行的事务 ID
│   └── DB_ROLL_PTR:指向前一版本的 Undo Log 指针
│
├── 2. Undo Log(回滚日志)
│   └── 存储每个版本数据变更前的旧值,形成版本链
│
├── 3. Read View(读视图)
│   └── 决定当前事务能看到哪些版本的核心数据结构
│
└── 4. Purge 线程(清理线程)
    └── 定期清理不再被任何事务需要的旧版本数据

MVCC 的工作流程

读取数据时的决策过程

用户发起 SELECT
    │
    ▼
从聚簇索引获取当前版本
    │
    ▼
查看 DB_TRX_ID(该版本由哪个事务创建的)
    │
    ▼
与当前事务的 Read View 比较
    │
    ├── 可见 → 直接返回该版本
    │
    └── 不可见
            │
            ▼
        通过 DB_ROLL_PTR 找到 Undo Log 中的前一版本
            │
            ▼
        检查该版本的 DB_TRX_ID 对当前事务是否可见
            │
            ├── 可见 → 返回该版本
            │
            └── 不可见 → 继续沿版本链向前追溯

写入数据时的过程

用户发起 UPDATE
    │
    ▼
对目标行加锁(防止并发修改)
    │
    ▼
将当前版本的旧值写入 Undo Log
    │
    ▼
更新行的数据为新值
    │
    ▼
更新 DB_TRX_ID 为当前事务 ID
    │
    ▼
更新 DB_ROLL_PTR 指向旧版本的 Undo Log
    │
    ▼
释放锁

MVCC 在不同隔离级别下的表现

READ COMMITTED

  • 每条 SELECT 创建新的 Read View
  • 能看到其他事务已提交的修改
  • 不可重复读可能发生
SELECT 1 → 创建 Read View A(看到此刻已提交的数据)
SELECT 2 → 创建 Read View B(看到包括事务 T 提交后的数据)
         → 两次结果可能不同

REPEATABLE READ(MySQL 默认)

  • 事务中第一条 SELECT 创建 Read View,后续复用
  • 不会看到其他事务的修改(即使已提交)
  • 实现可重复读
SELECT 1 → 创建 Read View A(记录此刻活跃事务列表)
SELECT 2 → 复用 Read View A(和第一次看到的一样)
         → 两次结果一致

MVCC 带来的收益

收益 说明
读不阻塞写 SELECT 不需要加锁,不会阻塞 UPDATE/DELETE
写不阻塞读 UPDATE/DELETE 时,其他事务仍可读到旧版本
高并发 读写并行,大幅提升并发吞吐
一致性快照 事务内多次读取结果一致
回滚方便 Undo Log 记录了所有旧版本,可直接回滚

MVCC 的代价

代价 说明
存储开销 Undo Log 占用额外磁盘空间
版本链遍历 历史版本过多时,查询需要遍历版本链
清理负担 Purge 线程需要定期清理废弃版本
长事务影响 长事务阻止版本清理,导致 Undo 膨胀

使用建议

  • 控制事务长度:避免长事务导致版本链过长
  • 监控 History List LengthSHOW ENGINE INNODB STATUS 中的 History list length 过高提示版本堆积
  • 合理隔离级别:大多数场景 REPEATABLE READ 或 READ COMMITTED 足够

面试要点

  1. MVCC 是什么:多版本并发控制,读不阻塞写,写不阻塞读
  2. 核心组成:隐藏列 + Undo Log + Read View + Purge 线程
  3. 实现差异:不同隔离级别的 MVCC 行为差异(Read View 创建时机)
  4. 英文全称:Multi-Version Concurrency Control,面试时一口说出加分
  5. 关联知识点:常与事务隔离级别、锁、Read View、Undo Log 一起考查

MVCC 解决什么问题

MVCC 解决什么问题

概述

MVCC(多版本并发控制)是 InnoDB 实现高并发读写的核心机制。但它的设计初衷和实际解决的问题到底是什么?很多人在学习 MVCC 时只记得”多版本”和”隐藏列”,却忽略了它要解决的根本问题——在保证数据一致性的前提下,最大化并发读写的性能

MVCC 要解决的核心问题

1. 读写互斥问题

传统锁方案的痛点

在没有 MVCC 的数据库中,并发控制通常依赖两阶段锁协议

-- 事务 A 读取
SELECT * FROM user WHERE id = 1;  -- 加读锁,其他人不能写

-- 事务 B 无法写入
UPDATE user SET name = '李四' WHERE id = 1;  -- ❌ 阻塞!

读和写互斥,意味着:
– 高并发场景下大量请求排队
– 响应时间急剧增加
– 系统吞吐量大幅下降

MVCC 的解法

MVCC 的核心突破:读不加锁,写不阻塞读

-- 事务 A 读取(不加锁)
SELECT * FROM user WHERE id = 1;  -- 直接读快照,不用锁

-- 事务 B 修改(不阻塞)
UPDATE user SET name = '李四' WHERE id = 1;  -- ✅ 成功

-- 事务 A 再次读取(仍然看到旧版本,不会被 B 的修改影响)
SELECT * FROM user WHERE id = 1;  -- 看到事务 B 修改前的数据

2. 一致性读(Consistent Read)

问题

事务在执行过程中,其他事务的修改可能导致前后读取结果不一致:

-- 报表生成场景
BEGIN;
SELECT SUM(amount) FROM orders;  -- 读到包括未发货的订单
-- 另一个事务修改了订单状态
SELECT SUM(discount) FROM orders;  -- 基于已经修改后的数据
-- 总和计算使用了不同时间点的数据,结果不正确

MVCC 的解法

MVCC 为每个事务提供一致性快照

BEGIN;
-- 第一次 SELECT 创建 Read View,记录此刻的所有活跃事务
SELECT SUM(amount) FROM orders;  -- 基于快照
SELECT SUM(discount) FROM orders;  -- 基于同一快照
-- 两个结果基于同一数据状态,逻辑一致
COMMIT;

3. 回滚能力

问题

事务执行过程中失败,需要回滚所有修改。如果已经修改了多行数据,没有旧版本信息就无法回滚。

MVCC 的解法

MVCC 通过 Undo Log 保留数据的每个历史版本:

START TRANSACTION;
UPDATE user SET name = 'A' WHERE id = 1;  -- 旧版本 'X' 存入 Undo
UPDATE user SET name = 'B' WHERE id = 2;  -- 旧版本 'Y' 存入 Undo

-- ...业务出错
ROLLBACK;
-- 从 Undo Log 中恢复:id=1 → 'X', id=2 → 'Y'

MVCC 不解决的问题

了解 MVCC 不能做什么同样重要:

不能解决的问题 需要借助的机制
写写冲突(两个事务同时修改同一行) 行锁(Record Lock)
幻读(当前读场景下) 间隙锁(Gap Lock)
丢失更新(读取-修改-写入的竞态条件) 乐观锁或悲观锁
主从一致 复制机制

实际收益对比

场景 无 MVCC(纯锁) 有 MVCC(MySQL)
读多写少系统 读会阻塞写,效率低 读写并行,性能极高
读写均衡系统 读写相互阻塞,吞吐低 读写互不阻塞,吞吐高
报表系统 影响线上写入 通过快照读不影响线上
备份导出 需加全局读锁 通过 MVCC 一致性读无锁导出

MVCC 解决的经典问题场景

案例 1:银行转账报表

-- 需要统计某账户当日所有流水(不关心实时值)
BEGIN;
SELECT * FROM transaction WHERE account_id = 123;  -- 快照
-- 此时有另一个转账事务在修改 account=123
-- MVCC 保证两次 SELECT 结果一致
SELECT SUM(amount) FROM transaction WHERE account_id = 123;  -- 基于同一快照
COMMIT;

案例 2:mysqldump

-- mysqldump 使用 --single-transaction 导出
-- 利用 MVCC 在 REPEATABLE READ 下获得一致性快照
-- 导出过程中其他事务可以继续写入,互不阻塞

面试要点

  1. 一句话总结 MVCC 解决的问题:在不加锁的前提下,为事务提供一致性读的能力,实现”读不阻塞写,写不阻塞读”
  2. 三个核心能力:一致性快照 + 读写不互斥 + 回滚能力
  3. MVCC 不是万能的:写写冲突仍需锁,幻读仍需间隙锁
  4. 高频面试题:”MVCC 解决了什么问题?” → 分别从三种并发问题角度回答

自增锁机制详解

自增锁机制详解

概述

自增锁(AUTO-INC Lock)是 InnoDB 为了处理自增列(AUTO_INCREMENT)的并发插入而设计的一种特殊表级锁。它确保多个事务并发插入时,自增 ID 分配的唯一性和连续性。

自增锁的历史

MySQL 5.1 之前

自增锁是表级锁。每个 INSERT 操作在获取自增值之前,都需要获取该表上的自增锁,插入完成后立即释放(注意:不是事务提交时释放)。

问题:高并发插入时,自增锁成为瓶颈。

MySQL 5.1+(innodb_autoinc_lock_mode 引入)

MySQL 5.1 引入了 innodb_autoinc_lock_mode 参数,提供了三种锁模式:

模式 说明
0 traditional 传统模式,始终使用表级自增锁
1 consecutive 连续模式,对批量插入使用表锁,对单行插入使用轻量互斥量(默认值)
2 interleaved 交错模式,始终使用轻量互斥量

MySQL 8.0 的变革

MySQL 8.0 中 innodb_autoinc_lock_mode默认值仍然是 1(consecutive),但自增 ID 的持久化机制发生了重要变化。

MySQL 8.0 之前

自增计数器保存在内存中:

MySQL 重启后,自增 ID 的恢复方式:
SELECT MAX(auto_inc_col) FROM t FOR UPDATE;

MySQL 8.0

自增计数器的变化被持久化到 redo log,每次修改都会写入磁盘:

  • 不需要在重启时重新计算 MAX(auto_inc_col)
  • 自增值更可靠、恢复更快
  • 但每次自增变化都要写 redo log,产生额外开销

三种锁模式的详细分析

模式 0:traditional(传统模式)

INSERT INTO users(name) VALUES('Tom');
-- 会话层级:获取 AUTO-INC 表锁 → 分配 ID → 释放 AUTO-INC 表锁
-- 对于所有 INSERT 都使用表级锁

性能最差,在 MySQL 8.0 中不建议使用。

模式 1:consecutive(连续模式,默认)

-- 简答的 INSERT(行数可预知)
INSERT INTO users(name) VALUES('Tom');
INSERT INTO users(name) VALUES('Jerry'), ('Bob');
-- 使用轻量互斥量(mutex),性能很好

-- 批量插入(行数不可预知)
INSERT INTO users(name) SELECT name FROM old_users;
-- 使用表级自增锁,保证连续 ID

为什么对 INSERT ... SELECT 要使用表锁?
– 这种插入的行数在执行前无法确定
– 如果不能一次性分配连续的 ID 范围,可能导致 ID 分配错误

模式 2:interleaved(交错模式)

INSERT INTO users(name) VALUES('Tom');
INSERT INTO users(name) SELECT name FROM old_users;
-- 全部使用轻量互斥量
-- 不保证 ID 连续性

优点:并发性能最高
缺点
– 批量插入的 ID 可能和并发插入的 ID 交错
– 基于语句的复制(statement-based replication)可能不兼容

自增锁的加锁与释放时机

加锁时机

在所有 INSERT 操作获取自增值之前。

释放时机(重要!)

  • 对于表级自增锁:获取自增值后立即释放(不是事务提交时)
  • 对于互斥量:获取自增值后立即释放
BEGIN;
INSERT INTO users(name) VALUES('Tom');
  -- 1. 获取自增锁(或互斥量)
  -- 2. 分配 ID=100
  -- 3. 释放自增锁  此时锁已经释放了
  -- 4. 插入数据行(持有行锁)
COMMIT;

这个设计很重要:自增锁不参与事务锁,不会因为长事务而被其他事务等待。

自增锁带来的问题

1. ID 不连续

自增 ID 不连续是正常现象,原因包括:

-- 原因 1:事务回滚
BEGIN;
INSERT INTO users(name) VALUES('Tom');  -- 分配 ID=100
ROLLBACK;  -- 行被回滚,但 ID=100 已被分配
INSERT INTO users(name) VALUES('Jerry');  -- ID=101,而不是 100

-- 原因 2:批量插入预留过多
INSERT INTO users(name) VALUES('A'),('B'),('C');  -- 预留 3 个 ID
-- 实际只用了 3 个,但如果预留发生在并发环境下,可能有些 ID 被浪费

-- 原因 3:MySQL 重启
-- MySQL 重启后,自增起始值可能改变

2. 并发插入的间隙

在高并发下如果同时进行批量插入和普通插入:

事务 A:INSERT INTO users(name) SELECT name FROM big_table;  -- 预留 100 个 ID
事务 B:INSERT INTO users(name) VALUES('New');  -- 得到 ID=101,而不是在 A 预留的 ID 之间

3. 复制环境的 ID 不一致

在 statement-based 复制中,如果使用 interleaved 模式:

-- 主库
INSERT INTO t VALUES(NULL, 'a');  -- ID=1
INSERT INTO t VALUES(NULL, 'b');  -- ID=2
-- 由于交错,可能 ID 顺序不同

建议:使用 binlog_format=ROW 避免此类问题。

如何查看和设置自增锁模式

-- 查看当前模式
SELECT @@innodb_autoinc_lock_mode;
-- MySQL 8.0 默认:1(consecutive)

-- 设置模式(修改配置文件 my.cnf)
-- [mysqld]
-- innodb_autoinc_lock_mode = 2

-- 查看当前自增值
SELECT AUTO_INCREMENT FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'dbname' AND TABLE_NAME = 'users';

-- 修改自增值
ALTER TABLE users AUTO_INCREMENT = 1000;

最佳实践

  1. 保持默认的 consecutive 模式(innodb_autoinc_lock_mode=1)
  2. 不要依赖自增 ID 的连续性
  3. INSERT ... SELECT 批量插入时,注意自增锁会退化为表锁
  4. 考虑使用 binlog_format=ROW 避免复制问题
  5. 如果对 ID 顺序有严格要求,考虑使用其他非自增 ID 方案

面试要点

  1. 自增锁的模式:traditional(0)、consecutive(1,默认)、interleaved(2)
  2. 自增锁是表级锁,但立即释放——不参与事务锁
  3. 自增 ID 不保证连续性,这是设计如此
  4. 批量插入(INSERT ... SELECT)在默认模式下会退化为表锁
  5. MySQL 8.0 将自增计数器持久化到 redo log,不再需要重启后计算 MAX(id)

死锁的四个必要条件

死锁的四个必要条件

概述

死锁(Deadlock)是指两个或多个事务互相持有对方需要的资源,互相等待对方释放,导致所有事务都无法继续执行的状态。死锁的产生必须同时满足四个必要条件,了解这些条件是预防和排查死锁的基础。

四个必要条件

条件一:互斥(Mutual Exclusion)

资源在同一时间只能被一个事务占用。

在 MySQL 中:
– 排他锁(X 锁)同一时间只能被一个事务持有
– 共享锁(S 锁)可以被多个事务持有,但 S 锁与 X 锁互斥

-- 事务 A 持有 id=1 的 X 锁
UPDATE users SET name = 'A' WHERE id = 1;

-- 事务 B 不能同时持有 id=1 的 X 锁
UPDATE users SET name = 'B' WHERE id = 1;  -- 等待 A

这是死锁的前提条件——如果资源可以共享使用,就不会发生死锁。数据库中的行锁、间隙锁都是互斥资源。

条件二:持有并等待(Hold and Wait)

事务已经持有了某些资源,同时又在等待获取其他资源。

-- 事务 A:持有 id=1 的锁,等待 id=2 的锁
UPDATE users SET balance = 100 WHERE id = 1;
UPDATE users SET balance = 200 WHERE id = 2;  -- 等待 B 释放

-- 事务 B:持有 id=2 的锁,等待 id=1 的锁
UPDATE users SET balance = 300 WHERE id = 2;
UPDATE users SET balance = 400 WHERE id = 1;  -- 等待 A 释放

这是死锁出现的必要阶段——如果事务不等待(直接报错),或者不持有其他锁,就不会死锁。

条件三:不可剥夺(No Preemption)

已获得的资源在未使用完之前不能强制剥夺,只能由持有者主动释放。

在 InnoDB 中:
– 锁只能通过 COMMIT 或 ROLLBACK 释放
– 其他事务不能强制让持有者释放锁
– 除非检测到死锁后,MySQL 选择回滚某个事务(这是例外)

-- 事务 A 持有 id=1 的锁
-- 无论事务 B 愿意付出什么代价,都不能让 A 交出锁
-- A 只能在 COMMIT 或 ROLLBACK 后释放

这意味着竞争的事务必须等待,直到锁被主动释放。

条件四:循环等待(Circular Wait)

存在一个事务等待环,每个事务都在等待下一个事务占用的资源。

事务 A → 等待 → 事务 B → 等待 → 事务 C → 等待 → 事务 A
             ↑                               |
             └───────────────────────────────┘
-- 经典的两事务循环等待
-- 事务 A 持有 id=1,等待 id=2
-- 事务 B 持有 id=2,等待 id=1

-- 图表示:
-- A → (等待 id=2) → B
-- B → (等待 id=1) → A
-- A ↔ B 形成循环

循环等待不一定只有两个事务:

三个事务的循环等待:
事务 A:持有 id=1,等待 id=2
事务 B:持有 id=2,等待 id=3  
事务 C:持有 id=3,等待 id=1

等待环:A → B → C → A

死锁产生的完整过程

经典两事务死锁

-- 初始数据
-- users 表中有 id=1 和 id=2 两条记录

-- 时间点 1:事务 A 执行
BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;  -- 锁 id=1

-- 时间点 2:事务 B 执行
BEGIN;
UPDATE users SET balance = balance - 200 WHERE id = 2;  -- 锁 id=2

-- 时间点 3:事务 A 执行
UPDATE users SET balance = balance + 100 WHERE id = 2;
-- 等待事务 B 释放 id=2 ← Hold and Wait

-- 时间点 4:事务 B 执行
UPDATE users SET balance = balance + 200 WHERE id = 1;
-- 等待事务 A 释放 id=1 ← 循环等待形成

-- 此时:死锁!InnoDB 会检测到并处理

死锁条件检查表

时间 互斥 持有并等待 不可剥夺 循环等待
T1 ✅ id=1 被 A 独占
T2 ✅ id=2 被 B 独占
T3 ✅ A 持有 id=1,等待 id=2
T4 ✅ B 持有 id=2,等待 id=1

死锁与间隙锁

间隙锁也会导致死锁,而且更隐蔽:

-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 5 FOR UPDATE;
-- id=5 不存在,锁间隙(假设已有 id=3, id=7)

-- 事务 B
BEGIN;
SELECT * FROM users WHERE id = 6 FOR UPDATE;
-- id=6 不存在,也锁在这个间隙(假设在 id=5 和 id=7 之间的间隙)

-- 事务 A
INSERT INTO users(id) VALUES(6);  
-- 需要等 B 释放间隙锁

-- 事务 B
INSERT INTO users(id) VALUES(5);  
-- 需要等 A 释放间隙锁
-- 死锁!

破坏死锁的突破口

要避免死锁,只需破坏四个条件中的任意一个

条件 破坏方法
互斥 ❌ 数据库锁本质是互斥的,不能破坏(除非改为不使用锁的并发控制)
持有并等待 ✅ 一次性获取所有需要的锁,或使用锁超时
不可剥夺 ✅ InnoDB 的死锁检测就是”剥夺”机制(回滚一个事务)
循环等待 ✅ 固定资源访问顺序

面试要点

  1. 死锁的四个条件缺一不可:互斥 + 持有并等待 + 不可剥夺 + 循环等待
  2. InnoDB 通过死锁检测自动打破”不可剥夺”,回滚代价最小的事务
  3. 最常见的原因是程序逻辑导致的循环等待——同一组资源的不同访问顺序
  4. 间隙锁使死锁更隐蔽——锁住不存在的行也会导致死锁
  5. 防止死锁的最佳实践是固定资源访问顺序缩短事务时间

表锁与 MDL 锁的区别

表锁与 MDL 锁的区别

概述

在 MySQL 中,表锁(Table Lock)和 MDL 锁(Metadata Lock,元数据锁)都是表级别的锁,但它们的用途、触发条件和行为截然不同。很多开发者容易混淆这两个概念,面试中也经常被问到。

表锁(Table Lock)

什么是表锁

表锁是 MySQL 中最粗粒度的锁,锁定整张表。在 MyISAM 存储引擎中,表锁是唯一的锁机制。在 InnoDB 中,虽然以行锁为主,但某些场景下仍然会使用表锁。

表锁的触发场景

1. 手动 LOCK TABLES

LOCK TABLES users READ;    -- 加表级读锁
LOCK TABLES users WRITE;   -- 加表级写锁
UNLOCK TABLES;             -- 释放锁

2. ALTER TABLE / DDL 操作

ALTER TABLE users ADD COLUMN age INT;
-- InnoDB 在 DDL 过程中需要使用表锁

3. 某些查询优化器选择

当查询不走索引时,InnoDB 可能会退化为表锁:

-- name 没有索引的场景
DELETE FROM users WHERE name = 'Tom';
-- 可能锁住整张表(取决于 MySQL 版本和优化器决策)

4. 全文索引查询

SELECT * FROM articles WHERE MATCH(title) AGAINST('database');
-- 全文索引搜索需要表级锁

表锁的类型

类型 写锁(WRITE) 读锁(READ)
自己 可读写 只能读
其他事务 阻塞读写 只能读

MDL 锁(Metadata Lock)

什么是 MDL 锁

MDL 锁是 MySQL 5.5 引入的机制,用于在并发环境下保护表结构(元数据)的一致性。它不是传统意义上的数据锁,而是”元数据锁”——防止在执行 DML 时表结构被 DDL 修改。

MDL 锁的关键特点

  • 自动加锁:MDL 锁完全由 MySQL 自动管理,不需要人工干预
  • 默认语义:在 MySQL 5.5+ 中默认启用
  • 表级元数据保护:保护表结构不被意外修改
  • MDL 阻塞可能导致严重后果

MDL 的类型

MDL 类型 获取场景 兼容性
MDL_SHARED (S) 查询语句 SELECT 和其他 S 兼容
MDL_SHARED_READ (SR) SELECT 等读语句 兼容多个 SR
MDL_SHARED_WRITE (SW) INSERT/UPDATE/DELETE 兼容多个 SW
MDL_SHARED_UPGRADABLE (SU) ALTER TABLE 的起始阶段 兼容 SR/SW,不兼容 SU
MDL_EXCLUSIVE (X) ALTER TABLE 的最终阶段 独占,阻塞所有

MDL 锁的典型问题:MDL 阻塞

MDL 最常见的问题是长查询阻塞 DDL

-- 会话 A:长查询
SELECT COUNT(*) FROM big_table;  -- 持有 MDL_SHARED_READ

-- 会话 B:DDL 操作
ALTER TABLE big_table ADD INDEX idx_name(name);  
-- 需要 MDL_EXCLUSIVE → 等待会话 A 释放

-- 会话 C:新的查询
SELECT * FROM big_table WHERE id = 1;
-- 需要 MDL_SHARED_READ → 被会话 B 的 MDL_EXCLUSIVE 阻塞

这就是经典的”MDL 阻塞链”——一个长查询阻塞了 DDL,DDL 又阻塞了所有后续请求。

如何排查 MDL 阻塞

-- MySQL 8.0
SELECT * FROM performance_schema.metadata_locks;

-- 查看哪些语句在等待 MDL 锁
SELECT * FROM sys.schema_table_lock_waits;

-- 找到阻塞的线程并处理
SHOW PROCESSLIST;

-- 必要时 kill 阻塞连接
KILL [connection_id];

表锁与 MDL 的核心区别

对比维度 表锁(Table Lock) MDL 锁(Metadata Lock)
保护对象 数据行(数据安全) 表结构(元数据安全)
锁粒度 整张表 整张表
管理方式 手动或特定场景自动 完全自动
阻塞影响 阻塞 DML 操作 主要阻塞 DDL,间接阻塞 DML
常见场景 LOCK TABLES、无索引 UPDATE DDL 操作期间
是否可避免 避免手动 LOCK TABLES 不可避免(内置机制)
性能影响 高(锁整表) 正常场景很低,但 MDL 链式阻塞影响大

实际案例分析

案例 1:MDL 阻塞导致服务不可用

-- 1. 会话 A 开始长事务
BEGIN;
SELECT * FROM users WHERE id = 1;  -- 获得 MDL_SHARED_READ

-- 2. 会话 B 尝试 DDL
ALTER TABLE users ADD INDEX idx_name(name);  
-- 等待 MDL_EXCLUSIVE,被会话 A 阻塞

-- 3. 其他所有会话
SELECT * FROM users WHERE id = 2;  
-- 等待 MDL_SHARED_READ,被会话 B 阻塞
-- 全部挂起!服务不可用

解决方案
– 在低峰期执行 DDL
– 使用 pt-online-schema-change 工具
– 设置 DDL 超时:lock_wait_timeout

案例 2:手动 LOCK TABLES 的影响

-- 会话 A
LOCK TABLES users WRITE;  -- 加写表锁
UPDATE users SET name = 'Tom' WHERE id = 1;  -- ✓

-- 会话 B(被阻塞)
SELECT * FROM users WHERE id = 2;  -- 等待表锁
UPDATE users SET name = 'Jerry' WHERE id = 1;  -- 等待表锁

面试要点

  1. 表锁保护数据,MDL 保护结构——两者用途完全不同
  2. 表锁可以手动加(LOCK TABLES),MDL 自动管理
  3. MDL 阻塞链是生产环境常见问题——一个慢查询阻塞 DDL,DDL 阻塞所有后续请求
  4. 使用 performance_schema.metadata_locks 排查 MDL 问题
  5. InnoDB 中尽量避免手动 LOCK TABLES
  6. 执行 DDL 前确保没有长事务在运行

意向锁机制详解

意向锁机制详解

概述

意向锁(Intention Lock)是 InnoDB 存储引擎为了更好地兼容表级锁行级锁而引入的一种锁机制。它本身不锁定具体数据,而是向后续的加锁请求”预告”当前事务对某个表的加锁意图。

为什么需要意向锁?

问题场景

想象一个场景:事务 A 正在修改表 users 中的某一行(持有该行的 X 锁)。这时事务 B 想要对整个表加表锁(LOCK TABLES WRITE)。

  • 如果不做检查就加表锁,可能破坏行级锁的数据安全性
  • 逐一检查表中的每一行是否被锁,性能极差

意向锁的解法

意向锁是一种”预告机制”:

  • 当事务对某一行加 X 锁之前,在表级别加上意向排他锁(IX)
  • 当事务对某一行加 S 锁之前,在表级别加上意向共享锁(IS)
  • 事务 B 要加表锁时,只需检查表上是否有 IX/IS 锁,有就等待,无需逐行检查

意向锁的类型

InnoDB 支持两种意向锁:

意向锁类型 缩写 含义
意向共享锁 IS 事务准备在各个行上加共享锁(S 锁)
意向排他锁 IX 事务准备在各个行上加排他锁(X 锁)

意向锁的兼容性

当前锁\请求锁 IS IX S X
IS
IX
S
X

关键规则

  1. 意向锁之间是兼容的:IS 和 IS、IS 和 IX、IX 和 IX 都可以同时存在
  2. 表级 S 锁与 IX 锁互斥:说明有事务要写行级数据,不能被共享锁阻塞
  3. 表级 X 锁与一切互斥:独占整张表
  4. 意向锁只阻塞表级锁,不阻塞行级锁

意向锁的工作原理

加锁顺序

事务 A:对行加 X 锁

1. 先在表 users 上加 IX 锁(表级)
2. 再对行 id=1 加 X 锁(行级)

事务 B:对行加 S 锁

1. 先在表 users 上加 IS 锁(表级)   ← ✅ 兼容 IX
2. 再对行 id=1 加 S 锁(行级)      ← ❌ 与 X 锁互斥,等待
事务 C:锁整张表

尝试加表级 S 锁 → 检查表上是否有 IX 锁 → 有 → 互斥 → 等待
                    无需检查任何行级锁!

意向锁是自动的

开发人员不需要手动管理意向锁。InnoDB 在以下情况下自动添加:

  • 执行 SELECT ... FOR UPDATE → 先在表上加 IX
  • 执行 SELECT ... FOR SHARE / LOCK IN SHARE MODE → 先在表上加 IS
  • 执行 UPDATE / DELETE → 先在表上加 IX
  • 执行 INSERT → 先在表上加 IX

意向锁与手动表锁

-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 自动:users 表上加 IX 锁,行上加 X 锁

-- 事务 B(另一个会话)
LOCK TABLES users WRITE;
-- 需要加 X 表锁
-- 检查到 users 表上有 IX 锁 → 互斥 → 等待事务 A 释放
-- 事务 A
BEGIN;
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- 事务 C
LOCK TABLES users READ;
-- 需要加 S 表锁
-- 检查到 users 表上有 IX 锁 → 互斥 → 等待事务 A 释放

意向锁与 MDL 锁的区别

对比维度 意向锁(Intention Lock) MDL(元数据锁)
作用对象 数据行所在的表 表结构/元数据
锁粒度 表级 表级
作用 预告下一步的行级锁 保护表结构不被 DDL 修改
触发 行锁操作自动触发 任何 SQL 自动获取对应类型的 MDL
是否互斥 IX/IS 兼容 MDL 类型不同互斥

常见问题

意向锁会死锁吗?

意向锁本身不会导致死锁。因为意向锁之间是兼容的(IS 和 IX 兼容),且意向锁的加锁顺序是固定的(先表级再行级)。死锁通常发生在行级锁的竞争上。

意向锁影响性能吗?

意向锁的加锁操作非常轻量,仅需在表级别的锁结构中标记一下。对性能的影响可以忽略不计。

如何查看意向锁?

-- MySQL 8.0
SELECT * FROM performance_schema.data_locks;

-- 查看 LOCK_MODE 列:
-- LOCK_MODE = 'IS' 表示意向共享锁(表级)
-- LOCK_MODE = 'IX' 表示意向排他锁(表级)

面试要点

  1. 意向锁是表级锁,但用于保护行级锁
  2. 核心作用:高效判断表级锁是否能立即授予,无需逐行检查
  3. IS/IX 之间兼容,只与表级 S/X 锁冲突
  4. 自动管理:InnoDB 自动加,开发人员无需关心
  5. 加锁顺序:先加意向锁(表级),再加行锁(行级)
  6. 性能影响极小:轻量标记操作

乐观锁在 Redis 中的实现:用 WATCH 命令玩转 CAS 机制

乐观锁在 Redis 中的实现:用 WATCH 命令玩转 CAS 机制

什么是乐观锁

乐观锁(Optimistic Lock)是一种并发控制策略,核心思想是:假设数据在大多数情况下不会发生冲突,只在更新数据时检查是否有冲突。与悲观锁不同,乐观锁不会先锁定数据,而是允许并发读取,在写入时验证数据是否被修改过。

Redis 中的乐观锁实现:WATCH 命令

Redis 通过 WATCH 命令提供乐观锁支持,它实现了 CAS(Check-And-Set) 操作模式:

WATCH key            -- 监视一个或多个 key
MULTI                -- 开启事务块
...                  -- 执行命令
EXEC                 -- 执行事务,如果 WATCH  key 被修改则放弃执行
UNWATCH              -- 取消监视

工作原理

-- 伪代码:用 WATCH 实现乐观锁
WATCH balance
balance_val = GET balance  -- 获取当前余额
if balance_val >= amount
    MULTI
    DECRBY balance amount
    EXEC                  -- 如果在 WATCH 之后 balance 被其他客户端修改,EXEC 返回 nil
end

当客户端执行 WATCH 后,Redis 会记录被监视的 key。随后执行 MULTI 开启事务并添加命令到队列,最后执行 EXEC 提交。如果提交时,被 WATCH 的 key 的值已经发生变化(被其他客户端修改),则 EXEC 返回 nil(空),表示事务执行失败,需要重试。

经典应用场景:秒杀系统扣减库存

# Python 实现:基于 WATCH 的库存扣减
def decrease_stock(redis, product_id, count):
    key = f"stock:{product_id}"
    while True:
        redis.watch(key)
        stock = redis.get(key)
        if stock is None or int(stock) < count:
            redis.unwatch()
            return False
        pipe = redis.pipeline(transaction=True)
        pipe.decrby(key, count)
        result = pipe.execute()
        if result is not None:
            return True
    # 执行失败自动重试

WATCH 的注意事项

1. 重试成本

WATCH 失败后需要显式重试,这意味着客户端代码需要包含循环逻辑。在高并发场景下,如果冲突频繁,重试会导致额外的网络开销。

2. 监视多个 key

WATCH key1 key2 key3

如果任何一个被监视的 key 被修改,事务都会失败。

3. 连接断开后 WATCH 自动取消

WATCH 的作用范围限于当前连接的生命周期。如果客户端断开连接,所有 WATCH 监视会被自动清除。

4. 与 Lua 脚本的对比

Lua 脚本天然具有原子性,不存在并发安全问题,通常比 WATCH+MULTI 的事务模式更高效。在需要复杂逻辑的场景下,优先推荐使用 Lua 脚本。

方案 原子性 重试 适用场景
WATCH+MULTI/EXEC 事务原子性 需要客户端重试 简单 CAS 操作
Lua 脚本 脚本原子性 不需要 复杂业务逻辑

WATCH 的底层实现

Redis 在每个客户端中有 watched_keys 列表。当某个 key 被修改时(如 SET、DEL 等写命令),Redis 会检查所有监视该 key 的客户端,标记这些客户端的事务为”已脏”(dirty)。当这些客户端执行 EXEC 时,检测到 dirty 标志,直接放弃执行返回 nil。

面试要点

  • WATCH 是乐观锁,不是悲观锁
  • 适合冲突概率低的场景
  • 需要配合循环重试
  • 建议用 Lua 脚本替代复杂场景
  • 连接断开自动失效

WATCH 命令作用——Redis 的乐观锁实现

WATCH 命令作用——Redis 的乐观锁实现

WATCH 是什么

WATCH 是 Redis 提供的一个 乐观锁(Optimistic Lock) 机制。它的作用是监视一个或多个 key,如果在事务执行(EXEC)之前,这些 key 被其他客户端修改了,那么当前事务会自动失败。

一句话概括:WATCH 让你能够在事务中实现 CAS(Compare And Set)操作。

基本用法

redis> WATCH stock:1001           监视库存 key
OK
redis> GET stock:1001             查看当前库存
"10"
redis> MULTI                      开启事务
OK
redis> DECR stock:1001            扣减库存(入队)
QUEUED
redis> EXEC                       执行事务
1) (integer) 9                    执行成功

如果 WATCH 的 key 在事务执行前被修改:

# 客户端 1
redis> WATCH stock:1001           监视库存
OK
redis> GET stock:1001
"10"
redis> MULTI
OK
redis> DECR stock:1001             EXEC 之前...

                                # 客户端 2 修改了 stock:1001
                                redis> DECR stock:1001
                                (integer) 9

redis> EXEC
(nil)                           事务执行失败!因为 key 被修改了

CAS 的工作原理

客户端 A                             客户端 B
                                        ├─ WATCH stock:1001                    ├─ GET stock:1001  10                                                       ├─ MULTI                                                                   ├─ DECR stock:1001  9
  ├─ DECR stock:1001 (入队)                                                    ├─ EXEC                                    (nil)                              因为 key 被修改了!                    事务自动放弃                       

与数据库乐观锁的对比

特性 Redis WATCH 数据库乐观锁(版本号)
本质 监视 key 变化 版本号比较
实现方式 客户端跟踪 SQL WHERE version = ?
失败处理 EXEC 返回 nil 更新行数为 0
重试 需要手动重试 需要手动重试
性能 非常高(内存操作) 中等

实际应用场景

场景一:库存扣减

public boolean deductStock(Long productId, int quantity) {
    String key = "stock:" + productId;

    // 使用 WATCH + 事务实现安全扣减
    return redisTemplate.execute(new SessionCallback<Boolean>() {
        @Override
        public Boolean execute(RedisOperations ops) {
            while (true) {  // 重试循环
                ops.watch(key);

                // 获取当前库存
                Integer stock = (Integer) ops.opsForValue().get(key);
                if (stock == null || stock < quantity) {
                    ops.unwatch();
                    return false;  // 库存不足
                }

                ops.multi();
                ops.opsForValue().decrement(key, quantity);

                List<Object> results = ops.exec();
                if (results != null && !results.isEmpty()) {
                    return true;  // CAS 成功!
                }
                // CAS 失败(被其他客户端修改了),重试
            }
        }
    });
}

场景二:分布式计数器

// 多操作组合的原子性保证
public boolean incrementUserStats(String userId) {
    String loginKey = "stats:" + userId + ":logins";
    String pointsKey = "stats:" + userId + ":points";

    return redisTemplate.execute(new SessionCallback<Boolean>() {
        @Override
        public Boolean execute(RedisOperations ops) {
            ops.watch(loginKey, pointsKey);

            ops.multi();
            ops.opsForValue().increment(loginKey);  // 登录次数 +1
            ops.opsForValue().increment(pointsKey, 5);  // 积分 +5

            List<Object> results = ops.exec();
            return results != null && !results.isEmpty();
        }
    });
}

WATCH 的注意事项

1. 自动释放

redis> WATCH key1 key2     监视 key1  key2
OK
redis> EXEC                执行后,WATCH 自动取消
# 或者
redis> UNWATCH             手动取消所有监视
OK

2. 连接断开自动释放

如果 WATCH 后连接断开,所有监视自动取消。

3. 仅能监视单个 Redis 节点

WATCH 是针对单个 Redis 实例的,无法跨节点使用。

WATCH 与事务的配合

WATCH 和 MULTI/EXEC 配合使用的典型模式:

WATCH key1 key2 ...     监视
GET key1 / GET key2     读取当前值(业务判断基准)
MULTI                    开启事务
命令1                    入队
命令2
EXEC                     执行
                         如果 WATCH key 被修改  返回 nil
                         如果没被修改  执行成功,返回结果
                         自动 UNWATCH

WATCH 和 Lua 脚本的选择

特性 WATCH + 事务 Lua 脚本
原理 乐观锁 服务端直执行
重试 需要手动 不需要
复杂逻辑 适合读-判断-写场景 适合纯写场景
读依赖 可以在事务外读 脚本内可以读
性能 可能多次重试 一次性执行

建议:如果操作需要读取当前值再做决定,且读取必须在事务外,用 WATCH+事务。如果操作逻辑完全在 Redis 内部,用 Lua 脚本更简单。

面试要点

  • WATCH 是实现 Redis 乐观锁的核心命令
  • 本质是 CAS(Compare And Swap) 机制的 Redis 实现
  • WATCH + MULTI + EXEC 的组合被广泛用于库存扣减、计数器等场景
  • 需要手动重试:执行失败不会自动重试,由业务代码控制
  • 与 Lua 脚本的区别:WATCH 适合”读后判断再写”的场景,Lua 适合”纯写”场景
  • 面试时可以举例:WATCH 类似 Java 的乐观锁(AtomicInteger、StampedLock)

其他分布式锁方案——Redis 之外的分布式协调之道

其他分布式锁方案——Redis 之外的分布式协调之道

为什么需要其他方案

Redis 分布式锁虽然应用广泛,但不是万能的。在某些场景下,其他方案可能更合适:

场景 Redis 锁的局限性 替代方案
金融交易、资金操作 主从丢锁风险、理论缺陷 ZooKeeper / Etcd
已有 ZooKeeper 基础设施 引入 Redis 增加运维成本 ZooKeeper Curator
云原生环境 Redis 不是 CNCF 项目 Etcd
中小型项目、一致性要求不高 不想引入额外中间件 数据库乐观锁

方案一:ZooKeeper 分布式锁

核心原理

ZooKeeper 使用 临时顺序节点(Ephemeral Sequential Node) 实现分布式锁:

1.  ZK  /locks/ 下创建临时顺序节点 /locks/lock-000000001
2. 获取 /locks/ 下所有子节点排序
3. 判断自己的节点序号是否最小
     获得锁
     监听前一个节点的删除事件
4. 前一个节点删除  重复步骤 2-3

Curator 实现

@Bean
public CuratorFramework curatorFramework() {
    return CuratorFrameworkFactory.newClient(
        "zk1:2181,zk2:2181,zk3:2181",
        new ExponentialBackoffRetry(1000, 3)
    );
}

public void processWithZkLock() {
    InterProcessMutex lock = new InterProcessMutex(client, "/locks/order");

    try {
        if (lock.acquire(10, TimeUnit.SECONDS)) {
            try {
                processOrder();
            } finally {
                lock.release();
            }
        }
    } catch (Exception e) {
        // 异常处理
    }
}

ZooKeeper vs Redis 对比

特性 ZooKeeper Redis
一致性模型 CP(强一致性) AP(最终一致性)
锁自动释放 Session 超时 + 临时节点 TTL 过期
可重入 支持(Curator) 需自行实现或 Redisson
性能 ~1万 QPS(读写比为瓶颈) ~10万 QPS
实现复杂度 Curator 封装较好 Redisson 封装较好
部署复杂度 需要 ZK 集群 Redis 集群通常已有

方案二:Etcd 分布式锁

核心原理

Etcd 使用 Lease(租约)+ Grant API 实现分布式锁:

// Go 语言示例(etcd 官方 concurrency 包)
import "go.etcd.io/etcd/client/v3/concurrency"

func acquireLock() error {
    session, _ := concurrency.NewSession(cli)
    // Session 自动续约 lease
    mutex := concurrency.NewMutex(session, "/mylock/")
    // 尝试加锁,超时 5 秒
    return mutex.Lock(context.TODO())
}

Etcd 的核心优势

  1. Raft 共识算法:强一致性保证,不会丢锁
  2. Lease 机制:基于租约的自动续约,类似 Watchdog
  3. TTL + 心跳:客户端崩溃后锁自动释放
  4. 云原生生态:Kubernetes 使用 Etcd,云原生场景天然适配

Etcd vs Redis vs ZooKeeper

特性 Etcd Redis ZooKeeper
一致性 Raft(强一致性) 异步复制(弱) ZAB(强一致性)
API gRPC RESTful Redis Protocol 自定义协议
云原生 ✅ CNCF 项目
复杂度 中等 中高
性能 ~10万写/秒 ~10万写/秒 ~1万写/秒
运维 简单(etcdctl) 简单 中等

方案三:数据库分布式锁

基于唯一索引

CREATE TABLE distributed_lock (
    lock_key VARCHAR(64) PRIMARY KEY,
    holder_id VARCHAR(64) NOT NULL,
    expire_at DATETIME NOT NULL,
    version INT NOT NULL DEFAULT 0
);

-- 加锁:插入成功即获得锁
INSERT INTO distributed_lock (lock_key, holder_id, expire_at)
VALUES ('order_lock_1001', 'client_A', NOW() + INTERVAL 30 SECOND);
-- 唯一索引冲突 → 插入失败 → 加锁失败

基于 SELECT FOR UPDATE

-- 悲观锁
BEGIN;
SELECT * FROM distributed_lock 
WHERE lock_key = 'order_lock_1001' AND holder_id = 'client_A'
FOR UPDATE;
UPDATE resource SET status = 'processing' WHERE order_id = 1001;
COMMIT;

数据库方案的优缺点

优点 缺点
无需额外中间件 性能差(几千 QPS)
事务和锁天然结合 行锁可能导致死锁
数据可靠(ACID) 连接池压力大
已有数据库,零依赖 扩展性差

方案四:基于内存的分布式锁

Memcached

add lock_key request_id 30  ← 类似 SETNX+EXPIRE,原子操作

Memcached 没有主从、没有持久化,可靠性比 Redis 还弱。

如何选择合适的方案

// 决策树
if (已部署 ZK/Etcd) {
    // 直接使用已有的协调组件
    return ZooKeeperCurator / EtcdLock;
} else if (一致性要求极高) {
    // 金融、交易场景
    return EtcdLock;          // 云原生首选
    // 或 ZooKeeperCurator;  // 传统架构首选
} else if (已有 Redis) {
    if (锁丢失可接受 || 有补偿机制) {
        return RedissonLock;  // 足够
    } else {
        return Redlock;       // 多节点
    }
} else {
    // 没有分布式协调中间件
    return DataBaseLock;      // 临时的简单方案
}

面试要点

  • 面试时不要只提 Redis 锁,展示你了解完整的分布式锁生态
  • Redis Redis 入门级方案,ZooKeeper/Etcd 是专业级方案
  • ZooKeeper 的核心是临时顺序节点 + Watcher 监听,强一致性但性能不如 Redis
  • Etcd 是云原生时代的新选择,Raft 共识 + Lease 机制
  • 最好能给出选择不同方案的具体条件(一致性要求 + 现有基础设施 + 性能要求)
  • 加分项:提到 Fencing Token 机制和隔离保证(Martin Kleppmann 的建议)

锁续约机制——让分布式锁适应不确定的业务耗时

锁续约机制——让分布式锁适应不确定的业务耗时

什么是锁续约

锁续约(Lock Renewal / Lease Extension)是指在锁的 TTL 即将到期时,延长锁的生命周期,确保在业务处理期间锁不会自动释放。

一句话概括:当业务需要更长的时间时,锁也跟着”续命”。

为什么需要锁续约

业务耗时的不确定性

在实际系统中,业务执行时间往往是不确定的:

// 锁 TTL = 5 秒
lock(key, value, 5, TimeUnit.SECONDS);

// 业务执行时间取决于很多因素:
// - 数据库查询速度(可能慢查询)
// - 外部 API 调用(可能超时重试)
// - GC pause(Stop-The-World)
// - 网络波动(RPC 延迟)
doBusiness();  // 有时 100ms,有时 5 秒+

没有续约机制时只能这样选择:
设死固定值:要么太长(死锁窗口大),要么太短(业务没完成锁过期)
设很短:风险较高

业务 “刚好” 超出 TTL 的场景

时间线:

0s:   加锁成功(TTL=5s)
1-4s: 执行业务逻辑...
5s:   锁自动过期 ❗(业务还在执行!)
5s:   客户端 B 获取到同一把锁
6s:   客户端 A 业务完成,释放锁(释放了 B 的锁)
6s:   客户端 A 和 B 同时操作同一资源 → 并发异常

续约实现方案

方案一:定时续约(Watchdog 模式)

客户端启动一个后台定时器,在锁持有期间持续续约:

public class LockRenewalService {
    private final StringRedisTemplate redis;
    private final ScheduledExecutorService scheduler;
    private final Map<String, ScheduledFuture> renewalTasks = new ConcurrentHashMap<>();

    public void startRenewal(String key, String holder, int ttlSeconds) {
        ScheduledFuture task = scheduler.scheduleAtFixedRate(() -> {
            // 续约 Lua 脚本:检查持有者后续约
            String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                         "  return redis.call('expire', KEYS[1], ARGV[2]) " +
                         "else return 0 end";
            redis.execute(new DefaultRedisScript<>(lua, Long.class),
                Collections.singletonList(key), holder, String.valueOf(ttlSeconds));
        }, ttlSeconds / 3, ttlSeconds / 3, TimeUnit.SECONDS);

        renewalTasks.put(key, task);
    }

    public void stopRenewal(String key) {
        ScheduledFuture task = renewalTasks.remove(key);
        if (task != null) {
            task.cancel(false);
        }
    }
}

核心参数
– 检查周期:TTL / 3(例如 TTL=30s,每 10s 续约一次)
– 续约时长:TTL(每次续满 TTL)

方案二:条件续约(检查进度)

续约前检查业务是否还在执行:

public class ConditionalRenewal {
    private volatile boolean businessCompleted = false;

    public void executeWithRenewal(String key, String holder, int ttlSeconds) {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

        scheduler.scheduleAtFixedRate(() -> {
            if (businessCompleted) {
                return;  // 业务已完成,不续约
            }
            // 续约
            renewLock(key, holder, ttlSeconds);
        }, ttlSeconds / 3, ttlSeconds / 3, TimeUnit.SECONDS);

        try {
            doBusiness();
        } finally {
            businessCompleted = true;
            scheduler.shutdown();
            unlock(key, holder);
        }
    }
}

方案三:服务端续约

有些中间件在服务端实现续约,客户端只需要保持心跳即可。ZooKeeper 的 Session 机制就是这类代表。

Redisson 的续约实现

Redisson 的 Watchdog 是最成熟的续约实现之一,我们已在前文详细分析,这里再次总结其续约流程:

RLock lock = redissonClient.getLock("myLock");
lock.lock();  // 启动 Watchdog

// Watchdog 内部:
// 1. 加锁时设置 TTL = 30 秒(默认)
// 2. 启动定时任务,每 10 秒执行一次
// 3. 执行续约 Lua 脚本(检查 hexists,刷 pexpire)
// 4. 解锁时取消续约任务

续约机制的挑战

挑战一:续约可能失败

// 场景:批量续约时 Redis 连接池满了
// 或 Redis 负载过高,续约超时
// → 锁没有续上 → 锁过期 → 其他客户端获取锁

应对:监控续约失败率,设置告警。

挑战二:续约的放大效应

// 1000 个锁同时续约,Redis 压力骤增
// 正常:1000 个 key 同时被 EXPIRED
// 续约:每 10 秒 1000 个 EXPIRED 命令

应对:热点场景下避免过多锁。

挑战三:GC pause 导致续约失败

Full GC 发生时,续约线程也暂停了。GC 恢复后,锁可能已经过期。

应对:减少 GC pause,使用 ZGC/G1 或设置足够保守的 TTL。

续约 vs 固定 TTL 的选择

维度 固定 TTL 续约机制
实现复杂度
业务适用性 耗时可预测 耗时不可预测
死锁风险 固定死锁窗口 取决于续约频率
资源消耗 有额外定时任务开销
灵活性

面试要点

  • 锁续约的本质是让锁的 TTL 自适应业务执行时间
  • Watchdog 是最经典的续约实现(每 TTL/3 续约一次)
  • 续约不是免费午餐:消耗 Redis 资源、续约本身可能失败
  • 能说出续约失败的后果和应对方案
  • 如果能结合 Redisson Watchdog 源码分析续约流程,那是大加分项

分布式锁性能优化——从能用用到好用的进阶之路

分布式锁性能优化——从能用用到好用的进阶之路

分布式锁的性能瓶颈

分布式锁虽然解决了并发安全问题,但它本质上是一个同步点。大规模使用分布式锁时,性能问题会变得突出:

瓶颈 表现 影响
加锁/解锁的 RTT 每次加锁都是一次 Redis 网络往返 增加请求延迟
锁竞争 大量线程竞争同一把锁 线程阻塞,吞吐量下降
Redis 请求量 每秒数万个锁操作 Redis 负载上升
续约开销 Watchdog 定时续约 增加无谓的 Redis QPS

优化一:减少锁的粒度

热点锁 vs 细分锁

// ❌ 粗粒度:订单500万的库存用一个锁
String lockKey = "lock:stock";  // 所有人抢订单500万的库存都阻塞在这个锁上

// ✅ 细粒度:按商品 ID 拆分
String lockKey = "lock:stock:" + productId;  // 不同商品的库存互不影响

// ✅ 更细粒度:库存充足时用乐观锁,不需要分布式锁
String sql = "UPDATE stock SET count = count - 1 " +
             "WHERE product_id = ? AND count > 0";  // 数据库乐观锁

分段锁

类似 ConcurrentHashMap 的 Segment 思想:

// 把库存分成 10 个段
int segment = (int)(Math.abs(productId.hashCode()) % 10);
String lockKey = "lock:stock:" + productId + ":seg:" + segment;

// 扣减时只锁对应的段
// 理论上支持的并发量提升 10 倍

优化二:减少锁的持有时间

锁住的时间越短,其他线程等待的时间越短。

// ❌ 在持有锁期间做慢操作
public void processOrder(Order order) {
    lock.lock();
    try {
        validateOrder(order);      // 外部 API 调用,可能很慢
        deductStock(order);         // 数据库操作
        sendNotification(order);    // 发送通知,可能阻塞
        updateCache(order);         // 缓存更新
    } finally {
        lock.unlock();
    }
}

// ✅ 锁内只做必要的保护操作,耗时操作移到锁外
public void processOrder(Order order) {
    String lockKey = "lock:stock:" + order.getProductId();
    lock.lock();
    try {
        boolean deducted = deductStock(order);  // 只需保护这一步
        if (!deducted) throw new StockException("库存不足");
    } finally {
        lock.unlock();
    }
    // 耗时操作移到锁外执行
    validateOrder(order);
    sendNotification(order);
    updateCache(order);
}

优化三:减少锁的等待时间

非阻塞 TryLock

// ❌ 阻塞等待:拿到锁或者一直等下去
lock.lock();  // 可能会阻塞很久

// ✅ 超时自动放弃
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) {
    // 快速失败,避免线程堆积
    throw new TooManyRequestsException("系统繁忙");
}

设置合理的等待超时

// 根据场景设置不同的等待时间
enum LockTimeout {
    FAST(10),    // 缓存更新:10ms 拿不到就放弃
    NORMAL(100), // 写入操作:100ms
    SLOW(1000);  // 复杂业务:1秒
}

优化四:减少 Redis 请求数

批量合并续约

如果系统中有大量锁,Watchdog 的续约请求会对 Redis 产生较大压力:

// ❌ 每个锁单独续约(1000 个锁 = 1000 条命令)
cache.eachLock((key, holder) -> renewLock(key, holder));

// ✅ 使用 pipeline 批量续约
List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) conn -> {
    for (Map.Entry<String, String> entry : activeLocks.entrySet()) {
        String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                     "then redis.call('expire', KEYS[1], ARGV[2]) end";
        conn.eval(lua.getBytes(), ReturnType.fromJavaType(Long.class),
                  1, entry.getKey().getBytes(),
                  entry.getValue().getBytes(),
                  String.valueOf(ttl).getBytes());
    }
    return null;
});

减少续约频率

// 默认每 TTL/3 续约一次
// TTL 越大 → 续约频率越低 → Redis 压力越小
// TTL = 30s → 每 10s 续约
// TTL = 60s → 每 20s 续约

// 但 TTL 过长也有问题
// 所以这是一个权衡:缩短续约频率 vs 增大死锁窗口

优化五:本地缓存锁状态

在部分场景中,获取锁前可以先检查本地缓存的状态,避免不必要的 Redis 访问:

public class OptimizedLock {
    private final Map<String, Boolean> localLockState = new ConcurrentHashMap<>();

    public boolean tryLock(String key) {
        // 先检查本地状态(可选,根据业务场景)
        if (Boolean.FALSE.equals(localLockState.get(key))) {
            return false;  // 提前判断锁不可用
        }
        // 再去 Redis 加锁
        boolean locked = redisLock.tryLock(key);
        if (locked) {
            localLockState.put(key, true);
        }
        return locked;
    }
}

性能对比

优化措施 提升效果 实现难度
粒度细分 10-100x
缩短持有时间 2-5x
非阻塞 tryLock 减少线程堆积
Pipeline 批量 减少 RTT
分段锁 10-100x
本地缓存状态 减少 Redis 调用
合理 TTL + 续约 减少续约开销

面试要点

  • 分布式锁性能优化的核心思路:尽量不分锁,分不了就少分,分了就短持
  • 能说出具体案例:库存扣减从粗粒度锁改成按 productId 分段,QPS 提升 10 倍
  • 区分”锁的粒度”和”锁的持有时间”是两个不同的优化维度
  • 提到 pipeline 续约是高级优化思路
  • 最后强调:缓存该做的事情交给缓存做,不要什么操作都加锁

锁超时时间设置——分布式锁最关键的参数调优

锁超时时间设置——分布式锁最关键的参数调优

问题背景

超时时间是分布式锁中最重要的参数。设错了会有两种截然不同的后果:

设得太短 → 业务没执行完锁就过期了 → 其他客户端也拿到锁 → 并发安全隐患
设得太长 → 客户端崩溃后锁长时间不释放 → 资源长时间不可用 → 影响可用性

只有设得刚好 → 业务执行完之前锁不释放,客户端崩溃后锁尽快消失

但 “刚好” 在分布式系统中几乎无法精确确定。

超时时间的计算依据

方法一:基于业务执行时间(固定值)

// 假设业务平均耗时 200ms,最大耗时 1s
// TTL 设置 = 最大耗时 × 3(预留余量)
int ttl = 3000;  // 3秒
lock.tryLock(key, value, ttl, TimeUnit.MILLISECONDS);

优点:简单直接
缺点:余量取多少不好说,重度慢查询可能大幅超标

方法二:基于预估最大值 + 安全缓冲区

// 步骤:
// 1. 压测得到业务最大执行时间:maxExecutionMs
// 2. 考虑最坏情况:Full GC = 200ms, 网络延迟 = 100ms
// 3. 安全缓冲区:以上总和的 2-3 倍
long maxExecutionMs = 1500;           // 压测数据
long gcPauseMs = 200;                 // Full GC 假停
long networkDelayMs = 100;            // Redis 网络延迟
long safetyFactor = 3;                // 安全系数

int ttl = (int)((maxExecutionMs + gcPauseMs + networkDelayMs) * safetyFactor);
// = (1500 + 200 + 100) × 3 = 5400ms → 约 5.4 秒

方法三:使用 Watchdog 动态续约(推荐)

不需要精确预估,锁会被一直续约到业务完成:

RLock lock = redissonClient.getLock("myLock");

// 不传 leaseTime,Watchdog 接管
lock.lock();

// 业务执行 30 秒,锁被续约 3 次(每 10 秒续约一次)
try {
    doBusiness();  // 可能 5 秒,也可能 30 秒
} finally {
    lock.unlock();  // 业务完成,释放锁
}

业务场景的最佳实践

场景一:微秒级操作(缓存更新、状态变更)

// 操作耗时 < 1ms,设 1 秒足矣
lock.tryLock(key, value, 1, TimeUnit.SECONDS);

场景二:毫秒级操作(数据库操作、RPC 调用)

// 操作耗 50-500ms,设 3-5 秒
lock.tryLock(key, value, 5, TimeUnit.SECONDS);

场景三:秒级操作(文件处理、批量计算)

// 操作耗时可能需要几秒
// 方案 1:固定 TTL + 较大余量
lock.tryLock(key, value, 30, TimeUnit.SECONDS);

// 方案 2:使用 Watchdog(推荐)
lock.lock();

场景四:超长任务(数据同步、定时任务)

// 操作可能持续分钟级,不建议固定 TTL
// ✅ 使用 Watchdog
lock.lock();

// 或者结合心跳 + 主动续约
while (hasMoreWork()) {
    lock.lock(30, TimeUnit.SECONDS);
    doBatchWork();
    lock.unlock();
}

超时时间过短导致的问题

问题现象

// 锁 TTL = 2 秒,但业务执行需要 3 秒
lock.tryLock(key, value, 2, TimeUnit.SECONDS);

// T=0:  加锁成功
// T=0~2: 业务执行中...
// T=2:  锁自动过期        
// T=2:  客户端 B 获取同锁成功 ← ⚠️ 并发安全问题
// T=3:  客户端 A 业务完成
// T=3:  客户端 A 释放锁 → 释放的是客户端的锁 ⚠️

解决方案

  1. 估大不估小:宁可设长一点,也要避免业务未完成锁先过期
  2. 使用 Watchdog:让框架处理续约
  3. 加监控告警:监控锁持有的时长分布,发现异常及时调整

超时时间过长导致的问题

问题现象

// 锁 TTL = 30 分钟
lock.tryLock(key, value, 30, TimeUnit.MINUTES);

// 客户端获取锁后崩溃了...
// 锁需要 30 分钟才能自动释放
// 这 30 分钟内其他客户端无法获取该锁 ← ⚠️ 可用性问题

解决方案

  1. 设短默认,用 Watchdog 续约:默认 30 秒,续约到业务完成
  2. 增强容错:使用 tryLock(waitTime, leaseTime) 设置等待时间,不要死等

实际生产中的经验值

业务类型 推荐 TTL 理由
高并发秒杀(扣库存) 1-3 秒 操作极快,TTL 可以短
订单生成 5-10 秒 涉及多个 DB 操作
文件导入 30 秒 – 5 分钟 不确定耗时,Watchdog 接管
定时任务调度 使用 Watchdog 任务时长不确定
默认值(所有场景兜底) 30 秒 Redisson 默认值

面试要点

  • 超时时间是一个权衡值:既要防止业务未完成锁过期,也要防止客户端崩溃后死锁
  • 核心建议:“估大不估小,用 Watchdog 兜底”
  • 设短了的后果比设长了更严重(并发安全问题 vs 可用性问题)
  • 如果你手动设置 leaseTime,要确保它大于业务最大执行时间
  • 如果你用 Watchdog,可以不用太关心具体数值,框架会处理

Lua 脚本在锁中作用——Redis 分布式锁的原子性基石

Lua 脚本在锁中作用——Redis 分布式锁的原子性基石

为什么需要 Lua 脚本

Redis 的 Lua 脚本是分布式锁实现中最关键的环节。没有 Lua 脚本,分布式锁的几乎所有操作都存在竞态条件。

关键问题:Redis 的每一个命令都是原子性的,但是多个命令的组合不是。

Lua 脚本解决了什么问题

1. 解锁时验证持有者

// ❌ 错误写法:非原子操作
String curValue = redis.get("lock_key");     // 命令1
if (requestId.equals(curValue)) {             // 比较
    redis.del("lock_key");                    // 命令2
}

问题:命令1 和 命令2 之间:
– 锁过期了
– 别的客户端拿到了锁
– 我们的 DEL 释放了别人的锁

Lua 解决方案

if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end

整个脚本作为一个原子操作执行,中间不会有其他命令插入。

2. 可重入锁的加锁

-- 这是一个逻辑上需要多步操作但必须原子执行的场景
-- 检查锁是否存在 → 不存在则创建 → 存在则判断持有者 → 重入

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return 1
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return 1
end
return redis.call('pttl', KEYS[1])

如果不使用 Lua,需要多次往返 Redis + 本地逻辑判断,任何一次网络中断都会破坏一致性。

3. 可重入锁的解锁

-- 检查持有者 → 计数减一 → 判断是否归零 → 删除或保留

if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 0
else
    redis.call('del', KEYS[1])
    return 1
end

4. Watchdog 续约

-- 检查锁是否还存在 → 存在则续约

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end
return 0;

没有 Lua 的话,检查锁存在和续约两个操作之间可能被别的事务打断。

5. Redlock 的多节点操作

Redlock 客户端不需要一次性原子操作所有节点,但在每个节点内仍然要用 Lua 保证单节点操作的原子性:

for (RedisClient node : nodes) {
    // 每个节点的 SET NX EX 本身是原子的(单个命令)
    // 但解锁时需要 Lua 保证原子
    node.eval(unlockScript, keys, args);
}

Lua 脚本在 Redis 中的执行机制

原子性

Redis 使用单线程执行脚本,脚本执行期间:
– 不会处理其他客户端的命令
– 不会执行其他脚本
– 实现了完整的事务隔离(类似于 SERIALIZABLE)

事务性

EVAL 命令执行失败时,脚本中所有已执行的写操作不会回滚,这一点需要开发者注意。

性能

良好的 Lua 脚本应在毫秒级完成,否则会阻塞整个 Redis:

-- ❌ 危险:脚本中不要有长时间操作
while true do
    -- 永远循环 → 阻塞 Redis 所有操作
end

-- ✅ 正确:短小精悍,只操作几个 key
if redis.call('get', KEYS[1]) == ARGV[1] then
    redis.call('del', KEYS[1])
end

Lua 脚本在分布式锁中的核心价值

操作 无 Lua 会怎样 Lua 解决
加锁 无法原子执行 SETNX + EXPIRE SET NX EX(命令级)或 Lua
解锁 先 GET 再 DEL(不是原子) GET + 比较 + DEL 原子执行
可重入 检查、累加、设过期三步分开 一步完成
Watchdog 检查锁存在 + 续约分开 检查 + 续约原子执行
批量操作 逐条执行有间隙 整批原子执行

面试要点

  • Lua 脚本的核心价值:将多条 Redis 命令合并为原子操作
  • 分布式锁的所有复杂操作(加锁、解锁、重入、续约)都需要 Lua 脚本
  • Lua 脚本在 Redis 中是串行执行的,天然原子
  • 脚本不应该执行耗时操作(不会导致死锁,但会阻塞 Redis)
  • 注意:Redis Lua 脚本中执行的命令不保证回滚
  • Redisson 内部大量使用 Lua 脚本,这也是为什么推荐直接使用 Redisson 而不是手写

可重入锁实现——让同一个线程能重复获取同一把锁

可重入锁实现——让同一个线程能重复获取同一把锁

什么是可重入锁

可重入锁(Reentrant Lock)允许同一个线程在持有锁的情况下,再次获取同一把锁而不会被阻塞。这在嵌套调用、递归等场景中至关重要。

没有可重入性的后果——自己把自己锁死

public void methodA() {
    lock.lock();       // ✅ 第一次加锁成功
    methodB();         // 调用 methodB
    lock.unlock();
}

public void methodB() {
    lock.lock();       // ❌ 第二次加锁失败(如果没有重入支持)
    // 这里永远等不到...
    lock.unlock();
}

如何在 Redis 上实现可重入锁

方案一:使用 Hash 数据结构

不能用简单的 String Key,因为不能记录持有者信息和重入次数。Hash 结构天生适合:

Key:    "lock:order:1001"
Fields: "uuid:thread1" → 3    (字段名是持有者标识,值是重入次数)

加锁 Lua 脚本

-- KEYS[1] = 锁的 key
-- ARGV[1] = TTL(毫秒)
-- ARGV[2] = 持有者标识(UUID:threadId)

if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,创建并设置持有者和重入次数 1
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return 1
end

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 当前持有者重入,计数 +1
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return 1
end

-- 被其他线程持有,返回失败
return 0

解锁 Lua 脚本

-- KEYS[1] = 锁的 key
-- ARGV[1] = 持有者标识

if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    -- 不是自己的锁或锁已不存在
    return nil
end

local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1)
if (counter > 0) then
    -- 重入计数还没归零,只是减少一次
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 0
else
    -- 重入计数归零,删除锁
    redis.call('del', KEYS[1])
    return 1
end

Java 实现

public class RedisReentrantLock {
    private final StringRedisTemplate redis;
    private final ThreadLocal<String> holderLocal = new ThreadLocal<>();

    public boolean lock(String key, int expireMs) {
        String holder = holderLocal.get();
        if (holder == null) {
            holder = UUID.randomUUID() + ":" + Thread.currentThread().getId();
            holderLocal.set(holder);
        }

        String lua = 
            "if (redis.call('exists', KEYS[1]) == 0) then " +
            "  redis.call('hset', KEYS[1], ARGV[1], 1); " +
            "  redis.call('pexpire', KEYS[1], ARGV[2]); " +
            "  return 1; " +
            "end; " +
            "if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then " +
            "  redis.call('hincrby', KEYS[1], ARGV[1], 1); " +
            "  redis.call('pexpire', KEYS[1], ARGV[2]); " +
            "  return 1; " +
            "end; " +
            "return 0;";

        Long result = redis.execute(
            new DefaultRedisScript<>(lua, Long.class),
            Collections.singletonList(key),
            holder, String.valueOf(expireMs)
        );
        return Long.valueOf(1).equals(result);
    }

    public boolean unlock(String key) {
        String holder = holderLocal.get();
        if (holder == null) return false;

        String lua = 
            "if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then " +
            "  return nil; " +
            "end; " +
            "local cnt = redis.call('hincrby', KEYS[1], ARGV[1], -1); " +
            "if (cnt == 0) then " +
            "  redis.call('del', KEYS[1]); " +
            "  return 1; " +
            "end; " +
            "return 0;";

        redis.execute(
            new DefaultRedisScript<>(lua, Long.class),
            Collections.singletonList(key), holder
        );
        return true;
    }
}

Redisson 的可重入实现

Redisson 内部已经实现了完整的可重入锁。用法简单:

RLock lock = redissonClient.getLock("myLock");

lock.lock();     // 第一次加锁
lock.lock();     // 第二次加锁(重入计数 +1)
lock.lock();     // 第三次加锁(重入计数 +1)

lock.unlock();   // 重入计数 -1
lock.unlock();   // 重入计数 -1
lock.unlock();   // 重入计数归零,真正释放

Redisson 用的也是 Hash 数据结构,原理和上面实现的类似。

可重入锁的注意事项

加锁和解锁必须成对出现

lock.lock();
lock.lock();
lock.unlock();  // 重入计数 -1,但锁仍未释放
// ⚠️ 忘记第二次 unlock → 锁永远不会释放

重入次数建议有限制

// 异常情况下可能无限重入
while (condition) {
    lock.lock();  // ⚠️ 如果循环没有退出条件,会无限重入
}

面试要点

  • 可重入锁的核心:同一线程能重复获取同一把锁
  • Redis 实现使用 Hash 数据结构(不是 String)
  • 加锁:不存在则创建(计数=1),己持有则计数+1
  • 解锁:计数-1,归零才删除锁
  • 持有者标识使用 UUID:ThreadId 组合来区分不同客户端的不同线程
  • Lua 脚本保证所有操作原子性

主从架构下锁缺陷——为什么主从 Redis 分布式锁不可靠

主从架构下锁缺陷——为什么主从 Redis 分布式锁不可靠

问题的根源

大多数公司的 Redis 部署架构是 主从 + Sentinel(或 Redis Cluster)。这种架构下,Master 负责写入,Slave 负责读和备份。但这套架构在分布式锁场景下有一个先天缺陷:

锁写入 Master 后,Master 宕机,锁还没有同步到 Slave,新的 Master 上就没有这把锁。

缺陷复现全过程

正常情况:
Client-A → Master (写入锁: lock_order ✓)
                  ↓ 同步
              Slave (lock_order ✓)

Client-A 释放锁 → 稳定运行

问题场景:
时间 | 事件
T1   | Client-A 向 Master 加锁成功(SET NX EX)
T2   | Master 突然宕机(锁未同步到 Slave)
T3   | Sentinel 选举 Slave 为新的 Master
T4   | Client-B 来加锁(新的 Master 上没有这把锁)
T5   | Client-B 加锁成功 ❗
T6   | Client-A 和 Client-B 都认为自己持有锁 → 互斥性被打破

为什么会发生

Redis 主从复制是异步的:

Client → Master (写入成功)
Master → Slave (异步同步,有延迟)

异步复制的原因:
1. 性能:同步复制会大幅降低写入性能
2. 架构设计:Redis 设计为 AP 系统(可用性和分区容忍性优先),不是 CP 系统(一致性优先)

这就导致了:
SET key value NX EX 30 返回成功时,只代表 Master 写入了
– 不代表 Slave 也有这把锁
– Master 立即宕机,锁就丢了

缺陷的概率有多大

这个缺陷的实际发生概率取决于三个因素:

因素 说明 概率
异步复制延迟 Master 到 Slave 的数据同步延迟 通常 <1ms,大流量下可能 10-100ms
Master 宕机频率 节点故障 很低的概率
锁竞争激烈程度 多个客户端同时抢锁 高并发下常见

综合来看:小流量系统几乎遇不到,高并发核心链路早晚会遇到。

几种应对方案

方案一:WAIT 命令(半同步复制)

Redis 提供了 WAIT 命令,可以让写操作等待指定数量的 Slave 确认后再返回:

> SET lock_order requestId NX EX 30
> WAIT 1 1000    ← 等待至少 1 个 Slave 确认,超时 1 秒

优点:确保锁已同步到至少一个 Slave
缺点:性能下降(增加同步等待时间),仍然不是 100% 可靠(WAIT 也可能超时)

方案二:Redlock 多节点方案

使用 5 个独立 Redis 节点,多数派加锁机制。

优点:不依赖主从复制,真正的多数派容错
缺点:需要部署 5 个独立节点,复杂度和成本高

方案三:ZooKeeper / Etcd

优点:CP 系统,强一致性保证,没有主从同步问题
缺点:系统复杂度增加,需要独立部署 ZooKeeper 集群

实际落地建议

// 方案选择策略
switch (场景) {
    case 非关键业务出错可补偿:
        // 使用 Redis 主从 + SET NX EX 就够
        // 出错概率低,即使出错了也有补偿机制
        break;
    case 关键互斥必须可靠:
        // 使用 Redlock(5节点)或 ZooKeeper
        // 接受更高的复杂度和成本
        break;
    case 高并发允许偶发的锁丢失:
        // 使用 Redis 主从 + 半同步(WAIT)
        // 平衡了可靠性和性能
        break;
}

面试中的常见问题

Q:你们的分布式锁用的什么方案?有没有遇到主从切换丢锁的问题?

建议分几个层次回答:

  1. 承认问题:Redis 主从架构下,分布式锁确实存在丢失风险
  2. 评估风险:我们评估了丢锁的概率和影响,在我们的场景下(库存扣减/防重复支付)可以接受,并且有补偿机制
  3. 防护措施:我们增加了监控(WAIT 命令的延迟和成功率),业务层有幂等和补偿
  4. 未来规划:如果业务发展到对一致性要求更高,会考虑迁移到 ZooKeeper 或 Redlock

面试要点

  • 根本原因:Redis 主从复制是异步的
  • 丢锁的可能性:Master 宕机 + 锁未复制到 Slave
  • 不是所有场景都需要解决这个问题——评估风险更重要
  • 能说出三种解决方案:WAIT 命令、Redlock、ZooKeeper
  • 如果面试官追问”那你们的方案怎么处理的”——回答思路展示思考深度,而不是提出完美方案

看门狗 Watchdog 机制——分布式锁的自动续约守护者

看门狗 Watchdog 机制——分布式锁的自动续约守护者

为什么要 Watchdog

手写 SET NX EX 分布式锁时,最头疼的问题之一是:

锁的超时时间设多长?

  • 设短了:业务没执行完锁就过期,其他客户端获取锁,并发安全被破坏
  • 设长了:客户端崩溃后,死锁窗口期太长,影响系统恢复速度

Watchdog 的出现就是为了解决这个两难问题。它的核心思想:“我不知道你要锁多久,但我每隔一段时间帮你续约一下,直到你说不用了。”

Watchdog 的工作原理

                    时间线 →
┌─────┐    ┌─────┐    ┌─────┐    ┌─────┐    ┌─────┐    ┌─────┐
│ 加锁│    │检查 │    │续约 │    │检查 │    │续约 │    │释放 │
│ T=0 │    │ T=9 │    │ T=10│    │ T=19│    │ T=20│    │ T=25│
└─────┘    └─────┘    └─────┘    └─────┘    └─────┘    └─────┘
   ↑          ↑          ↑          ↑          ↑          ↑
 加锁     还持有吗?   续30秒   还持有吗?   续30秒    释放锁
 (TTL=30)    Y→续约               Y→续约             Watchdog停止

Redisson Watchdog 的实现细节

默认参数

// Redisson 默认配置
lockWatchdogTimeout = 30 * 1000;  // 30秒,Watchdog 超时时间
// 内部定时任务每 lockWatchdogTimeout / 3 = 10 秒执行一次

启动 Watchdog

当调用 lock.tryLock()lock.lock() 但是没有传 leaseTime(锁租约时间),Redisson 会启动 Watchdog:

RLock lock = redissonClient.getLock("myLock");

// ⚠️ 不传 leaseTime → 启动 Watchdog
lock.lock();

// ⚠️ leaseTime=10秒 → 10秒后自动释放,不启动 Watchdog
lock.lock(10, TimeUnit.SECONDS);

Watchdog 核心逻辑

// Redisson Watchdog 的简化实现
private void scheduleExpirationRenewal(long threadId) {
    Timeout task = commandExecutor.getConnectionManager()
        .newTimerTask(() -> {
            // 1️⃣ 使用 Lua 脚本续约
            RFuture<Boolean> future = renewExpirationAsync(threadId);

            // 2️⃣ 如果续约成功,安排下一次续约
            if (future.get()) {
                scheduleExpirationRenewal(threadId);
            }
        }, 
        internalLockLeaseTime / 3,  // 每隔 TTL/3 检查一次
        TimeUnit.MILLISECONDS
    );
}

续约 Lua 脚本

-- Redisson Watchdog 续约 Lua 脚本
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 当前线程仍持有锁,刷新过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end
-- 锁已不存在(被释放或过期),不续约
return 0;

关键:Watchdog 只会在当前线程仍然持有锁的时候才会续约。

Watchdog 的停止时机

Watchdog 会在以下情况自动停止:

  1. 手动释放锁lock.unlock() → Lua 脚本删除 Hash 中的字段
    – 如果重入计数减到 0,删除整个 key
    – Watchdog 发现 key 不存在,不再续约
  2. 客户端崩渍:进程退出,Watchdog 线程退出
    – 锁会在 TTL 到期后自动释放
  3. 锁过期:如果 TTL 到期且 Watchdog 没续上
    – 例如:网络分区导致 Redisson 连不上 Redis

Watchdog 的缺陷和注意事项

缺陷一:时间误差

// 情况:Full GC 导致 Watchdog 线程也暂停了
lock.lock();  // TTL = 30s, Watchdog 每 10s 续约

// 突然 Full GC 15 秒...
// Watchdog 线程也暂停了,没有续约
// 锁在 30 秒后过期(实际上 Full GC 期间也过去了 15 秒)
// 锁还剩 15 秒 生命周期...

缺陷二:续约是无状态的

Watchdog 只按 TTL 续约,不关心业务实际执行进度。

注意事项

// 手动指定 leaseTime 时 Watchdog 不会启动
// 如果业务执行超过 leaseTime,锁自动释放
lock.lock(5, TimeUnit.SECONDS);  // 5 秒后自动释放,不管业务是否完成

// 不传 leaseTime 时 Watchdog 启动
// 业务执行多久,锁就续约多久
lock.lock();  // Watchdog 接管

实践建议
– 明确知道业务耗时的情况下,指定 leaseTime
– 不确定业务耗时的情况下,不传 leaseTime 让 Watchdog 兜底
– 两种方式都要配合 try-finally 确保手动释放

面试要点

  • Watchdog 解决的问题是 “锁超时时间不好确定”
  • 工作模式:每隔 TTL/3 检查一次,续约一次
  • 默认 TTL = 30s,检查间隔 = 10s
  • 续约使用 Lua 脚本,只在当前线程仍持有锁时才续约
  • 释放锁后 Watchdog 自动停止
  • 面试官可能会问:如果业务执行 2 小时,Watchdog 会一直续约吗?
  • 答案:是的,直到业务执行完成释放锁
  • 反问:那 Redis 挂了怎么办?——这就是 Watchdog 的局限,依赖 Redis 可用

Redisson 实现分布式锁——企业级生产的锁框架

Redisson 实现分布式锁——企业级生产的锁框架

Redisson 是什么

Redisson 是一个基于 Redis 的 Java 客户端框架,提供了丰富的分布式数据结构和服务。其中最有名的是它的分布式锁实现——几乎解决了手写 SET NX EX 的所有痛点。

相比自己手写 Redis 锁,Redisson 提供了:

  • 开箱即用的分布式锁 API
  • 自动续约机制(Watchdog)
  • 可重入支持
  • 多种锁模式(公平锁、读写锁、红锁)
  • Lua 脚本自动管理

快速上手

Maven 依赖


    org.redisson
    redisson-spring-boot-starter
    3.27.0

配置 Redisson 客户端

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://localhost:6379")
            .setConnectionPoolSize(10)
            .setConnectionMinimumIdleSize(5);
        return Redisson.create(config);
    }
}

使用分布式锁

@Autowired
private RedissonClient redissonClient;

public void processOrder(Long orderId) {
    RLock lock = redissonClient.getLock("lock:order:" + orderId);

    // 尝试加锁,最多等待 100 秒,加锁后 30 秒自动解锁
    boolean locked = lock.tryLock(100, 30, TimeUnit.SECONDS);
    if (!locked) {
        throw new RuntimeException("获取锁失败");
    }

    try {
        // 执行业务逻辑
        orderService.process(orderId);
    } finally {
        lock.unlock();
    }
}

Redisson 提供的锁类型

锁类型 类名 特性 使用场景
可重入锁 RLock 基础锁,支持重入 大部分场景
公平锁 RLockFair 保持请求顺序 先进先出场景
读写锁 RReadWriteLock 读读不互斥 读多写少
信号量 RSemaphore 限流控制 资源池管理
闭锁 RCountDownLatch 等待多任务完成 分布式协调
红锁 RLock (multiLock) 多节点加锁 一致性要求高

Redisson 锁的核心优势

1. 自动续约(Watchdog)

Redisson 默认启动一个 Watchdog 线程,在你持有锁期间持续续约:

// 如果不手动指定 TTL,Watchdog 默认续约 30 秒
// 每 10 秒检查一次,如果锁还在持有中,就刷新 TTL
lock.tryLock();  // 不传参数,默认使用 Watchdog

2. 可重入

同一线程可以多次获取同一把锁:

RLock lock = redissonClient.getLock("myLock");

lock.lock();     // 第一次加锁 → 成功
lock.lock();     // 第二次加锁 → 成功(重入计数 +1)

lock.unlock();   // 释放一次
lock.unlock();   // 再释放一次(真正释放)

3. 非阻塞加锁

// 尝试加锁,立即返回
boolean locked = lock.tryLock();

// 尝试加锁,等待 3 秒,超时返回 false
boolean locked = lock.tryLock(3, TimeUnit.SECONDS);

4. 自动释放

// 加锁 10 秒后自动释放(如果不续约的情况下)
lock.lock(10, TimeUnit.SECONDS);

使用 try-finally 手动释放是好习惯,Watchdog 是兜底机制。

Redisson 如何实现锁

Redisson 的锁基于 Redis 的 Hash 数据结构存储:

-- Redisson 加锁 Lua 脚本(简化版)
if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,创建锁并设置过期时间
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    -- 锁已存在并且是当前线程持有,重入计数+1
    redis.call('hincrby', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil
end
-- 锁被其他线程持有,返回剩余 TTL
return redis.call('pttl', KEYS[1])

Key 是锁的名字,Field 是持有者标识(UUID:threadId),Value 是重入次数。

与手写锁对比

特性 手写 SET NX EX Redisson
实现难度 较高 开箱即用
可重入 需自行实现 原生支持
自动续约 需自行实现 Watchdog
代码量 50+ 行 3 行
功能完整度 基础 丰富
社区 活跃、成熟

面试要点

  • Redisson 是目前 Java 生态使用最广泛的 Redis 分布式锁框架
  • 核心功能:可重入锁、Watchdog 自动续约、Lua 脚本管理
  • 加锁的 Lua 脚本使用 Hash 结构存储(不是简单的 String)
  • Watchdog 工作原理:后台线程每 10 秒检查一次并续约 30 秒
  • 推荐用法:lock.tryLock(waitTime, leaseTime, unit) 明确设置 leaseTime,Watchdog 作为兜底

分布式锁核心要素——一把合格的分布式锁需要满足什么

分布式锁核心要素——一把合格的分布式锁需要满足什么

一把合格的分布式锁

分布式锁不是一个简单的抢 key 操作,它需要满足一系列严格的要求。面试官常常通过考察你能否说出分布式锁的核心要素,来判断你的技术水平是停留在”会用”还是”理解原理”。

五大核心要素

1. 互斥性——最重要的要素

在任意时刻,只有一个客户端能持有锁。

客户端 A 获取锁成功 → 持有锁
客户端 B 尝试获取同一把锁 → 被阻塞或返回失败
客户端 A 释放锁 → 客户端 B 才能获取

评判标准:是否存在任何两个客户端同时认为”我持有锁”的可能。只要存在这种可能,互斥性就被破坏了。

2. 死锁预防——系统健壮性的底线

持有锁的客户端可能因为各种原因崩溃、网络异常、Full GC 导致无法释放锁。锁必须能够自动释放。

实现方式
– 设置锁的超时时间(TTL/lease time),到期自动释放
– 客户端崩溃后,不需要任何操作,锁自然消失

关键点:TTL 的设置需要考虑业务的最长执行时间。设得太短,业务没执行完锁就过期;设得太长,死锁窗口期太大。

3. 容错性——锁服务本身的高可用

锁服务应该是高可用的。如果锁服务是单点的,那么整个系统就面临单点故障风险。

架构 容错性 典型方案
单机 Redis 低(单点故障) 最简 SETNX
Redis 主从 中(主切从可能丢锁) SETNX + 主从
Redis 多节点 高(多数存活即可) Redlock
ZooKeeper 高(ZAB 协议保证) Curator

4. 可重入性——同一客户端能重复获取

持有锁的客户端在锁未释放前,需要再次获取同一把锁时应该成功。

public void a() {
    lock.lock();
    b();  // 内部也需要同一把锁
    lock.unlock();
}

public void b() {
    lock.lock();  // 如果不可重入,这里会死锁
    // 业务逻辑
    lock.unlock();
}

实现方式:在锁的值中记录持有者标识和重入计数,使用 Hash 结构或 ThreadLocal 实现。

5. 高性能——锁不能成为系统瓶颈

分布式锁的加锁、解锁操作应该有高吞吐量和低延迟:

  • 加锁/解锁的延迟:通常应在 1ms 以内
  • Redis 方案:单机 10万+ QPS
  • ZooKeeper 方案:单机 1万+ QPS(写性能不如 Redis)

其他重要要素

锁的持有者验证

只有锁的持有者才能释放锁。不能让客户端 A 释放了客户端 B 的锁。

实现:每个锁请求带上唯一标识(UUID/requestId),释放时验证。

公平性(可选)

按请求顺序获取锁。Redis 分布式锁默认非公平,ZooKeeper 可以实现公平锁。

阻塞 vs 非阻塞

  • 非阻塞:获取不到立即返回失败
  • 阻塞:获取不到一直等待(或等待一段时间后超时)

要素的权衡取舍

要素 Redis SETNX Redisson ZooKeeper 数据库
互斥性
防死锁 ✅(TTL) ✅(Watchdog) ✅(Session) ❌(需额外)
容错性 ❌(单点) ✅(Redlock) ✅(ZAB) ✅(主从)
可重入
高性能

面试考点

面试时被问到分布式锁,建议按这个框架回答:

  1. 互斥性:分布式锁的首要目标,必须保证
  2. 防死锁:通过 TTL/Session 确保锁自动释放
  3. 容错性:锁服务本身的高可用设计
  4. 可重入:同一客户端能重复获取(非必需但重要)
  5. 高性能:加解锁操作要快

最后补充:我们公司目前使用 Redisson,因为它内置了 Watchdog 续约、可重入、Lua 脚本等特性,在保证功能完整性的同时性能也足够。如果对一致性要求极高,可以考虑 ZooKeeper 方案但性能会有所牺牲。


SET NX EX 正确写法——Redis 分布式锁的标准姿势

SET NX EX 正确写法——Redis 分布式锁的标准姿势

为什么需要正确写法

分布式锁看似简单,无数项目因为写错一行代码导致线上故障。Redis 官方在 2.6.12 版本后就给出了标准写法,但仍然有很多人在写出有问题的代码。

常见错误——

// ❌ 错误1:SETNX + EXPIRE 分开执行
jedis.setnx("lock", "1");
jedis.expire("lock", 30);

问题:SETNX 成功但 EXPIRE 前客户端崩溃 → 死锁。

// ❌ 错误2:release 时不验证持有者
jedis.del("lock");

问题:可能释放了别的客户端的锁。

// ❌ 错误3:get + 比较 + del 分开做
if (jedis.get("lock").equals(myId)) {
    jedis.del("lock");
}

问题:get 和 del 之间锁可能已过期并被他人获取,导致误删。

正确的标准写法

加锁:SET NX EX(原子操作)

SET lock_key unique_value NX EX 30
参数 含义 说明
lock_key 锁的名称 不同资源用不同的 key
unique_value 客户端唯一标识 UUID/requestId,确保只有自己能释放
NX Not eXists key 不存在时才设置
EX Expire seconds 设置过期时间,秒级

Java 实现

// 加锁
String requestId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
    .setIfAbsent("lock:order", requestId, 30, TimeUnit.SECONDS);

解锁:Lua 脚本保证原子性

-- Lua 脚本:比较 unique_value 后删除
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end
// Java 调用
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                   "then return redis.call('del', KEYS[1]) " +
                   "else return 0 end";
Long result = (Long) redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList("lock:order"), 
    requestId
);

完整的最佳实践代码

public class RedisLock {
    private final StringRedisTemplate redis;

    /**
     * 尝试获取锁
     * @param key 锁标识
     * @param expireSeconds 锁超时时间
     * @return 锁标识(用于释放),null 表示获取失败
     */
    public String tryLock(String key, int expireSeconds) {
        String requestId = UUID.randomUUID().toString();
        Boolean success = redis.opsForValue()
            .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS);
        if (Boolean.TRUE.equals(success)) {
            return requestId;  // 返回锁标识给调用方
        }
        return null;
    }

    /**
     * 释放锁
     * @param key 锁标识
     * @param requestId 加锁时返回的标识
     */
    public boolean unlock(String key, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                        "then return redis.call('del', KEYS[1]) " +
                        "else return 0 end";
        Long result = redis.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(key), 
            requestId
        );
        return Long.valueOf(1).equals(result);
    }

    // 使用示例
    public void processOrder(Long orderId) {
        String lockKey = "lock:order:" + orderId;
        String lockValue = tryLock(lockKey, 30);
        if (lockValue == null) {
            throw new RuntimeException("获取锁失败");
        }
        try {
            // 执行业务逻辑
            doProcess(orderId);
        } finally {
            unlock(lockKey, lockValue);  // 确保释放
        }
    }
}

设置过期时间的考量

TTL 应该设多长?

  • 参考业务执行时间:假设业务平均执行 200ms,可以设 3-5 秒
  • 考虑最坏情况:Full GC、慢查询,最坏情况业务执行多久?
  • 不能太短:业务没执行完锁就过期了,其他客户端可能获取锁
  • 不能太长:客户端崩溃后,死锁窗口太长

实践建议:TTL = 业务预估最大执行时间 × 3,或者使用 Watchdog 续约机制。

常见错误列表

错误写法 问题 正确写法
SETNX + EXPIRE 非原子,可能死锁 SET key value NX EX
固定 value 不能区分持有者 使用 UUID/requestId
GET + 比较 + DEL 非原子,可能误删 Lua 脚本
忘记 finally 异常时锁不释放 try-finally
TTL 太短 业务未完成锁过期 Watchdog 或适当延长

面试要点

  • 正确写法 = SET NX EX 加锁 + Lua 解锁
  • 加锁必须原子操作,不能分成两步
  • 解锁必须验证持有者,且验证和删除必须原子
  • 面试官很喜欢问:”这段代码有什么问题?”——记住上面的”常见错误列表”
  • 如果能说出”这个方案在主从架构下还有缺陷,需要 Redlock”,就是加分项

SETNX 实现分布式锁——Redis 分布式锁的基础原语

SETNX 实现分布式锁——Redis 分布式锁的基础原语

SETNX 是什么

SETNX 是 Redis 的一个命令,全称 SET if Not eXists。它的语义是:当指定的 key 不存在时,才设置这个 key;如果 key 已经存在,什么都不做。

SETNX lock:order 1
→ (integer) 1   // 加锁成功,因为 key 不存在
SETNX lock:order 2
→ (integer) 0   // 加锁失败,因为 key 已存在

用 SETNX 实现最基础的分布式锁

// 尝试加锁
Boolean locked = redis.opsForValue().setIfAbsent("lock:order", "1");
if (Boolean.TRUE.equals(locked)) {
    try {
        // 执行业务逻辑...
        processOrder();
    } finally {
        // 释放锁
        redis.delete("lock:order");
    }
} else {
    // 获取锁失败,返回异常或重试
    throw new BizException("系统繁忙,请重试");
}

基础实现的问题

问题一:没有过期时间 → 死锁

如果加锁的客户端在执行业务逻辑时崩溃(OOM、断网、重启),锁一直存在,其他客户端永远拿不到锁。

// ❌ 危险!客户端崩溃后锁永远不释放
redis.opsForValue().setIfAbsent("lock:order", "1");
processOrder();  // 如果这里崩溃...
redis.delete("lock:order");  // 永远不会执行

问题二:SETNX 和 EXPIRE 不是原子操作

// ❌ 两步操作之间有间隙,SETNX 成功但 EXPIRE 没执行到
redis.opsForValue().setIfAbsent("lock:order", "1");
redis.expire("lock:order", 30);  // 如果崩溃在这里,锁没有超时时间

正确的实现:SET NX EX(原子操作)

Redis 2.6.12 之后,SET 命令支持 NX 和 EX/PX 选项,可以一步完成加锁和设置过期时间:

// ✅ 原子操作:SET NX EX
Boolean locked = redis.opsForValue()
    .setIfAbsent("lock:order", requestId, 30, TimeUnit.SECONDS);

对应的 Redis 命令:

SET lock:order requestId NX EX 30

释放锁时的关键问题

不能释放别人锁

直接 DEL 可能释放了其他客户端持有的锁:

时间 | 客户端 A                | 客户端 B
T1   | SET NX EX 30 (获得锁)   |
T2   | 业务逻辑执行 35秒...    |
T3   | 锁自动过期              |
T4   |                          | SET NX EX 30 (获得锁)
T5   | DEL lock:order           | ❌ A 释放了 B 的锁!

解决方案:设置一个只有自己知道的唯一值,释放时验证:

String requestId = UUID.randomUUID().toString();

// 加锁(只有自己知道 requestId)
redis.opsForValue()
    .setIfAbsent("lock:order", requestId, 30, TimeUnit.SECONDS);

// 释放锁:用 Lua 保证比较和删除是原子的
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
             "then return redis.call('del', KEYS[1]) " +
             "else return 0 end";
redis.execute(new DefaultRedisScript<>(lua, Long.class),
    Arrays.asList("lock:order"), requestId);

标准 SETNX 分布式锁代码

public class RedisDistributedLock {
    private final RedisTemplate redis;

    public boolean tryLock(String key, String requestId, int expireSeconds) {
        return Boolean.TRUE.equals(
            redis.opsForValue()
                .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS)
        );
    }

    public boolean unlock(String key, String requestId) {
        String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
                     "then return redis.call('del', KEYS[1]) " +
                     "else return 0 end";
        Long result = (Long) redis.execute(
            new DefaultRedisScript<>(lua, Long.class),
            Collections.singletonList(key), requestId
        );
        return Long.valueOf(1).equals(result);
    }
}

SETNX 的局限性

  1. 业务执行可能超时:加锁时设置的 TTL 是固定的,业务逻辑如果执行时间超过 TTL,锁自动释放,造成并发
  2. 主从架构的缺陷:Master 写入锁后宕机,锁还没同步到 Slave,其他客户端可以成功获取锁
  3. 无法重入:同一个客户端不能重复加锁(需要额外实现)
  4. 非公平:谁先抢到谁用,不保证请求顺序

面试要点

  • 用 SETNX 实现分布式锁是最基础的方式,必须掌握
  • 核心的三个注意事项:加锁原子性、过期时间、释放时验证
  • 释放锁必须用 Lua 脚本保证原子性,不能先 GET 再 DEL
  • SET NX EX 是 Redis 官方推荐的分布式锁基础命令
  • SETNX 方案在单机 Redis 下足够,主从架构需要更完善的方案(Redlock)

为什么需要分布式锁——从单机锁到分布式锁的演进

为什么需要分布式锁——从单机锁到分布式锁的演进

从一段代码说起

synchronized (this) {
    // 临界区:扣减库存
    if (stock > 0) {
        stock--;
        orderService.create();
    }
}

这段代码在单机部署时完美运行——synchronized 确保同一时刻只有一个线程执行库存扣减。但当系统发展到一定规模后,一切变了。

单机锁的局限性

多进程部署的困境

微服务架构下,同一段逻辑会部署在多个实例上:

          ┌─── App Server 1 ───┐
请求 → ───┤  synchronized(this) ├──→ 数据库
          └────────────────────┘
          ┌─── App Server 2 ───┐
请求 → ───┤  synchronized(this) ├──→ 数据库
          └────────────────────┘
          ┌─── App Server 3 ───┐
请求 → ───┤  synchronized(this) ├──→ 数据库
          └────────────────────┘

每个实例的 synchronized 只保护自己进程内的线程并发,跨进程的并发是完全不受控的。三个实例同时执行库存扣减,最终导致超卖。

单机锁解决不了的场景

场景 问题 示例
库存扣减 多实例同时操作库存 双十一超卖
定时任务 多台机器重复执行 重复发送通知
幂等控制 重复请求同时处理 支付重复扣款
资源竞争 独占资源抢占 写同一个文件
限流控制 全局 QPS 控制 触发开关

分布式锁的核心需求

1. 互斥性

在任何时刻,全局范围内只有一个客户端持有锁。

2. 避免死锁

持有锁的客户端崩溃后,锁能自动释放,不会造成死锁。

3. 容错性

锁服务本身是高可用的,不会因为单点故障导致锁不可用。

4. 可重入性(可选)

持有锁的客户端可以重复获取同一把锁而不阻塞。

5. 公平性(可选)

请求锁的顺序和获得锁的顺序一致。

分布式锁的几种实现方案

实现方式 特点 典型工具
数据库 实现简单,性能低 SELECT FOR UPDATE
Redis 高性能,功能丰富 SETNX、Redisson
Zookeeper 强一致性,可靠性高 Curator
Etcd 云原生,raft 一致性 etcd lock API

为什么 Redis 是分布式锁的主流选择

  1. 性能极强:基于内存,单机 10 万+ QPS
  2. 部署广泛:大部分系统已经在用 Redis 做缓存
  3. 原子操作:SET NX EX 天然支持加锁的原子操作
  4. TTL 支持:内置过期机制避免死锁
  5. 成熟生态:Redisson 等客户端提供了开箱即用的锁实现

何时使用分布式锁

必须用分布式锁

  • 资源互斥:扣减库存、抢红包、领取优惠券
  • 幂等控制:防止重复支付、重复下单
  • 分布式定时任务的互斥调度

不一定需要分布式锁

  • 数据库乐观锁就能解决的库存扣减(update stock=stock-1 where stock>0)
  • 利用消息队列单线程消费的流式处理

面试要点

  • 分布式锁的核心价值是解决 跨进程互斥,单机锁做不到
  • 能对比 Redis、ZooKeeper、数据库三种方案的优缺点
  • 关键要说出分布式锁的五个核心要素:互斥性、防死锁、容错、可重入(可选)、高性能
  • 面试时可以举例:库存扣减为什么不能用 synchronized,必须用分布式锁

线程进程区别及 GIL 全局解释器锁的影响

线程进程区别及 GIL 全局解释器锁的影响

核心概念

进程(Process) 是操作系统资源分配的基本单位,线程(Thread) 是 CPU 调度的基本单位。一个进程可以包含多个线程,共享进程的内存空间。

进程 vs 线程对比

graph TB
    subgraph "进程 A"
        PA1[代码段] --- PA2[数据段]
        PA2 --- PA3[堆]
        PA3 --- PA4[栈]
    end
    subgraph "进程 B"
        PB1[代码段] --- PB2[数据段]
        PB2 --- PB3[堆]
        PB3 --- PB4[栈]
    end
    subgraph "线程"
        T1[线程 1<br>独立的栈和寄存器] --- T2[线程 2<br>独立的栈和寄存器]
        T1 -..-|共享| PA2
        T2 -..-|共享| PA2
    end
特性 进程 线程
地址空间 独立 共享
资源开销 大(独立内存、文件句柄等) 小(共享进程资源)
通信方式 IPC(管道、队列、共享内存) 直接读写共享变量
创建销毁速度
上下文切换 快(同一进程内)
独立性 强(一个崩溃不影响其他) 弱(一个崩溃导致进程退出)
适用场景 CPU 密集型 I/O 密集型

GIL 是什么

GIL(Global Interpreter Lock,全局解释器锁) 是 CPython 解释器的一个机制——它确保任何时候只有一个线程在执行 Python 字节码。

import sys
print(sys.version)
# CPython 存在 GIL
print(sys._is_gil_enabled())  # 3.12+ 可选禁用 GIL
flowchart LR
    subgraph "无 GIL"
        T1[线程 1] --> CP[多核并行]
        T2[线程 2] --> CP
        T3[线程 3] --> CP
    end
    subgraph "有 GIL"
        G[GIL] ---|互斥| T1G[线程 1
持有锁
] T2G[线程 2
等待锁
] -.-> G T3G[线程 3
等待锁
] -.-> G end

GIL 的历史原因

# 为什么会有 GIL?
# 1. CPython 内存管理不是线程安全的
# 2. 引用计数(refcount)需要原子操作保护
# 3. 用 GIL 简化了 C 扩展的编写

import sys
a = []
b = a
print(sys.getrefcount(a))  # 引用计数
# 如果没有 GIL,多个线程同时操作引用计数会导致
# 内存泄漏或 Segfault

GIL 对不同任务的影响

import threading
import time

# CPU 密集型任务 —— GIL 导致多线程无加速
def cpu_heavy():
    total = 0
    for _ in range(10_000_000):
        total += 1

def test_cpu():
    start = time.time()
    threads = [threading.Thread(target=cpu_heavy) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
    print(f"多线程 CPU 耗时: {time.time() - start:.2f}s")
    # 约等于单线程耗时(GIL 限制了并行)

# I/O 密集型任务 —— GIL 释放,多线程有效
def io_task():
    time.sleep(1)  # 模拟 I/O 等待

def test_io():
    start = time.time()
    threads = [threading.Thread(target=io_task) for _ in range(4)]
    for t in threads: t.start()
    for t in threads: t.join()
    print(f"多线程 I/O 耗时: {time.time() - start:.2f}s")
    # 约 1 秒(I/O 时 GIL 被释放)

何时 GIL 被释放?

# GIL 在以下情况下被释放
# 1. I/O 操作(read, write, send, recv)
# 2. time.sleep()
# 3. C 扩展中调用 Py_BEGIN_ALLOW_THREADS
# 4. 执行了 100 个字节码指令后自动释放(Python 3.2+)

import threading
import time

def check_gil_release():
    """检查 GIL 是否在 I/O 时释放"""
    t1 = threading.Thread(target=lambda: time.sleep(2))
    t2 = threading.Thread(target=lambda: print("同时运行"))

    t1.start()
    t2.start()
    # t2 不会被 t1 的 sleep 阻塞
    # 因为 time.sleep() 释放了 GIL

绕过 GIL 的方案

方案 说明 适用场景
multiprocessing 多进程,每个进程独立 GIL CPU 密集型
C 扩展 在 C 代码中释放 GIL 科学计算
concurrent.futures 高层封装 通用
Python 3.12 自由线程 可选禁用 GIL 实验性
asyncio 协程(单线程并发) I/O 密集型
# multiprocessing 绕过 GIL
from multiprocessing import Pool

def cpu_heavy(n):
    return sum(i ** 2 for i in range(n))

with Pool(4) as pool:
    results = pool.map(cpu_heavy, [10_000_000] * 4)
    # 真正利用多核!

面试高频题

Q: Python 3.12 的自由线程(Free-threading)是什么?

A: Python 3.12 引入 --disable-gil 构建模式(实验性),允许解释器在无 GIL 的情况下运行。通过细粒度锁替代全局锁,多线程可以真正并行执行 CPU 任务。但部分 C 扩展尚未兼容。

Q: 为什么 Python 不直接去掉 GIL?

A: 去掉 GIL 会导致:大量 C 扩展需要重写、单线程性能下降(需额外锁开销)、引用计数等核心机制需大幅改造。Python 3.12 的自由线程模式也只是一小步。

Q: GIL 对性能的影响主要体现在哪里?

A: 对 CPU 密集型 多线程程序影响最大——多核无法利用,性能甚至可能退化(上下文切换开销)。对 I/O 密集型 程序几乎没有影响。


死锁四个必要条件与预防策略详解

死锁四个必要条件与预防策略详解

什么是死锁

死锁是指两个或多个线程互相等待对方释放资源,导致所有相关线程永远无法继续执行的状态。

graph TD
    subgraph "死锁状态"
        T1[线程 1] -->|持有| R1[资源 A]
        T2[线程 2] -->|持有| R2[资源 B]
        T1 -.->|等待| R2
        T2 -.->|等待| R1
        R1 -.-> T1
        R2 -.-> T2
    end
    Note["形成环路:T1→R1→T2→R2→T1"]

死锁的四个必要条件

  1. 互斥:资源一次只能被一个线程使用
  2. 持有并等待:线程已持有资源,同时等待其他资源
  3. 不可剥夺:资源不能被强制夺走
  4. 循环等待:存在 T1→T2→...→Tn→T1 的等待环路

经典死锁示例

import threading
import time

fork_a = threading.Lock()
fork_b = threading.Lock()

def philosopher_bad(name, first_fork, second_fork):
    """哲学家就餐——死锁版本"""
    for _ in range(3):
        print(f"{name}: 思考中...")
        time.sleep(0.1)

        with first_fork:  # 拿第一根筷子
            print(f"{name}: 拿到第一根筷子")
            time.sleep(0.1)  # 故意制造时序问题

            # 如果所有哲学家同时拿左筷子,没人拿得到右筷子
            with second_fork:  # 拿第二根筷子
                print(f"{name}: 开始吃饭")
                time.sleep(0.1)

# 所有哲学家都先拿左筷子(左撇子版)
philosophers = [
    threading.Thread(target=philosopher_bad,
                     args=(f"P{i}", fork_a, fork_b))
    for i in range(2)
]

for p in philosophers: p.start()
for p in philosophers: p.join()
# 可能死锁:P1 有 A 等 B,P2 有 B 等 A

避免策略

1. 固定资源顺序(破坏循环等待)

# 给资源编号,总是按固定顺序获取
class ResourceManager:
    def __init__(self, resources):
        self.resources = sorted(resources, key=lambda r: id(r))
        self.locks = {r: threading.Lock() for r in resources}

    def acquire_resources(self, requested):
        # 总是按编号升序获取锁
        for resource in sorted(requested, key=lambda r: id(r)):
            self.locks[resource].acquire()

    def release_resources(self, requested):
        for resource in requested:
            self.locks[resource].release()

# 使用
rm = ResourceManager(['A', 'B', 'C'])
def safe_worker():
    rm.acquire_resources(['B', 'A'])  # 内部自动排序为 A, B
    try:
        do_work()
    finally:
        rm.release_resources(['B', 'A'])

2. 超时等待(避免无限等待)

def acquire_with_timeout(lock, timeout=1.0):
    """带超时的锁获取"""
    deadline = time.monotonic() + timeout
    while True:
        if lock.acquire(blocking=False):
            return True
        if time.monotonic() >= deadline:
            return False
        time.sleep(0.001)

def safe_worker_with_timeout(lock_a, lock_b):
    if not acquire_with_timeout(lock_a):
        return
    try:
        time.sleep(0.05)
        if not acquire_with_timeout(lock_b):
            # 获取 B 失败,释放 A 避免死锁
            return
        try:
            do_critical_work()
        finally:
            lock_b.release()
    finally:
        lock_a.release()

3. 哲学家就餐——无死锁方案

import threading

class LockedFork:
    def __init__(self, index):
        self.index = index
        self.lock = threading.Lock()

    def __enter__(self):
        self.lock.acquire()
        return self

    def __exit__(self, *args):
        self.lock.release()

def safe_philosopher(name, left_fork, right_fork):
    """避免死锁:奇数哲学家先拿左,偶数先拿右"""
    # 奇偶顺序打破循环等待
    first_fork = left_fork if hash(name) % 2 == 0 else right_fork
    second_fork = right_fork if first_fork is left_fork else left_fork

    for _ in range(5):
        with first_fork:
            with second_fork:
                print(f"{name} 开始吃饭")

4. 使用 threading.TIMEOUT 检测

import signal

def deadlock_detector(timeout=10):
    """简单死锁检测器"""

    def handler(signum, frame):
        print("⚠️ 检测到疑似死锁!打印线程状态:")
        import sys, traceback
        for thread_id, frame in sys._current_frames().items():
            print(f"\n线程 {thread_id}:")
            traceback.print_stack(frame)

    signal.signal(signal.SIGALRM, handler)
    signal.alarm(timeout)

实际场景:转账死锁

class Account:
    def __init__(self, name, balance):
        self.name = name
        self.balance = balance
        self.lock = threading.Lock()

    def transfer(self, to_account, amount):
        # 死锁版本
        with self.lock:
            print(f"{self.name}: 锁定自己")
            time.sleep(0.01)  # 加剧死锁风险
            with to_account.lock:
                self.balance -= amount
                to_account.balance += amount

    # 无死锁版本:按 id 固定顺序
    def safe_transfer(self, to_account, amount):
        first = self if id(self) < id(to_account) else to_account
        second = to_account if first is self else self

        with first.lock:
            with second.lock:
                self.balance -= amount
                to_account.balance += amount
                print(f"转账 {amount} 完成: {self.name}{to_account.name}")

死锁检测工具

import threading
import time

def monitor_thread():
    """后台监控线程——检测线程是否僵死"""
    thread_snapshots = {}
    while True:
        time.sleep(5)
        for t in threading.enumerate():
            if t is threading.current_thread():
                continue
            if t.name not in thread_snapshots:
                thread_snapshots[t.name] = time.time()
            elif time.time() - thread_snapshots[t.name] > 30:
                if t.is_alive():
                    print(f"警告:线程 {t.name} 可能死锁!")

# 启动监控
monitor = threading.Thread(target=monitor_thread, daemon=True)
monitor.start()

面试高频题

Q: Python 有内置的死锁检测机制吗?

A: Python 标准库不提供自动死锁检测。需要用外部监控手段:定时打印 sys._current_frames()、设置超时、或用专门的调试工具。

Q: 写代码时如何预防死锁?

A: 遵循黄金法则:(1) 固定加锁顺序 (2) 尽量缩小临界区 (3) 使用超时机制 (4) 用更高级的同步原语(如 queue.Queue)替代手动加锁。

Q: 死锁和活锁有什么区别?

A: 死锁中线程永久阻塞;活锁中线程持续活动但无法推进工作(比如两个线程遇到对方就让路,结果一直让来让去)。活锁比死锁更难检测,因为线程看起来仍在运行。


如何实现无锁队列(CAS)

如何实现无锁队列(CAS)

定义

无锁队列(Lock-Free Queue)是一种不使用传统互斥锁(mutex)的并发队列,通过原子操作(主要是 CAS,即 Compare-And-Swap)来实现线程安全。其核心优势是避免锁带来的上下文切换和挂起,在高并发场景下具有更好的性能。

原理

CAS 原子操作

CAS 的全称是 Compare-And-Swap(比较并交换)。Go 中通过 sync/atomic 包提供:

// 伪代码:如果 *addr == old,则将 *addr = new,返回 true
atomic.CompareAndSwapInt32(addr, old, new) bool

CAS 是一条 CPU 指令级别的原子操作,无需加锁。

无锁队列的核心思路

基于链表(Linked List)实现无锁队列是最常见的方式:

  1. 队列维护头指针(head)和尾指针(tail)
  2. 入队(Enqueue):CAS 更新 tail->next,然后 CAS 更新 tail
  3. 出队(Dequeue):CAS 更新 head
graph LR
    A[head] --> B[Node1]
    B --> C[Node2]
    C --> D[Node3]
    D --> E[nil]
    F[tail] --> D

代码示例(基于链表的无锁队列)

package main

import (
    "sync/atomic"
    "unsafe"
)

type node struct {
    value any
    next  unsafe.Pointer // *node
}

type LockFreeQueue struct {
    head unsafe.Pointer // *node
    tail unsafe.Pointer // *node
}

func NewLockFreeQueue() *LockFreeQueue {
    sentinel := &node{} // 哨兵节点
    return &LockFreeQueue{
        head: unsafe.Pointer(sentinel),
        tail: unsafe.Pointer(sentinel),
    }
}

func (q *LockFreeQueue) Enqueue(val any) {
    newNode := &node{value: val}
    for {
        tail := (*node)(atomic.LoadPointer(&q.tail))
        next := (*node)(atomic.LoadPointer(&tail.next))
        // 检查 tail 是否仍是尾节点
        if tail == (*node)(atomic.LoadPointer(&q.tail)) {
            if next == nil {
                // 尝试将新节点追加到尾部
                if atomic.CompareAndSwapPointer(&tail.next, nil, unsafe.Pointer(newNode)) {
                    // 尝试移动 tail,即使失败也没关系,下次操作会修正
                    atomic.CompareAndSwapPointer(&q.tail, unsafe.Pointer(tail), unsafe.Pointer(newNode))
                    return
                }
            } else {
                // tail 落后了,帮助推进 tail
                atomic.CompareAndSwapPointer(&q.tail, unsafe.Pointer(tail), unsafe.Pointer(next))
            }
        }
    }
}

func (q *LockFreeQueue) Dequeue() (any, bool) {
    for {
        head := (*node)(atomic.LoadPointer(&q.head))
        tail := (*node)(atomic.LoadPointer(&q.tail))
        next := (*node)(atomic.LoadPointer(&head.next))
        if head == (*node)(atomic.LoadPointer(&q.head)) {
            if head == tail {
                if next == nil {
                    return nil, false // 队列为空
                }
                // 帮助推进 tail
                atomic.CompareAndSwapPointer(&q.tail, unsafe.Pointer(tail), unsafe.Pointer(next))
            } else {
                val := next.value
                if atomic.CompareAndSwapPointer(&q.head, unsafe.Pointer(head), unsafe.Pointer(next)) {
                    return val, true
                }
            }
        }
    }
}

要点

  1. ABA 问题:CAS 操作中,值从 A→B→A 变化,CAS 会误判未修改。Go 的指针 CAS 有 GC 保障,在 64 位系统上 ABA 风险较低。需要完全解决可使用 tagged 指针或使用 atomic.Value
  2. 哨兵节点:队列始终保持一个空的哨兵节点,避免 head 和 tail 为 nil 的特殊情况处理。
  3. 帮助机制:当一个线程发现 tail 滞后时,主动帮助推进 tail,这是无锁编程中常见的协作模式。
  4. memory ordering:Go 的 atomic 包默认提供顺序一致性(sequentially consistent),保证可见性。
  5. 性能考量:CAS 在低竞争下极快,但在高竞争下可能因为频繁重试而降低性能。

面试题

问题 1:CAS 相比 Mutex 有什么优缺点?

回答:优点:无锁不阻塞,不会导致线程上下文切换和挂起,在低到中等竞争下性能更好;不会发生死锁。缺点:编程复杂度高,容易引入 ABA 问题、活锁(live lock)等问题;在高竞争场景下 CAS 频繁重试,性能可能反而不如 mutex。

问题 2:Go 中除了 CAS 还有哪些原子操作可以实现无锁结构?

回答atomic.AddT(原子加减)、atomic.Store/Load(原子读写)、atomic.Swap(原子交换)、atomic.CompareAndSwap(CAS)、atomic.Value(可安全读写的任意类型容器)。实际实现中经常组合使用这些操作。

问题 3:Go 中实现无锁队列是否还有更好的方案?

回答:对于大多数场景,Go 推荐使用带缓冲 channel 来实现生产者-消费者模式,channel 底层已经做了高效的并发处理。但如果追求极致性能,或者需要批量操作、非阻塞语义,可使用第三方库(如 go-queue)或基于 CAS 自行实现。Go 标准库中的 channel 内部也使用了类似的无锁技术。


通道死锁

通道死锁

问题

通道(channel)是 Go 并发模型的核心,但使用不当很容易触发死锁。死锁通常发生在 goroutine 在通道上发送或接收时,没有对应的接收方或发送方,导致所有参与方永久阻塞。

通道死锁的本质

flowchart LR
    G1["goroutine A
向 ch 发送数据"
] G2["goroutine B
从 ch 接收数据"
] ch["channel ch"] G1 -- "ch <- v" --> ch ch -- "v := <-ch" --> G2 style G1 fill:#f96 style G2 fill:#9cf

当 A 发送但没有 B 接收,或者 B 接收但没有 A 发送时,就发生了死锁。

典型错误 1:无缓冲通道只发不收

func main() {
    ch := make(chan int)
    ch <- 42 // ❌ 阻塞:没有对应的接收方
    fmt.Println(<-ch)
}
// fatal error: all goroutines are asleep - deadlock!

无缓冲通道要求发送和接收同时就绪。上述代码只有发送没有接收,主 goroutine 永远阻塞。

典型错误 2:无缓冲通道只收不发

func main() {
    ch := make(chan int)
    <-ch // ❌ 阻塞:没有发送方
}
// fatal error: all goroutines are asleep - deadlock!

典型错误 3:所有 goroutine 都在等待

func main() {
    ch := make(chan int)
    go func() {
        v := <-ch // goroutine 阻塞等待发送
        fmt.Println(v)
    }()
    // 主 goroutine 没有发送数据就结束了
    // 子 goroutine 永远阻塞
}

虽然不会触发 fatal error(因为还有活动的 goroutine),但子 goroutine 会被泄漏。

典型错误 4:混合锁和通道

func main() {
    var mu sync.Mutex
    ch := make(chan int)

    go func() {
        mu.Lock()
        v := <-ch // 等待主 goroutine 发数据
        mu.Unlock()
        fmt.Println(v)
    }()

    mu.Lock()
    ch <- 42 // 等待子 goroutine 接收
    mu.Unlock()
    // 🔥 死锁:主 goroutine 持有锁等待子 goroutine 接收
    //    子 goroutine 持有锁等待主 goroutine 发送
}

这是经典的锁顺序死锁,结合了通道和互斥锁。

典型错误 5:select 中多个通道都不就绪且没有 default

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    select {
    case v := <-ch1:
        fmt.Println(v)
    case v := <-ch2:
        fmt.Println(v)
    }
    // ❌ 两个通道都没有数据,且没有 default 分支
    // 永久阻塞 → 死锁
}

正确做法

无缓冲通道:确保收发成对

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42 // 子 goroutine 发送
    }()
    v := <-ch // 主 goroutine 接收
    fmt.Println(v) // 42
}

使用有缓冲通道避免瞬时阻塞

func main() {
    ch := make(chan int, 1) // 缓冲大小为 1
    ch <- 42 // ✅ 不阻塞:缓冲区有空位
    v := <-ch
    fmt.Println(v)
}

select + default 避免永久阻塞

func main() {
    ch := make(chan int)
    select {
    case v := <-ch:
        fmt.Println(v)
    default:
        fmt.Println("通道无数据,跳过")
    }
}

使用超时避免死锁

func main() {
    ch := make(chan int)
    select {
    case v := <-ch:
        fmt.Println(v)
    case <-time.After(3 * time.Second):
        fmt.Println("超时,避免死锁")
    }
}

双向死锁 vs 资源泄漏

stateDiagram-v2
    state "所有 goroutine 阻塞" as Deadlock
    state "有 goroutine 泄漏" as Leak

    [*] --> Deadlock: 所有 goroutine 都阻塞<br/>在通道操作上
    [*] --> Leak: 部分 goroutine 阻塞<br/> main 已退出

    Deadlock --> Fatal: runtime 检测到<br/>输出 fatal error
    Leak --> Lost: goroutine 永远不回收
  • 双向死锁:所有 goroutine 都阻塞,runtime 能检测到并 panic
  • 资源泄漏:非主 goroutine 阻塞,主 goroutine 正常退出,程序不报错但资源泄漏

调试技巧

// 查看当前所有 goroutine 的堆栈
func dumpStacks() {
    buf := make([]byte, 1<<16)
    n := runtime.Stack(buf, true)
    fmt.Printf("%s", buf[:n])
}

运行程序后发送 SIGQUIT(Ctrl+\) 也可以触发 goroutine dump:

kill -QUIT 
# 或
Ctrl + \

总结

  • ✅ 无缓冲通道要求发送和接收同时就绪
  • ✅ 有缓冲通道在缓冲区未满/未空时不阻塞
  • select 配合 defaulttime.After 避免永久阻塞
  • ❌ 不要在持有锁时通过通道通信(容易锁顺序死锁)
  • 💡 调试时使用 runtime.StackSIGQUIT 查看 goroutine 状态

锁的复制警告

锁的复制警告

问题:为什么不能复制锁

sync.Mutexsync.RWMutex 包含一个内部状态字段,用于跟踪锁的持有状态。复制锁会复制锁的状态,导致严重的并发问题。

func main() {
    var mu1 sync.Mutex
    mu1.Lock()

    mu2 := mu1 // 复制了已锁定的 Mutex!

    mu1.Unlock()
    mu2.Unlock() // panic: sync: unlock of unlocked mutex
    // 因为 mu2 复制了 mu1 的锁定状态
}

go vet 检测

Go 提供了内置的 go vet 工具检测锁复制问题:

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c Counter) Add() { // ⚠️ 值接收者复制了 Counter!
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}
go vet ./..
# 输出: counter.go:12: Add passes lock by value: Counter

常见的复制场景

1. 值接收者方法

// ❌ 值接收者复制了锁
type SafeCounter struct {
    mu sync.Mutex
    v  int
}

func (s SafeCounter) Inc() { // 锁被复制
    s.mu.Lock()
    s.v++
    s.mu.Unlock()
}

// ✅ 指针接收者
func (s *SafeCounter) Inc() {
    s.mu.Lock()
    s.v++
    s.mu.Unlock()
}

2. 函数参数传递

// ❌ 传值复制了锁
func process(counter Counter) {
    counter.mu.Lock()
    defer counter.mu.Unlock()
}

// ✅ 传指针
func process(counter *Counter) {
    counter.mu.Lock()
    defer counter.mu.Unlock()
}

3. 切片和 map 中的结构体

// ❌ 从切片取值复制了锁
counters := []Counter{{}, {}, {}}
counters[0].mu.Lock() // 这里的访问创建了临时副本!

// ✅ 用指针切片
counters := []*Counter{{}, {}, {}}
counters[0].mu.Lock()

// 或者在切片上直接调用
counters[0].mu.Lock()
counters[0].v++
counters[0].mu.Unlock()

4. 结构体嵌入

// ❌ 嵌入锁 + 值传递
type MyStruct struct {
    sync.Mutex
    data string
}

func copyMutex() {
    a := MyStruct{data: "hello"}
    a.Lock()
    b := a  // 复制了 Mutex
    a.Unlock()
    b.Unlock() // 可能 panic
}

// ✅ 嵌入锁 + 始终指针操作
func usePointer() {
    a := &MyStruct{data: "hello"}
    a.Lock()
    defer a.Unlock()
}

5. 闭包捕获

func captureMutex() {
    var mu sync.Mutex

    // 闭包通过引用捕获,没问题
    fn1 := func() {
        mu.Lock()
        defer mu.Unlock()
        fmt.Println("safe")
    }

    // 但如果闭包赋值后 mu 被复制到另一个值
    // 实际上闭包捕获的是指针,这是安全的
}

如何正确传递含锁的结构体

方法一:始终使用指针

type SafeMap struct {
    mu sync.Mutex
    m  map[string]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{m: make(map[string]int)}
}

// 所有方法都是指针接收者
func (s *SafeMap) Set(key string, value int) {
    s.mu.Lock()
    s.m[key] = value
    s.mu.Unlock()
}

方法二:嵌入 sync.Locker 接口

type SafeContainer struct {
    locker sync.Locker // interface,复制安全
    data   map[string]int
}

func NewSafeContainer() *SafeContainer {
    return &SafeContainer{
        locker: &sync.Mutex{}, // 存储指针
        data:   make(map[string]int),
    }
}

// locker 是接口类型,复制的是接口值
// 底层指针不会被复制

方法三:禁止复制

type NoCopy struct {
    mu sync.Mutex
    _  noCopy // 嵌入 noCopy 结构
}

// noCopy 用于 go vet 检测复制
type noCopy struct{}

func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

实际案例

// HTTP Handler 中的锁复制
type Handler struct {
    mu      sync.Mutex
    counter int
}

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ⚠️ 值接收者!每次请求复制 Handler
    h.mu.Lock()
    h.counter++
    h.mu.Unlock()
    fmt.Fprintf(w, "count: %d\n", h.counter)
    // 永远不会超过 1,因为锁被复制了
}

总结

推荐做法 避免做法
指针接收者方法 值接收者方法(含锁时)
容器用指针切片 容器用值切片 + 取地址
函数参数传指针 函数参数传值
embed sync.Mutex 时用指针接收者 忘记 go vet 检查

最佳实践:
1. 所有含锁的方法使用指针接收者
2. 运行 go vet ./... 检查锁复制
3. linter 集成 go vet 到 CI 流程
4. 不确定时优先返回指针

记住:Go 不是线程安全的语言,但锁可以帮我们实现。而锁本身复制后就不再安全了。


如何避免多个Agent之间的死锁或无限循环?

如何避免多个Agent之间的死锁或无限循环?

定义

在多 Agent 系统中,死锁是指多个 Agent 相互等待对方释放资源或完成前置任务,导致所有 Agent 都无法继续;无限循环是指 Agent 陷入重复执行同一行为序列、始终无法收敛到最终状态。这是多 Agent 编排中最常见的运行错误之一。

原理

死锁和循环的根本原因通常包括:(1) 循环等待——A 等 B 的结果,B 也在等 A;(2) 缺少终止条件——Agent 没有正确判断何时结束;(3) 消息冲突——两个 Agent 不断修正对方的结果;(4) 上下文退化——随着轮次增加,模型注意力分散,无法做出有效决策。

stateDiagram-v2
    [*] --> NormalOp: 正常运行

    NormalOp --> CircularLoop: Agent A修改→\nB反对A再修改B再反对
    NormalOp --> Deadlock: A等B的锁\nB等A的锁
    NormalOp --> TokenExhaustion: 循环导致\ntoken耗尽

    CircularLoop --> CycleDetected: 轮询器发现\n重复模式
    CycleDetected --> ForceBreak: 强制中断

    CircularLoop --> TokenExhaustion
    Deadlock --> Timeout: 超时检测
    Timeout --> ForceBreak

    ForceBreak --> NormalOp: 手动恢复
    TokenExhaustion --> [*]

预防与解决方案

1. 最大步数限制

MAX_TURNS = 10  # 硬限制

def run_agent_system(initial_input, max_turns=MAX_TURNS):
    turn = 0
    state = initial_input

    while not is_final(state) and turn < max_turns:
        state = execute_turn(state)
        turn += 1
        print(f"[Turn {turn}/{max_turns}]")

    if turn >= max_turns:
        print("[WARN] 超过最大轮次,强制终止")
        state["_warnings"] = ["轮次超限,结果可能不完整"]

    return state

2. 循环检测

from collections import Counter

class CycleDetector:
    """检测 Agent 行为序列的循环模式。"""

    def __init__(self, window_size: int = 5):
        self.history = []
        self.window_size = window_size

    def record_action(self, agent: str, action: str):
        """记录 Agent 的行为。"""
        self.history.append(f"{agent}:{action}")

    def has_cycle(self) -> bool:
        """检测最近的行为是否形成循环。"""
        if len(self.history) < self.window_size * 2:
            return False

        recent = self.history[-self.window_size:]
        prev = self.history[-self.window_size*2:-self.window_size]

        return recent == prev

    def state_repeating(self) -> bool:
        """检测状态是否在反复。"""
        if len(self.history) < 3:
            return False

        # 检查最后三个步骤是否与之前的三步完全相同
        recent_3 = self.history[-3:]
        for i in range(len(self.history) - 6):
            if self.history[i:i+3] == recent_3:
                return True
        return False

# 使用
detector = CycleDetector()
for state in agent_executor.stream({"input": text}):
    for node_name, value in state.items():
        action = value.get("action", "none")
        detector.record_action(node_name, action)
        if detector.has_cycle():
            print("[DETECT] 检测到循环!强制中断")
            break

3. 消息去重与幂等性

class MessageDeduplicator:
    """防止重复消息导致的循环。"""

    def __init__(self):
        self.seen_messages = set()

    def is_duplicate(self, message: dict) -> bool:
        """检查消息是否已处理过。"""
        msg_fingerprint = (
            message.get("sender", ""),
            message.get("type", ""),
            str(message.get("payload", {}))[:100],  # 截断避免 token 差异
        )
        if msg_fingerprint in self.seen_messages:
            return True
        self.seen_messages.add(msg_fingerprint)
        return False

class IdempotentAgent:
    """幂等 Agent:相同输入产生相同行为。"""

    def handle_message(self, msg: dict):
        dedup = MessageDeduplicator()
        if dedup.is_duplicate(msg):
            print(f"[SKIP] 重复消息: {msg.get('type')}")
            return  # 跳过处理
        # 正常处理逻辑...

4. 超时与看门狗

import asyncio
import time

class Watchdog:
    """看门狗:监控 Agent 总执行时间。"""

    def __init__(self, timeout_seconds: int = 60):
        self.timeout = timeout_seconds
        self.start_time = time.time()

    def check(self) -> bool:
        """检查是否超时。"""
        elapsed = time.time() - self.start_time
        if elapsed > self.timeout:
            raise TimeoutError(f"执行超时 ({elapsed:.1f}s > {self.timeout}s)")
        return True

# 结合 asyncio 的超时
async def run_with_timeout(coroutine, timeout=30):
    try:
        result = await asyncio.wait_for(coroutine, timeout=timeout)
        return result
    except asyncio.TimeoutError:
        print(f"[TIMEOUT] Agent 执行超过 {timeout}s,强制结束")
        return {"error": "timeout", "partial_result": None}

5. LLM 级别约束

# 在 system prompt 中加入循环预防
SYSTEM_PROMPT = """你是协作系统中的 Agent。
规则:
1. 当连续2次调用同一工具并得到相同结果时,停止尝试。
2. 如果发现自己已执行超过10步,强制完成任务并给出最终答案。
3. 每次行动前检查:这个操作之前是否做过?做过的就不要重复。
4. 如果无法取得进展,主动告诉协调者请求指导。
"""

要点总结

策略 原理 实现成本
最大轮次限制 硬性终止条件 低(几行代码)
循环检测 模式匹配识别重复 中(需要记录历史)
消息去重 幂等性保证 低(哈希集合)
看门狗超时 全局时间限制 低(timeout 检查)
Prompt 约束 从源头减少循环倾向 最低
状态哈希检查 检查状态是否回归 中(需要状态比较)

面试常见问题

Q: 如何区分”有价值的多轮迭代”和”无效循环”?
A: 关键指标是状态是否在进步。定义”进步”量化指标:子任务完成数增加、信息量(文本长度+新实体数)增长、状态路径的向量距离增大。当这些指标连续 2-3 步没有改善时,判定为无效循环。也可以监控”重复率”——重复的工具调用比例超过阈值时中断。

Q: AutoGen 中有内置的循环防护吗?
A: AutoGen 提供了 max_turnsmax_consecutive_auto_reply 参数防止无限对话。但更复杂的循环检测(如行为模式分析)需要自己实现。LangGraph 的 checkpointer 配合条件边可以更精确地控制执行流向并检测异常路径。

Q: 分布式多 Agent 场景中的死锁如何检测?
A: 使用分布式锁的超时机制(Redis Lock TTL),配合”等待图”(Wait-for Graph)构建 Agent 间的依赖关系。如果检测到环状等待,则选择一个 Agent 回滚释放资源(”牺牲者选择”模式)。



单例模式详解:饿汉式 / 懒汉式 / 双重校验锁 / 静态内部类 / 枚举

单例模式详解:饿汉式 / 懒汉式 / 双重校验锁 / 静态内部类 / 枚举

一、定义

单例模式(Singleton Pattern)确保一个类只有一个实例,并提供一个全局访问点。它是创建型设计模式中最简单但最常用的模式之一。

应用场景
– 数据库连接池
– 配置管理类
– 日志记录器
– 线程池
– 缓存管理器

二、饿汉式(Eager Initialization)

1. 实现

public class SingletonEager {
    // 类加载时创建实例
    private static final SingletonEager INSTANCE = new SingletonEager();

    private SingletonEager() {}

    public static SingletonEager getInstance() {
        return INSTANCE;
    }
}

2. 特点

优点 缺点
实现简单,线程安全 类加载即创建,可能浪费资源
没有锁的性能开销 不能延迟加载
运行效率高 无法传参

三、懒汉式(Lazy Initialization)

1. 线程不安全版本

public class SingletonLazyUnsafe {
    private static SingletonLazyUnsafe instance;

    private SingletonLazyUnsafe() {}

    public static SingletonLazyUnsafe getInstance() {
        if (instance == null) {
            instance = new SingletonLazyUnsafe(); // 线程不安全!
        }
        return instance;
    }
}

问题:多线程下可能创建多个实例。

2. 线程安全版本(方法加锁)

public class SingletonLazySync {
    private static SingletonLazySync instance;

    private SingletonLazySync() {}

    public static synchronized SingletonLazySync getInstance() {
        if (instance == null) {
            instance = new SingletonLazySync();
        }
        return instance;
    }
}

问题:每次调用都加锁,性能差。

四、双重校验锁(DCL)

public class SingletonDCL {
    // volatile 防止指令重排序
    private static volatile SingletonDCL instance;

    private SingletonDCL() {}

    public static SingletonDCL getInstance() {
        if (instance == null) {                    // 第一次检查(不加锁)
            synchronized (SingletonDCL.class) {     // 加锁
                if (instance == null) {             // 第二次检查(线程安全)
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
}

为什么需要 volatile

flowchart LR
    A[instance = new SingletonDCL()] --> B[1. 分配内存空间]
    B --> C[2. 初始化对象]
    C --> D[3. 指向内存地址]

    E[指令重排序后] --> F[1. 分配内存空间]
    F --> G[3. 指向内存地址 - instance非null]
    G --> H[2. 初始化对象 - 未完成]

    I[线程B判断instance != null<br>返回未初始化的对象!]
    H --> I

volatile 禁止了指令重排序,确保对象完全初始化后才赋值给引用。

五、静态内部类方式(推荐)

public class SingletonHolder {
    private SingletonHolder() {}

    // 静态内部类
    private static class Holder {
        private static final SingletonHolder INSTANCE = new SingletonHolder();
    }

    public static SingletonHolder getInstance() {
        return Holder.INSTANCE; // 第一次调用时才加载 Holder 类
    }
}

原理
– JVM 延迟加载静态内部类,只有调用 getInstance() 时才加载
– JVM 的类加载机制保证了线程安全
推荐理由:兼顾简洁、延迟加载、无锁性能最优

六、枚举方式(最安全)

public enum SingletonEnum {
    INSTANCE;

    public void doSomething() {
        // 业务方法
    }
}

优点
– 绝对防止反射攻击(反射无法创建枚举)
– 自动序列化安全(枚举的序列化是特例处理)
– 线程安全

七、各种实现方式对比

实现方式 延迟加载 线程安全 防止反射 防序列化破坏 性能
饿汉式
懒汉式(同步)
双重校验锁
静态内部类
枚举

八、面试常见问题

Q1:双重校验锁为什么需要 volatile 关键字?
A:instance = new Singleton() 不是原子操作,JVM 可能指令重排序。volatile 禁止重排序,确保对象完全初始化后才赋值,防止线程拿到未初始化完成的对象。

Q2:静态内部类方式为什么是线程安全的?
A:JVM 的类加载机制保证。类的 () 方法在类加载时由 JVM 加锁执行,同一时间只有一个线程能执行它。

Q3:枚举单例为什么能防止反射?
A:Java 语言规范规定,Constructor.newInstance() 禁止对枚举类型调用,抛出 IllegalArgumentException。枚举是唯一从语言层面直接防御反射攻击的单例实现。

Q4:Spring 中的 Bean 默认是单例的吗?和设计模式中的单例有什么区别?
A:Spring 默认单例是 IOC 容器级别的,每个容器中一个 Bean 只有一个实例。和传统单例的区别是:Spring 通过控制反转管理 Bean 生命周期,而非类自身控制。


相关文章工厂模式详解 | 代理模式详解 | 装饰器模式详解 | Spring Bean 生命周期


MySQL 锁机制详解:行锁 / 表锁 / 间隙锁 / 死锁

MySQL 锁机制详解:行锁 / 表锁 / 间隙锁 / 死锁

一、定义

MySQL 锁机制是数据库用于控制并发访问、保证数据一致性的重要手段。InnoDB 存储引擎支持多种锁粒度,在确保数据一致性的同时尽量提高并发性能。

二、锁的分类体系

1. 按粒度分类

锁类型 描述 并发性能 开销
表锁 锁住整张表
页锁 锁住一页(BDB 引擎)
行锁 锁住一行记录

2. 按模式分类

flowchart TD
    A[锁模式] --> B[共享锁 - S锁]
    A --> C[排他锁 - X锁]
    B --> D[读锁,允许多个事务同时读]
    C --> E[写锁,阻止其他事务读写]
    B -.->|兼容| B
    C -.->|不兼容| C
    C -.->|不兼容| B
    B -.->|不兼容| C
锁模式 描述 S锁兼容 X锁兼容
共享锁(S锁) 读锁
排他锁(X锁) 写锁

3. 意向锁

InnoDB 的意向锁是表级锁,用于表示事务稍后需要在行级别获得哪种锁:

  • 意向共享锁(IS):事务准备给行加共享锁
  • 意向排他锁(IX):事务准备给行加排他锁

作用:快速判断表上是否有行锁,无需逐行检查。

三、行锁详解

行锁的加锁方式

-- 共享锁(S锁)
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;

-- 排他锁(X锁)
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- UPDATE、DELETE、INSERT 会自动加排他锁

行锁的实现方式

InnoDB 的行锁是通过给索引项加锁实现的:

flowchart TD
    A[InnoDB行锁] --> B{查询走索引?}
    B -->|| C[锁定索引上的记录]
    C --> D[主键索引 → 直接锁行]
    C --> E[二级索引 → 锁二级索引+回表锁主键索引]
    B -->| - 全表扫描| F[锁定所有行]
    F --> G[实际退化为表锁]

四、表锁详解

手动加表锁

-- 读锁
LOCK TABLES users READ;

-- 写锁
LOCK TABLES users WRITE;

-- 解锁
UNLOCK TABLES;

何时使用表锁:MyISAM 引擎全量表锁;InnoDB 中某些 DDL 操作。

五、间隙锁(Gap Lock)与 Next-Key Lock

间隙锁工作原理

假设表中 id 有值:1, 3, 5, 7,则间隙如下:

flowchart LR
    subgraph 间隙范围
        A["(-∞,1)"] --> B["(1,3)"]
        B --> C["(3,5)"]
        C --> D["(5,7)"]
        D --> E["(7,+∞)"]
    end
    subgraph Next-Key Lock区间
        F["(-∞,1]"] --> G["(1,3]"]
        G --> H["(3,5]"]
        H --> I["(5,7]"]
        I --> J["(7,+∞)"]
    end

间隙锁触发条件

条件 是否加间隙锁
RR 级别 + 非唯一索引 + 范围查询
RR 级别 + 非唯一索引 + 等值查询
RR 级别 + 唯一索引 + 等值查询(命中)
RC 级别
-- 间隙锁示例(RR 级别)
BEGIN;
SELECT * FROM users WHERE id > 3 AND id < 7 FOR UPDATE;
-- 给 (3, 5], (5, 7) 之间的间隙加锁
-- 其他事务无法插入 id=4 或 id=6 的记录

Next-Key Lock = 行锁 + 间隙锁。既防止修改/删除已有记录,又防止插入新记录,彻底解决幻读问题。

六、死锁

死锁示例

-- 事务 A
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;  -- 锁住 id=1
UPDATE accounts SET balance = 1000 WHERE id = 2;  -- 等待事务B释放id=2

-- 事务 B
BEGIN;
UPDATE accounts SET balance = 300 WHERE id = 2;  -- 锁住 id=2
UPDATE accounts SET balance = 800 WHERE id = 1;  -- 等待事务A释放id=1

-- 死锁!InnoDB检测到后回滚一个事务
flowchart TD
    subgraph 死锁循环
        TA[事务A] -->|持有| L1[: id=1]
        TA -->|等待| L2[: id=2]
        TB[事务B] -->|持有| L2
        TB -->|等待| L1
    end
    L1 -.->|持有| TA
    L2 -.->|持有| TB
    TA -.->|等待| L2
    TB -.->|等待| L1

死锁解决策略
– InnoDB 自动检测死锁,回滚持有锁较少的事务
– 应用层捕获死锁异常并重试
预防策略:按固定顺序加锁,减少锁范围,降低隔离级别

七、锁机制总结表

锁类型 说明 兼容性
共享锁(S) 读锁 S兼容S,S不兼容X
排他锁(X) 写锁 X不兼容任何锁
意向共享锁(IS) 准备加S锁 表级
意向排他锁(IX) 准备加X锁 表级
间隙锁(Gap) 锁定间隙防插入 RR级别
Next-Key Lock 行锁+间隙锁 RR级别

八、面试常见问题

Q1:InnoDB 的行锁是基于什么实现的?
A:InnoDB 的行锁是基于索引实现的。如果在 UPDATE/DELETE 时没有走索引(全表扫描),行锁会退化为表锁,锁住所有行。

Q2:间隙锁在什么条件下生效?
A:间隙锁在 REPEATABLE READ 隔离级别下生效。当使用非唯一索引做条件查询(等值或范围)时,InnoDB 会在满足条件记录的前后间隙加锁。READ COMMITTED 级别下间隙锁被禁用。

Q3:如何排查死锁?
A:使用 SHOW ENGINE INNODB STATUS 查看最近死锁信息;开启 innodb_print_all_deadlocks=1;应用层捕获死锁异常并重试。

Q4:什么是隐式锁?
A:InnoDB 的优化策略。一个事务插入数据时,不立即加锁,而是通过事务 ID 标记(trx_id)隐式表示占有。如果其他事务尝试访问该行,才为其构建显式锁。


相关文章事务隔离级别详解 | 脏读/不可重复读/幻读 | InnoDB 索引原理 B+树 | 索引失效场景


Golang 并发编程面试题全解析——锁、原子操作、Context、sync 包

Golang 并发编程面试题全解析——锁、原子操作、Context、sync 包

一、引言

Go 语言的并发模型是其最大的卖点之一。go 关键字让创建 goroutine 变得极其简单,但并发编程的挑战从来不在”创建”,而在”同步”

在 Go 面试中,并发编程的考察通常占据 30%~50% 的比重。面试官不仅考察你是否了解基本的锁和 channel 用法,更关注你是否理解底层实现原理、性能取舍、以及常见陷阱

本文将从五个核心维度系统梳理 Go 并发编程的面试考点:sync.Mutex/RWMutex 原理、atomic 原子操作、Context 链路传播、sync.Map/WaitGroup/Once/Pool 等同步原语。每部分都包含源码分析、图解和面试常见追问。


二、sync.Mutex 与 RWMutex

2.1 Mutex 的演进史

Go 的 Mutex 经历了多次重大改进:

版本 状态模式 关键改进
Go 1.0 饥饿/正常二模式 基础版本
Go 1.9 饥饿模式优化 解决 goroutine 公平性问题
Go 1.14 信号量改进 减少自旋等待中的系统调用
Go 1.18+ 并发安全优化 增加 TryLock 支持

2.2 Mutex 的两种模式

graph TD
    subgraph 正常模式
        Wait[等待队列] -->|FIFO 排队| Lock1[加锁]
        Lock1 -->|被新 goroutine 插队| Starve[饥饿风险]
    end
    subgraph 饥饿模式
        Starve -->|等待 > 1ms| Switch[切换饥饿模式]
        Switch -->|等待队列头部直接获得锁| Fair[保证公平]
        Fair -->|队列为空或等待 <1ms| Back[恢复正常模式]
    end

正常模式(Normal Mode):

  • 等待者以 FIFO 顺序排队
  • 新到达的 goroutine 会尝试自旋,如果锁被释放,它们可能”插队”先获得锁
  • 性能更好(减少唤醒等待者的开销)
  • 但可能导致等待者长时间无法获得锁

饥饿模式(Starvation Mode):

  • 互斥锁直接交给等待队列头部的 goroutine
  • 新到达的 goroutine 不尝试自旋,直接进入等待队列尾部
  • 保证公平性,防止 goroutine 长时间饥饿

2.3 Mutex 源码分析

// Go 源码:sync/mutex.go
type Mutex struct {
    state int32   // 锁状态
    sema  uint32  // 信号量
}

const (
    mutexLocked     = 1 << iota // 1: 锁定状态
    mutexWoken                  // 2: 是否有被唤醒的 goroutine
    mutexStarving               // 4: 是否处于饥饿模式
    mutexWaiterShift = iota     // 3: 等待者计数的偏移量
)
// Lock 方法核心逻辑
func (m *Mutex) Lock() {
    // 快速路径:CAS 尝试加锁
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        return
    }
    // 慢速路径
    m.lockSlow()
}

2.4 RWMutex 读写锁

type RWMutex struct {
    w           Mutex   // 写锁
    writerSem   uint32  // 写等待信号量
    readerSem   uint32  // 读等待信号量
    readerCount int32   // 当前持有读锁的数量
    readerWait  int32   // 写锁等待时,等待的读锁数量
}

规则
– 多个读操作可以同时持有读锁
– 写操作必须独占(没有读锁或写锁时才能获取)
– 写锁优先级高于读锁(防止写锁饥饿)

var rw sync.RWMutex

func read() {
    rw.RLock()
    defer rw.RUnlock()
    // 并发读取,不阻塞其他读操作
}

func write() {
    rw.Lock()
    defer rw.Unlock()
    // 独占写入,阻塞所有读写操作
}

2.5 常见面试追问

Q: Mutex 的 TryLock 有什么使用场景和风险?
A: 非阻塞尝试加锁,适用于死锁检测、超时控制等场景。但过度使用会破坏锁的语义,Go 官方不推荐随意使用。

Q: 什么时候 Mutex 会进入饥饿模式?
A: 当 goroutine 等待锁的时间超过 1 毫秒时。这防止了刚释放锁被新 goroutine 抢走,导致老 goroutine 长期等不到。

Q: RWMutex 和 Mutex 的性能差异?
A: 读多写少的场景下,RWMutex 性能优于 Mutex(多读并发)。读写比例接近 1:1 时,RWMutex 可能更差(锁维护开销大)。


三、atomic 原子操作

3.1 原子操作 vs 加锁

var counter int64
var mu sync.Mutex

// 使用 Mutex 保护
func incrementWithLock() {
    mu.Lock()
    counter++
    mu.Unlock()
}

// 使用原子操作
func incrementAtomic() {
    atomic.AddInt64(&counter, 1)
}

原子操作的优势

对比维度 atomic sync.Mutex
性能 极快(CPU 指令级) 较慢(模式切换)
内存占用 无额外开销 额外 24 字节
适用场景 简单计数器、标志位 复杂共享数据的保护
可组合性

3.2 Go 支持的原子操作

import "sync/atomic"

var val int64
var ptr unsafe.Pointer

// 增减
atomic.AddInt64(&val, 1)

// 加载/存储
v := atomic.LoadInt64(&val)
atomic.StoreInt64(&val, 42)

// 比较并交换(CAS)
swapped := atomic.CompareAndSwapInt64(&val, 42, 100)

// 交换
old := atomic.SwapInt64(&val, 200)

3.3 CAS(Compare-And-Swap)原理

CAS 操作是原子操作的核心,它在硬件层面保证”比较和交换”的原子性:

// CAS 的语义(伪代码)
func CompareAndSwap(addr *int32, old, new int32) bool {
    if *addr == old {
        *addr = new
        return true
    }
    return false
}

CAS 的问题——ABA 问题

线程 1:addr 的值为 A
线程 2:将 addr 从 A 改为 B,再从 B 改回 A
线程 1:CAS 检查时 addr 仍然是 A,操作成功
       → 但数据可能已经被线程 2 修改过

ABA 问题的解决方案:使用版本号(Go 中通常使用 uint64 作为版本计数器)。

3.4 使用 atomic.Value 实现无锁并发安全

type Config struct {
    Addr string
    Port int
}

var config atomic.Value

func LoadConfig() {
    // 初始加载
    config.Store(&Config{Addr: "localhost", Port: 8080})

    // 在另一个 goroutine 中更新
    go func() {
        for {
            newCfg := &Config{
                Addr: fmt.Sprintf("server-%d", time.Now().Unix()),
                Port: 8080,
            }
            config.Store(newCfg)  // 无锁更新
            time.Sleep(time.Second)
        }
    }()
}

func GetConfig() *Config {
    return config.Load().(*Config)  // 无锁读取
}

3.5 常见面试追问

Q: atomic.AddInt64 的底层实现?
A: 在 x86 架构上使用 LOCK XADD 指令(带锁前缀的总线锁定),确保多核下的原子性。

Q: atomic.Value 的使用限制?
A: 存储的值类型必须一致,不能为 nil,不能存储 slice/map/channel/function 等非可比较类型。

Q: CAS 和 Mutex 的取舍?
A: CAS 适合简单的状态转换(标志位、计数器),Mutex 适合保护复杂的临界区。


四、Context 链路传播

4.1 Context 的设计意图

Context 的核心作用是在 goroutine 树中传递截止时间、取消信号和请求范围内的值

graph TD
    Root[context.Background] --> Req[请求级 Context]
    Req --> DB[数据库查询]
    Req --> RPC[下游 RPC 调用]
    Req --> Cache[缓存查询]
    DB -->|超时取消| DB1[子查询]
    RPC -->|级联取消| RPC1[子请求]

4.2 创建 Context

import "context"

// 根 Context
ctx := context.Background()      // 通常用于 main 函数
ctx2 := context.TODO()            // 不确定用什么 Context 时占位

// 派生 Context
// 带取消
ctx3, cancel := context.WithCancel(ctx)
defer cancel()  // 务必调用,释放资源

// 带超时
ctx4, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

// 带截止时间
ctx5, cancel := context.WithDeadline(ctx, time.Now().Add(5*time.Second))
defer cancel()

// 带值
ctx6 := context.WithValue(ctx, "user_id", 12345)

4.3 Context 的传递与使用

func handleRequest(ctx context.Context, req Request) {
    // 设置 3 秒超时
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    result := make(chan string)

    // 启动一个 goroutine 处理
    go func() {
        result <- processRequest(ctx, req)
    }()

    select {
    case res := <-result:
        fmt.Println("处理完成:", res)
    case <-ctx.Done():
        fmt.Println("请求超时或被取消:", ctx.Err())
    }
}

func processRequest(ctx context.Context, req Request) string {
    // 传递 ctx 给数据库调用
    dbCtx, _ := context.WithTimeout(ctx, 2*time.Second)
    data := queryDatabase(dbCtx, req.ID)

    // 检查是否被取消
    select {
    case <-ctx.Done():
        return ""  // 已被取消
    default:
    }

    return data
}

4.4 Context 的底层实现

Context 接口定义:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

cancelCtx 是带有取消功能的 Context 实现:

type cancelCtx struct {
    Context
    mu       sync.Mutex
    done     atomic.Value      // chan struct{},懒加载
    children map[canceler]struct{}
    err      error
}

// 取消操作
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return  // 已经被取消
    }
    c.err = err
    c.done.Store(closedchan)

    // 级联取消所有子 Context
    for child := range c.children {
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

4.5 常见面试追问

Q: Context 为什么是接口而不是结构体?
A: 为了实现可扩展性——不同的 Context 实现(cancelCtx、timerCtx、valueCtx)可以组合使用。

Q: WithValue 的查找效率?
A: O(n) 层级遍历。不建议用 Context 传递大量参数,只适合请求范围的元数据。

Q: 为什么不直接把 cancel 函数交给子 goroutine?
A: 因为 Context 的设计目的是从根到叶单向传播取消信号,而不是让子 goroutine 随意取消父 Context。


五、sync 包深度解析

5.1 sync.WaitGroup

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()  // 完成时计数器减 1
    fmt.Printf("Worker %d 开始\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d 完成\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)      // 计数器加 1
        go worker(i, &wg)
    }

    wg.Wait()  // 等待所有 goroutine 完成
    fmt.Println("所有 worker 完成")
}

注意事项

  1. Add() 必须在 go 语句之前调用
  2. WaitGroup 不能被复制(contains internal state)
  3. Wait() 可以在多个 goroutine 中并发调用
  4. 计数器不能为负数(否则 panic)

5.2 sync.Once

var (
    once   sync.Once
    config *Config
)

func GetConfig() *Config {
    once.Do(func() {
        // 这段代码只执行一次
        config = loadConfig()
    })
    return config
}

sync.Once 的实现

type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

关键点:使用原子读取 + 互斥锁 + 二次检查的模式确保 f 只执行一次。

5.3 sync.Map

Go 1.9 引入的并发安全 Map,适用于读多写少的场景

var m sync.Map

// 存储
m.Store("key1", "value1")

// 读取
value, ok := m.Load("key1")

// 删除
m.Delete("key1")

// 读取或存储
actual, loaded := m.LoadOrStore("key2", "default")

// 遍历
m.Range(func(key, value interface{}) bool {
    fmt.Println(key, value)
    return true  // 返回 false 停止遍历
})

// 比较并交换(Go 1.20+)
swapped := m.CompareAndSwap("key1", "old", "new")

sync.Map 的底层结构

type Map struct {
    mu       Mutex
    read     atomic.Value  // readOnly(无锁读取)
    dirty    map[any]*entry // 写数据
    misses   int            // 从 read 中 miss 的次数
}
graph TD
    subgraph 读操作
        R[Load] --> R1{read map 存在?}
        R1 -->|| R2[直接返回
无锁
] R1 -->|| R3[加锁 mu] R3 --> R4[从 dirty 读取] end subgraph 写操作 W[Store] --> W1{read map 存在?} W1 -->|, 已提升| W2[更新 entry
无锁
] W1 -->|| W3[加锁 mu] W3 --> W4[深入处理] end

适用场景

场景 推荐 原因
读多写少 sync.Map 无锁读取,缓存友好
读写均衡 sync.Map 或加锁 map 需基准测试
写多读少 sync.Mutex + map 无提升必要,sync.Map 的额外开销不值得

5.4 sync.Pool

sync.Pool 用于存储临时对象,减少 GC 压力:

type MyBuffer struct {
    buf []byte
}

var bufferPool = sync.Pool{
    New: func() interface{} {
        return &MyBuffer{buf: make([]byte, 1024)}
    },
}

func processRequest() {
    buf := bufferPool.Get().(*MyBuffer)
    defer bufferPool.Put(buf)  // 使用完放回池中

    // 使用 buf
    buf.buf = buf.buf[:0]  // 重置
    writeToBuffer(buf.buf)
}

注意事项

  1. Pool 中的对象可能在不通知的情况下被 GC 回收
  2. Pool 适用于存储临时对象(减少 GC 压力),不适用于持久连接池
  3. 每个 P 有自己的本地池,减少锁竞争

5.5 sync.Cond

var (
    mu    sync.Mutex
    cond  = sync.NewCond(&mu)
    ready = false
)

func waitForReady() {
    mu.Lock()
    defer mu.Unlock()
    for !ready {
        cond.Wait()  // 释放锁并等待通知
    }
    fmt.Println("就绪!")
}

func setReady() {
    mu.Lock()
    ready = true
    cond.Broadcast()  // 唤醒所有等待者
    mu.Unlock()
}

5.6 常见面试追问

Q: sync.Pool 的对象什么时候被回收?
A: 每个 GC 周期都会清理 Pool。Pool 内部每个 P 维护一个本地池,GC 时清空。

Q: sync.Map 的 read 和 dirty 什么时候同步?
A: 当从 read 中 miss 的次数(reads % 2 == 0)达到一定阈值时,会将 dirty 提升为 read。

Q: WaitGroup 的计数器能否重用?
A: 可以,但需要确保之前 Wait 的调用已经返回。不推荐重用,容易出错。


六、总结

本文从五个核心维度系统梳理了 Go 并发编程面试的高频考点:

知识点 核心要点 面试深度
Mutex/RWMutex 正常/饥饿双模式、自旋策略、读写锁优先级 ⭐⭐⭐⭐⭐
atomic 原子操作 CAS 原理、ABA 问题、atomic.Value、CPU 指令级 ⭐⭐⭐⭐
Context 取消传播、超时控制、值传递、级联取消 ⭐⭐⭐⭐⭐
sync 包 WaitGroup/Once/Map/Pool 的实现与适用场景 ⭐⭐⭐⭐
面试陷阱 复制 Mutex、死锁检测、race condition 调试 ⭐⭐⭐⭐⭐

给面试者的建议:

  1. 不要死记硬背源码,要理解设计选择——比如 Mutex 为什么有饥饿模式、Context 为什么要用树形结构
  2. 实践出真知——日常编码中有意识使用 atomic.Value 和 sync.Pool,理解它们的性能边界
  3. 调试工具要熟练——使用 -race 检测竞态、pprof 分析锁争用、trace 工具查看 goroutine 调度
  4. 多练手写代码——面试中常考”用 channel 实现互斥锁”、”用 Context 实现超时控制”这类手写题

并发编程是 Go 的灵魂,也是面试中拉开分差的关键领域。深入理解这些同步原语,你不仅能在面试中脱颖而出,更能写出真正生产级别的并发 Go 程序。

祝面顺利! 🚀

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

请登录后发表评论

    暂无评论内容