适用于 MySQL InnoDB、PostgreSQL 等支持行级锁的 MVCC 引擎
重点以 MySQL InnoDB 为例说明


一、基本概念

锁类型 全称 别名 作用
S 锁 Shared Lock 共享锁、读锁 允许多个事务同时读同一数据
X 锁 Exclusive Lock 排他锁、写锁 确保只有一个事务能修改数据

二、加锁方式(MySQL InnoDB)

操作 加的锁 说明
SELECT ... LOCK IN SHARE MODE S 锁 显式加共享锁(MySQL 特有语法)
SELECT ... FOR UPDATE X 锁 显式加排他锁(通用)
UPDATE / DELETE X 锁 自动对匹配行加 X 锁
普通 SELECT 无锁 使用 MVCC 快照读,不加任何锁

💡 PostgreSQL 中对应语法为 SELECT ... FOR SHARE(S 锁)和 SELECT ... FOR UPDATE(X 锁)


三、锁兼容性矩阵(关键!)

当前持有 → 请求 ← S 锁 X 锁
S 锁 ✅ 兼容 ❌ 冲突
X 锁 ❌ 冲突 ❌ 冲突

✅ 含义:

  • 多个事务可同时持有 S 锁(并发读)
  • X 锁是独占的:不能与其他任何锁共存
  • S 与 X 互斥:有 S 锁时不能加 X 锁,反之亦然

四、详细行为对比 + 示例

场景设定

1 -- 表结构
2 CREATE TABLE products (
3    id INT PRIMARY KEY,
4    stock INT
5 );
6 INSERT INTO products VALUES (1001, 10);

1. X 锁(排他锁)—— FOR UPDATE

🔒 加锁语句

SELECT * FROM products WHERE id = 1001 FOR UPDATE;

🧪 并发行为测试

会话A(持 X 锁) 会话B 尝试操作 结果
START TRANSACTION; SELECT ... FOR UPDATE; SELECT stock FROM products WHERE id = 1001; 立即返回(普通读,MVCC)
SELECT ... FOR UPDATE; 阻塞
SELECT ... LOCK IN SHARE MODE; 阻塞
UPDATE products SET stock = 5 WHERE id = 1001; 阻塞
UPDATE products SET stock = 5 WHERE id = 1002; 立即成功(不同行)

✅ 适用场景

  • 电商扣库存
  • 银行转账前锁定账户
  • “读完就改”的悲观锁流程

💡 示例(Spring Boot)

1 @Transactional
2 public boolean deductStock(Long id) {
3    // 加 X 锁
4    Integer stock = jdbcTemplate.queryForObject(
5        "SELECT stock FROM products WHERE id = ? FOR UPDATE", 
6        Integer.class, id
7    );
8    
9    if (stock > 0) {
10        jdbcTemplate.update("UPDATE products SET stock = stock - 1 WHERE id = ?", id);
11        return true;
12    }
13    return false;
14}

2. S 锁(共享锁)—— LOCK IN SHARE MODE

🔒 加锁语句

SELECT * FROM products WHERE id = 1001 LOCK IN SHARE MODE;

🧪 并发行为测试

会话 A(持 S 锁) 会话 B 尝试操作 结果
START TRANSACTION; SELECT ... LOCK IN SHARE MODE; SELECT stock FROM products WHERE id = 1001; 立即返回(普通读)
SELECT ... LOCK IN SHARE MODE; 立即返回(S 与 S 兼容)
SELECT ... FOR UPDATE; 阻塞
UPDATE products SET stock = 5 WHERE id = 1001; 阻塞
UPDATE products SET stock = 5 WHERE id = 1002; 立即成功(不同行)

如果有一个加了 @Transactional 并对某行加了 FOR UPDATE 锁的事务(方法 A)正在执行,
另一个
没有事务(或独立事务)的方法 B 直接执行 UPDATE 操作同一行,
那么方法 B 会不会被阻塞?要不要等方法 A 执行完?

无论方法 B 是否加了 @Transactional,只要它要修改被 X 锁保护的行,就会被阻塞。

✅ 适用场景

  • 生成一致性报表(防止底层数据被修改)
  • 引用完整性检查(如“删除用户前确认无订单”)
  • 多步骤只读事务需防中间修改

💡 示例:安全删除用户

1 -- 检查是否有未完成订单(加 S 锁防新增/修改)
2 SELECT 1 FROM orders WHERE user_id = 123 LOCK IN SHARE MODE;
3
4 -- 若无结果,再删除用户(此时订单表仍被 S 锁保护)
5 DELETE FROM users WHERE id = 123;

五、关键注意事项

1. 必须在事务中使用

🔒 锁的生命周期 = 事务的生命周期
→ 只有在整个事务提交(或回滚)后,锁才会释放。

1  // ❌ 错误:无 @Transactional → 锁立即释放
2  public void bad() {
3    repo.findWithLock(id); // FOR UPDATE 执行完就 COMMIT,锁失效!
    // 此时锁已释放!其他请求可以同时进入
    
    if (stock > 0) {
        // 2. 更新(无锁!)
        update("UPDATE products SET stock = stock - 1 WHERE id = ?", id);
    }
4  }
5
6  // ✅ 正确
7  @Transactional
8  public void good() {
9    repo.findWithLock(id); // 锁持续到方法结束(事务提交)
10 }

2. 锁粒度依赖索引

  • WHERE id = ?(主键)→ 只锁一行
  • WHERE name = ?(无索引)→ 可能锁全表(因全表扫描)

🔍 用 EXPLAIN 验证是否走索引!

3. 普通 SELECT 永远不阻塞

  • 无论 S 锁还是 X 锁,普通 SELECT 都通过 MVCC 读历史版本,不加锁、不等待

4. 死锁风险

  • 多事务交叉加锁顺序不一致 → 可能死锁
  • 数据库会自动回滚一个事务(如 MySQL 报 Deadlock found
  • 应用需捕获异常并重试

六、对比总结表

特性 S 锁(共享锁) X 锁(排他锁)
加锁语法 SELECT ... LOCK IN SHARE MODE SELECT ... FOR UPDATE
是否允许多个并发 ✅ 是 ❌ 否
阻止其他事务读? ❌(普通读不受影响) ❌(普通读不受影响)
阻止其他事务写? ✅ 是 ✅ 是
阻止其他事务加 S 锁? ❌ 否 ✅ 是
典型用途 读时防改(报表、校验) 读后续改(扣库存、转账)
死锁风险 较低 较高

七、最佳实践建议

  1. 优先使用 X 锁(FOR UPDATE:逻辑更简单,避免 S-X 转换复杂性
  2. 确保 WHERE 条件有索引 → 避免意外锁表
  3. 事务尽量短 → 减少锁持有时间
  4. 普通查询不要加锁 → 用 MVCC 快照读即可
  5. 处理死锁异常 → 实现重试机制

记住一句话

X 锁 = “我读完要改,谁都别动”。
普通 SELECT 永远自由通行,靠 MVCC 保证一致性。