📌 本文由 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;
面试要点
- 定义:悲观锁是通过数据库锁机制实现的并发控制,先加锁再操作,防止并发冲突
- 核心 SQL:
SELECT ... FOR UPDATE(X 锁)、SELECT ... FOR SHARE(S 锁) - 新特性:NOWAIT、SKIP LOCKED 用于高并发场景避免等待
- 优缺点:保证数据一致性,但降低并发性能,有死锁风险
- 与乐观锁的对比:悲观锁写入强一致但并发差,乐观锁并发好但需处理冲突
乐观锁的实现方式
乐观锁的实现方式
概述
乐观锁(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 文章编辑、用户资料修改、配置管理
❌ 不适合使用
- 写冲突激烈:大量并发写同一个数据(→ 大量重试,性能差)
- 不允许失败:扣库存、支付等必须成功的操作
- 长事务:冲突窗口大
面试要点
- 乐观锁的本质:不加锁,提交时检查冲突
- 三种实现:版本号(最推荐)、时间戳、条件字段
- 与悲观锁对比:乐观锁适合读多写少,悲观锁适合写冲突多
- 经典问题:乐观锁怎么实现?→ 立即回答”版本号 CAS”,并给出 SQL 示例
- 重试必须:使用乐观锁时应用层必须实现重试逻辑
如何避免和减少死锁
如何避免和减少死锁
概述
死锁是数据库并发控制中不可避免的风险,但通过合理的设计和编码规范,可以大幅降低死锁发生的概率。掌握避免死锁的策略,是每个后端工程师的重要技能。
死锁的四个必要条件
回顾死锁必须同时满足的四个条件:
- 互斥:资源每次只能被一个事务使用
- 持有并等待:事务持有资源的同时等待其他资源
- 不可剥夺:资源只能由持有者主动释放
- 循环等待:事务之间形成环形等待链
打破任何一个条件,就能避免死锁。
策略一:固定访问顺序
原理
打破”循环等待”条件。如果所有事务按相同的顺序访问资源,就不会形成环路。
反面示例
-- ❌ 事务 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 | ⭐⭐ |
| 应用层重试 | 死锁发生后自动重试 | ⭐⭐ |
面试要点
- 核心原则:打破死锁四个条件中的任意一个
- 最有效的方法:固定资源访问顺序 + 缩小事务范围
- 经典反问:面试官问”如何避免死锁”时,从事务编码、索引设计、应用重试三个层面回答
- 补充:强调死锁无法完全避免,应用层必须有重试机制
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;
面试要点
- 检测机制:等待图算法(Wait-For Graph),寻找环
- 解决策略:选择代价最小的事务回滚
- 错误码:1213(40001),应用层应做好重试
- 配置项:
innodb_deadlock_detect开关 - 与锁等待超时的区别:死锁检测是主动发现并解决;锁等待超时是被动等待超时
自增锁机制详解
自增锁机制详解
概述
自增锁(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;
最佳实践
- 保持默认的 consecutive 模式(innodb_autoinc_lock_mode=1)
- 不要依赖自增 ID 的连续性
- 在
INSERT ... SELECT批量插入时,注意自增锁会退化为表锁 - 考虑使用
binlog_format=ROW避免复制问题 - 如果对 ID 顺序有严格要求,考虑使用其他非自增 ID 方案
面试要点
- 自增锁的模式:traditional(0)、consecutive(1,默认)、interleaved(2)
- 自增锁是表级锁,但立即释放——不参与事务锁
- 自增 ID 不保证连续性,这是设计如此
- 批量插入(
INSERT ... SELECT)在默认模式下会退化为表锁 - 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 的死锁检测就是”剥夺”机制(回滚一个事务) |
| 循环等待 | ✅ 固定资源访问顺序 |
面试要点
- 死锁的四个条件缺一不可:互斥 + 持有并等待 + 不可剥夺 + 循环等待
- InnoDB 通过死锁检测自动打破”不可剥夺”,回滚代价最小的事务
- 最常见的原因是程序逻辑导致的循环等待——同一组资源的不同访问顺序
- 间隙锁使死锁更隐蔽——锁住不存在的行也会导致死锁
- 防止死锁的最佳实践是固定资源访问顺序和缩短事务时间
表锁与 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; -- 等待表锁
面试要点
- 表锁保护数据,MDL 保护结构——两者用途完全不同
- 表锁可以手动加(LOCK TABLES),MDL 自动管理
- MDL 阻塞链是生产环境常见问题——一个慢查询阻塞 DDL,DDL 阻塞所有后续请求
- 使用
performance_schema.metadata_locks排查 MDL 问题 - InnoDB 中尽量避免手动 LOCK TABLES
- 执行 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 | ❌ | ❌ | ❌ | ❌ |
关键规则
- 意向锁之间是兼容的:IS 和 IS、IS 和 IX、IX 和 IX 都可以同时存在
- 表级 S 锁与 IX 锁互斥:说明有事务要写行级数据,不能被共享锁阻塞
- 表级 X 锁与一切互斥:独占整张表
- 意向锁只阻塞表级锁,不阻塞行级锁
意向锁的工作原理
加锁顺序
事务 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' 表示意向排他锁(表级)
面试要点
- 意向锁是表级锁,但用于保护行级锁
- 核心作用:高效判断表级锁是否能立即授予,无需逐行检查
- IS/IX 之间兼容,只与表级 S/X 锁冲突
- 自动管理:InnoDB 自动加,开发人员无需关心
- 加锁顺序:先加意向锁(表级),再加行锁(行级)
- 性能影响极小:轻量标记操作
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 Length:
SHOW ENGINE INNODB STATUS中的 History list length 过高提示版本堆积 - 合理隔离级别:大多数场景 REPEATABLE READ 或 READ COMMITTED 足够
面试要点
- MVCC 是什么:多版本并发控制,读不阻塞写,写不阻塞读
- 核心组成:隐藏列 + Undo Log + Read View + Purge 线程
- 实现差异:不同隔离级别的 MVCC 行为差异(Read View 创建时机)
- 英文全称:Multi-Version Concurrency Control,面试时一口说出加分
- 关联知识点:常与事务隔离级别、锁、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 下获得一致性快照
-- 导出过程中其他事务可以继续写入,互不阻塞
面试要点
- 一句话总结 MVCC 解决的问题:在不加锁的前提下,为事务提供一致性读的能力,实现”读不阻塞写,写不阻塞读”
- 三个核心能力:一致性快照 + 读写不互斥 + 回滚能力
- MVCC 不是万能的:写写冲突仍需锁,幻读仍需间隙锁
- 高频面试题:”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;
最佳实践
- 保持默认的 consecutive 模式(innodb_autoinc_lock_mode=1)
- 不要依赖自增 ID 的连续性
- 在
INSERT ... SELECT批量插入时,注意自增锁会退化为表锁 - 考虑使用
binlog_format=ROW避免复制问题 - 如果对 ID 顺序有严格要求,考虑使用其他非自增 ID 方案
面试要点
- 自增锁的模式:traditional(0)、consecutive(1,默认)、interleaved(2)
- 自增锁是表级锁,但立即释放——不参与事务锁
- 自增 ID 不保证连续性,这是设计如此
- 批量插入(
INSERT ... SELECT)在默认模式下会退化为表锁 - 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 的死锁检测就是”剥夺”机制(回滚一个事务) |
| 循环等待 | ✅ 固定资源访问顺序 |
面试要点
- 死锁的四个条件缺一不可:互斥 + 持有并等待 + 不可剥夺 + 循环等待
- InnoDB 通过死锁检测自动打破”不可剥夺”,回滚代价最小的事务
- 最常见的原因是程序逻辑导致的循环等待——同一组资源的不同访问顺序
- 间隙锁使死锁更隐蔽——锁住不存在的行也会导致死锁
- 防止死锁的最佳实践是固定资源访问顺序和缩短事务时间
表锁与 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; -- 等待表锁
面试要点
- 表锁保护数据,MDL 保护结构——两者用途完全不同
- 表锁可以手动加(LOCK TABLES),MDL 自动管理
- MDL 阻塞链是生产环境常见问题——一个慢查询阻塞 DDL,DDL 阻塞所有后续请求
- 使用
performance_schema.metadata_locks排查 MDL 问题 - InnoDB 中尽量避免手动 LOCK TABLES
- 执行 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 | ❌ | ❌ | ❌ | ❌ |
关键规则
- 意向锁之间是兼容的:IS 和 IS、IS 和 IX、IX 和 IX 都可以同时存在
- 表级 S 锁与 IX 锁互斥:说明有事务要写行级数据,不能被共享锁阻塞
- 表级 X 锁与一切互斥:独占整张表
- 意向锁只阻塞表级锁,不阻塞行级锁
意向锁的工作原理
加锁顺序
事务 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' 表示意向排他锁(表级)
面试要点
- 意向锁是表级锁,但用于保护行级锁
- 核心作用:高效判断表级锁是否能立即授予,无需逐行检查
- IS/IX 之间兼容,只与表级 S/X 锁冲突
- 自动管理:InnoDB 自动加,开发人员无需关心
- 加锁顺序:先加意向锁(表级),再加行锁(行级)
- 性能影响极小:轻量标记操作
乐观锁在 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 的核心优势
- Raft 共识算法:强一致性保证,不会丢锁
- Lease 机制:基于租约的自动续约,类似 Watchdog
- TTL + 心跳:客户端崩溃后锁自动释放
- 云原生生态: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 释放锁 → 释放的是客户端的锁 ⚠️
解决方案
- 估大不估小:宁可设长一点,也要避免业务未完成锁先过期
- 使用 Watchdog:让框架处理续约
- 加监控告警:监控锁持有的时长分布,发现异常及时调整
超时时间过长导致的问题
问题现象
// 锁 TTL = 30 分钟
lock.tryLock(key, value, 30, TimeUnit.MINUTES);
// 客户端获取锁后崩溃了...
// 锁需要 30 分钟才能自动释放
// 这 30 分钟内其他客户端无法获取该锁 ← ⚠️ 可用性问题
解决方案
- 设短默认,用 Watchdog 续约:默认 30 秒,续约到业务完成
- 增强容错:使用
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:你们的分布式锁用的什么方案?有没有遇到主从切换丢锁的问题?
建议分几个层次回答:
- 承认问题:Redis 主从架构下,分布式锁确实存在丢失风险
- 评估风险:我们评估了丢锁的概率和影响,在我们的场景下(库存扣减/防重复支付)可以接受,并且有补偿机制
- 防护措施:我们增加了监控(
WAIT命令的延迟和成功率),业务层有幂等和补偿 - 未来规划:如果业务发展到对一致性要求更高,会考虑迁移到 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 会在以下情况自动停止:
- 手动释放锁:
lock.unlock()→ Lua 脚本删除 Hash 中的字段
– 如果重入计数减到 0,删除整个 key
– Watchdog 发现 key 不存在,不再续约 - 客户端崩渍:进程退出,Watchdog 线程退出
– 锁会在 TTL 到期后自动释放 - 锁过期:如果 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) | ✅(主从) |
| 可重入 | ❌ | ✅ | ✅ | ❌ |
| 高性能 | ✅ | ✅ | ❌ | ❌ |
面试考点
面试时被问到分布式锁,建议按这个框架回答:
- 互斥性:分布式锁的首要目标,必须保证
- 防死锁:通过 TTL/Session 确保锁自动释放
- 容错性:锁服务本身的高可用设计
- 可重入:同一客户端能重复获取(非必需但重要)
- 高性能:加解锁操作要快
最后补充:我们公司目前使用 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 的局限性
- 业务执行可能超时:加锁时设置的 TTL 是固定的,业务逻辑如果执行时间超过 TTL,锁自动释放,造成并发
- 主从架构的缺陷:Master 写入锁后宕机,锁还没同步到 Slave,其他客户端可以成功获取锁
- 无法重入:同一个客户端不能重复加锁(需要额外实现)
- 非公平:谁先抢到谁用,不保证请求顺序
面试要点
- 用 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 是分布式锁的主流选择
- 性能极强:基于内存,单机 10 万+ QPS
- 部署广泛:大部分系统已经在用 Redis 做缓存
- 原子操作:SET NX EX 天然支持加锁的原子操作
- TTL 支持:内置过期机制避免死锁
- 成熟生态: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"]
死锁的四个必要条件
- 互斥:资源一次只能被一个线程使用
- 持有并等待:线程已持有资源,同时等待其他资源
- 不可剥夺:资源不能被强制夺走
- 循环等待:存在
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)实现无锁队列是最常见的方式:
- 队列维护头指针(head)和尾指针(tail)
- 入队(Enqueue):CAS 更新 tail->next,然后 CAS 更新 tail
- 出队(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
}
}
}
}
}
要点
- ABA 问题:CAS 操作中,值从 A→B→A 变化,CAS 会误判未修改。Go 的指针 CAS 有 GC 保障,在 64 位系统上 ABA 风险较低。需要完全解决可使用 tagged 指针或使用
atomic.Value。 - 哨兵节点:队列始终保持一个空的哨兵节点,避免 head 和 tail 为 nil 的特殊情况处理。
- 帮助机制:当一个线程发现 tail 滞后时,主动帮助推进 tail,这是无锁编程中常见的协作模式。
- memory ordering:Go 的
atomic包默认提供顺序一致性(sequentially consistent),保证可见性。 - 性能考量: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配合default或time.After避免永久阻塞 - ❌ 不要在持有锁时通过通道通信(容易锁顺序死锁)
- 💡 调试时使用
runtime.Stack或SIGQUIT查看 goroutine 状态
锁的复制警告
锁的复制警告
问题:为什么不能复制锁
sync.Mutex 和 sync.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_turns 和 max_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 完成")
}
注意事项:
Add()必须在go语句之前调用WaitGroup不能被复制(contains internal state)Wait()可以在多个 goroutine 中并发调用- 计数器不能为负数(否则 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)
}
注意事项:
- Pool 中的对象可能在不通知的情况下被 GC 回收
- Pool 适用于存储临时对象(减少 GC 压力),不适用于持久连接池
- 每个 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 调试 | ⭐⭐⭐⭐⭐ |
给面试者的建议:
- 不要死记硬背源码,要理解设计选择——比如 Mutex 为什么有饥饿模式、Context 为什么要用树形结构
- 实践出真知——日常编码中有意识使用 atomic.Value 和 sync.Pool,理解它们的性能边界
- 调试工具要熟练——使用
-race检测竞态、pprof 分析锁争用、trace 工具查看 goroutine 调度 - 多练手写代码——面试中常考”用 channel 实现互斥锁”、”用 Context 实现超时控制”这类手写题
并发编程是 Go 的灵魂,也是面试中拉开分差的关键领域。深入理解这些同步原语,你不仅能在面试中脱颖而出,更能写出真正生产级别的并发 Go 程序。
祝面顺利! 🚀


暂无评论内容