深入理解 DBMS 中的基于锁的并发控制协议:从原理到实战

在构建现代高并发应用时,我们经常面临一个棘手的挑战:如何确保数据库在同时处理成千上万个请求时,数据依然保持准确和一致?试想一下,如果两个银行转账操作同时修改同一个账户余额,而没有适当的管控,后果不堪设想。这就是数据库管理系统(DBMS)中并发控制机制大显身手的时候。

在本文中,我们将深入探讨 DBMS 中最基础也是最核心的并发控制技术——基于锁的协议。我们将一起探索这些协议是如何在幕后工作的,了解共享锁与排他锁的区别,并通过实际的代码示例和场景分析,掌握如何在保证数据安全的前提下,最大限度地提升系统的并发性能。

为什么我们需要并发控制?

首先,让我们达成一个共识:数据库通常是多用户共享的。当多个事务同时执行时,如果不加以控制,可能会引发一系列严重的数据一致性问题,例如我们常说的“更新丢失”、“脏读”或“不可重复读”。

锁机制的本质,其实就是一种“通行证”制度。它确保在同一时间,只有一个事务能够对特定的关键数据进行修改。我们可以把它看作是强制事务排队的机制,以此来维护数据的完整性。基于锁的协议核心原则非常简单:事务在想要读取或写入数据之前,必须先获得该数据项的锁。

核心概念:锁的类型与规则

在 DBMS 中,锁主要分为两种类型,理解它们是掌握并发协议的第一步。

1. 共享锁

共享锁,也常被称为“读锁”。顾名思义,这种锁的态度是非常“友善”的。

  • 用途:当事务只需要读取数据,而不打算修改它时,我们会请求一个共享锁。
  • 特性:多个事务可以同时持有同一数据项上的共享锁。这就好比多个人可以同时阅读同一张公告栏上的通知,互不干扰。
  • 操作指令:通常表示为 lock-S

2. 排他锁

排他锁,也被称为“写锁”。这是锁机制中最严格的一种。

  • 用途:当事务想要写入或更新数据时,必须请求排他锁。
  • 特性:一旦一个事务持有了某数据项的排他锁,其他任何事务都无法再获得该数据项的任何类型的锁(无论是读锁还是写锁)。这就像你在使用一台公共打印机时,锁上了门,其他人必须等你用完才能进来。
  • 操作指令:通常表示为 lock-X

锁的兼容性矩阵

为了更直观地理解这两把锁如何协同工作,我们可以参考下面的兼容性矩阵。这决定了我们的锁请求是否会被批准。

请求锁 \ 当前持有锁

无锁

共享锁 (S)

排他锁 (X)

:—

:—:

:—:

:—:

共享锁 (S)

✅ 兼容

✅ 兼容

❌ 冲突

排他锁 (X)

✅ 兼容

❌ 冲突

❌ 冲突关键规则解读:

  • 读读相容:如果 T1 持有读锁,T2 请求读锁,系统会批准。因为你们都只是看,不会破坏数据。
  • 读写互斥:如果 T1 持有读锁,T2 请求写锁,T2 必须等待。反之亦然。我们要防止在你读数据的时候,有人偷偷把数据改了。
  • 写写互斥:如果 T1 在写数据,T2 想写,T2 必须等待。如果两个写操作同时进行,必定会导致数据覆盖和损坏。

如果请求的锁与现有锁不兼容,请求的事务将被强制进入等待状态,直到持有锁的事务释放了不兼容的锁。

基于锁的协议类型

在实际的数据库系统中,并不是仅仅有了锁就万事大吉了。我们还需要一套规则来规定什么时候加锁、什么时候解锁。这就是“协议”存在的意义。让我们看看几种常见的协议及其演变。

1. 简单锁协议

这是最基础的实现方式。它的逻辑非常直接:凡是要用数据,先加锁;用完数据,就解锁。

虽然简单,但这里隐藏着一个巨大的风险。让我们通过一个具体的场景来看看它是如何工作的,以及它的局限性。

#### 场景示例:银行转账更新

假设我们有一个数据库,其中包含一个数据项 INLINECODE7935c740 代表账户余额,初始值为 INLINECODEe2f2083f。

事务 T1:INLINECODEe87fc78f -> INLINECODEa209c303 -> Write(X) (存款 10 元)
事务 T2:INLINECODE6934be55 -> INLINECODEef92aa9f (查询余额)
执行流程:

  • T1 请求排他锁:T1 想要更新 INLINECODEb986dd11,于是请求 INLINECODE5db2be2d。系统批准。
  • T1 执行操作:T1 读取 INLINECODE21b79b18,计算得到 INLINECODE39444713,并将 20 写回数据库。
  • T2 请求共享锁:与此同时,T2 想要查询余额,请求 lock-S(X)
  • 发生冲突:由于 T1 还没释放排他锁,T2 的请求被阻塞,必须等待。
  • T1 完成:T1 提交事务,释放 lock-X(X)
  • T2 获得锁:T2 现在获得了 INLINECODEc988de26,读取到了最新的余额 INLINECODE23e17723。

局限性分析

在这个简单的例子中,一切似乎都很完美。但是,简单锁协议无法保证可串行性,也无法防止死锁。更糟糕的是,如果在 T1 更新后、提交前,T2 获得了锁,T2 可能会读到“脏数据”(未提交的数据)。为了解决这些问题,我们需要更严格的协议。

2. 预声明锁协议

死锁是并发控制中的噩梦。预声明协议是一种用来避免死锁的策略。它的核心思想是:“要么全要,要么全不要”

在这个协议下,事务在开始执行任何操作之前,必须预先声明它将要锁定的所有数据项。如果系统能够授予所有这些锁,事务才能开始执行;如果哪怕有一个锁无法授予(因为被其他事务占用),该事务就必须等待,且一个锁都不会拿到。

#### 场景示例:避免死锁的转账

考虑两个事务 T1 和 T2,以及两个账户 INLINECODE535e12c4 和 INLINECODE48630c46。

事务 T1:从 INLINECODE3cdc4782 转 100 元到 INLINECODEa363af40(需要写锁 X 和 Y)
事务 T2:从 INLINECODE412ca158 转 50 元到 INLINECODE6cdfd233(需要写锁 Y 和 X)

如果没有预声明协议,可能会出现这种情况:T1 锁住 X,T2 锁住 Y,然后互相等待对方的锁,形成死锁。

使用预声明协议的流程:

  • T1 启动阶段:T1 向系统请求 {lock-X(X), lock-X(Y)}。系统检查发现 X 和 Y 都空闲,全部批准
  • T1 执行:T1 获得 X 和 Y 的锁,开始执行转账逻辑,最后释放所有锁。
  • T2 启动阶段:T2 向系统请求 {lock-X(Y), lock-X(X)}
  • 发生等待:如果 T1 还没结束,系统发现至少有一个锁(比如 Y)被占用。系统拒绝 T2 的请求,T2 进入等待状态,不会占用任何资源。

优点:彻底避免了死锁,因为事务不会持有部分资源去等待其他资源。
缺点:并发度明显降低。在 T1 运行期间,即使 T2 只想操作 X,也必须傻傻地等到 T1 彻底结束。

3. 两阶段锁协议 (2PL) —— 工业界的标准

这是目前大多数商业数据库系统(如 MySQL, Oracle, PostgreSQL)所使用或变体基于的核心协议。为了在可串行性和并发性之间取得最佳平衡,2PL 要求事务分为两个阶段来处理锁:

  • 增长阶段:事务可以申请获得任何数据项上的任何类型的锁,但是不能释放任何锁
  • 缩减阶段:事务可以释放任何数据项上的任何类型的锁,但是不能再申请新的锁

一旦事务释放了锁(进入缩减阶段),它就不再允许请求任何新的锁。这个规则保证了事务调度的可串行性。

代码逻辑示例:

# 伪代码:2PL 的事务结构

# --- 阶段 1: 增长阶段 ---
# 1. 请求读锁
lock_S(A)
read(A)

# 2. 请求写锁
lock_X(B)
read(B)
B = B + A
write(B)

# --- 关键点:从此刻开始,进入缩减阶段 ---

# 3. 释放锁 (不能再请求 lock-C 之类的操作了)
unlock(A)
unlock(B)

2PL 的变体:

  • 严格两阶段锁:事务持有的所有排他锁必须在事务结束时(提交或中止)才释放。这是为了防止“脏读”和级联回滚。
  • 强严格两阶段锁:事务持有的所有锁(包括读锁和写锁)都只能在事务结束时释放。这是最严格的实现,保证了调度的可恢复性。

4. 实战代码示例与应用场景

让我们通过一个具体的 SQL 场景来看看这些锁是如何在代码层面体现的。虽然 DBMS 自动管理锁,但理解其背后的逻辑有助于我们写出高效的 SQL。

#### 示例 1:标准的行锁更新 (MySQL InnoDB)

-- 假设我们有一个用户表 users,id 为主键

BEGIN;

-- 1. T1: 排他锁更新
-- 这一步,T1 获得了 id=1 这一行记录的排他锁 (X 锁)
UPDATE users SET balance = balance + 100 WHERE id = 1;

-- 此时,如果另一个事务 T2 试图更新 id=1,它会被阻塞。
-- SELECT ... FOR UPDATE 也会产生排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE; 

COMMIT; -- 锁在此刻释放

解读:在 INLINECODE5a0809dc 之前,T1 对这行数据拥有绝对的控制权。如果 T2 试图执行 INLINECODEe315421b,它会发现锁被占用,只能等待 T1 提交。这就是 2PL 的典型表现。

#### 示例 2:共享锁带来的并发性

-- T1: 查询数据
BEGIN;

-- 使用 LOCK IN SHARE MODE 获取共享锁 (S 锁)
SELECT balance FROM users WHERE id = 1 LOCK IN SHARE MODE;
-- T1 此时并未修改数据,只是锁定以防别人修改

-- T2: 同时查询
-- 因为 S 锁兼容,T2 也可以成功执行并瞬间返回结果
SELECT balance FROM users WHERE id = 1 LOCK IN SHARE MODE;

-- T3: 尝试更新
-- UPDATE 请求 X 锁,X 与 S 冲突,T3 被阻塞,直到 T1 (和 T2) 释放 S 锁。
-- UPDATE users SET balance = 0 WHERE id = 1;

COMMIT;

实战见解:这种机制非常适合报表统计场景。你希望读取的数据在读取期间保持不变(以便计算出正确的总和),但又不希望阻塞其他同样只需要读取的报表程序。

常见陷阱与性能优化建议

理解了原理之后,我们在实际开发中还需要注意一些“坑”。

1. 警惕锁粒度

你可能会遇到这样的情况:你的事务看起来很简单,却把整个表锁住了。

  • 问题:如果你在查询中使用没有索引的列作为条件,或者执行全表扫描,数据库可能会为了确保一致性,将锁的粒度从“行锁”升级为“表锁”。这会导致整个系统的并发性能瞬间跌停。
  • 解决方案永远为查询条件建立适当的索引。确保锁只落在必要的数据行上,而不是波及整张表。

2. 锁等待超时与死锁回滚

虽然 2PL 保证了可串行性,但它无法避免死锁,只能检测死锁。

  • 场景:T1 等待 T2,T2 等待 T1。数据库的检测机制会发现这个循环,然后强行“牺牲”其中一个事务(抛出 Deadlock found when trying to get lock 错误)。
  • 解决方案:作为开发者,你的应用程序必须具备重试机制。当你捕获到死锁错误时,不要直接向用户报错,而是稍等片刻,重新发起事务。

3. 缩短锁的持有时间

这是一个极好的性能优化建议:让事务尽可能短小精悍。

  • 反模式:在数据库事务中间去调用一个慢速的外部 API,或者进行复杂的文件处理。
  • 正确做法:在 BEGIN 之前做好所有数据准备,只把纯粹的数据库读写操作放在事务中。锁持有时间越短,其他事务等待的时间就越短,系统吞吐量就越高。

总结与后续步骤

在这篇文章中,我们像剥洋葱一样,层层深入地探讨了 DBMS 中的基于锁的并发控制协议。我们从最基础的“互斥”需求出发,学习了共享锁与排他锁的区别,通过矩阵理解了它们的兼容性规则,并最终掌握了工业界标准的两阶段锁协议 (2PL)。

关键要点回顾:

  • 锁是基础:S 锁用于读,X 锁用于写,读写操作必须遵循兼容性规则。
  • 协议是保障:简单锁协议不够安全,预声明协议防死锁但并发低,2PL 是目前的主流选择。
  • 实战很重要:合理的索引设计、短事务逻辑以及完善的重试机制,是发挥锁机制最大效能的关键。

掌握了这些,你不仅理解了数据库底层是如何工作的,更重要的是,当你在设计高并发系统时,能够更有信心地处理数据一致性问题。接下来,建议你尝试观察自己生产环境数据库的锁等待情况,看看是否能利用今天学到的知识去优化那些慢查询。

希望这篇文章能帮助你解开并发控制的谜团。如果你在实际项目中遇到了棘手的锁问题,不妨回到这里,重温一下这些基本原则,通常都能找到解决思路。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/52792.html
点赞
0.00 平均评分 (0% 分数) - 0