-
-
[分享]数据库系统分类、数据模型、反规范化技术、并发操作问题、共享锁和排他锁、数据库封锁协议和封锁粒度、死锁预防和解除
-
发表于: 57分钟前 30
-
AI模型:Deepseek
仅供参考
Let's Go!
=======================我是分割线=================================
数据库系统分类
按数据库系统的体系结构,通常可分为集中式、客户/服务器式、并行式和分布式四类。它们的主要区别在于:数据和处理逻辑放在哪些节点上、节点之间如何协作。
1. 集中式数据库系统
特点:数据、DBMS、应用程序都集中在一台主机或服务器上,用户通过终端访问,终端本身不做数据处理。
举例:
- 早期银行主机系统:IBM 大型机上运行 DB2,柜员使用“哑终端”连接主机。
- 单机版 MySQL/SQLite:某小公司在一台电脑上安装 SQLite,所有数据都存在本地文件中,应用程序也在这台电脑上运行。
2. 客户/服务器式数据库系统
特点:数据库服务器负责数据管理、查询、事务和并发控制;客户端负责用户界面和部分业务逻辑,通过网络发送 SQL 请求。常见有两层 C/S 和三层 C/S。
举例:
- 超市收银系统:收银机安装客户端程序,后台服务器运行 SQL Server,收银机连接服务器完成商品查询和结账。
- 学校教务系统:Web 应用服务器连接 MySQL/PostgreSQL 数据库,学生通过浏览器查询成绩。这属于三层客户/服务器结构。
3. 并行式数据库系统
特点:使用多个处理器、多个磁盘或多个节点并行执行查询,以提高处理速度。常见结构有共享内存、共享磁盘、无共享等。
举例:
- Teradata 数据仓库:采用无共享 MPP 结构,多个节点并行扫描、连接和汇总海量数据。
- Oracle RAC:多个数据库实例共享同一存储,并行处理事务和查询。
- Greenplum:把一个大查询拆成多个子任务,在多个 Segment 节点上并行执行,常用于电信、电商的数据分析。
4. 分布式数据库系统
特点:数据分布在多个地理或网络节点上,每个节点通常有一定自治能力,但对外可看作一个逻辑整体,支持全局事务和数据透明访问。
举例:
- Google Spanner:数据分布在全球多个数据中心,支持跨区域事务和强一致性。
- TiDB、CockroachDB、OceanBase:数据自动分片、多副本存储,节点可分布在不同机房或城市。
- 全国连锁银行系统:北京、上海、广州节点分别存储本地客户数据,跨地区转账由全局协调机制完成。
简单对比
| 类型 | 关键特点 | 典型例子 |
|---|---|---|
| 集中式 | 单机集中存储和处理 | 单机 MySQL、SQLite、大型机 DB2 |
| 客户/服务器式 | 客户端 + 数据库服务器 | SQL Server + 收银客户端、Web + MySQL |
| 并行式 | 多节点并行执行查询 | Teradata、Greenplum、Oracle RAC |
| 分布式 | 数据分布、多地自治、全局透明 | Spanner、TiDB、OceanBase、跨区银行系统 |
需要注意:并行式和分布式容易混淆。并行式通常在同一地点、紧耦合、由单一数据库系统统一管理,主要追求性能;分布式则更强调数据在地理上的分布、自治性和容灾能力。现实中很多系统是混合架构,例如分布式数据库内部也会采用并行执行技术。
=======================我是分割线=================================
数据模型
常用的基本数据模型主要有层次模型、网状模型、关系模型、面向对象模型。它们描述数据及其联系的方式不同。下面用“学生—课程—教师”这个常见场景来举例说明。
1. 层次模型
特点:数据组织成树形结构,每个子节点只有一个父节点,适合表达一对多联系。
典型系统:IBM IMS。
例子:
大学组织结构可以表示为:
学校
├── 计算机学院
│ ├── 软件工程系
│ │ ├── 学生张三
│ │ └── 学生李四
│ └── 计算机科学系
│ └── 学生王五
└── 外国语学院
└── 英语系
└── 学生赵六
在学生选课场景中,可以设计成:
学生张三
├── 选课记录:数据库
└── 选课记录:操作系统
但层次模型不擅长直接表达“一个学生选多门课,一门课被多个学生选”的多对多关系,通常需要冗余数据或引入虚拟节点。
2. 网状模型
特点:数据组织成有向图,一个节点可以有多个父节点,能较自然地表达多对多联系。
典型系统:CODASYL DBTG、IDMS。
例子:
学生和课程之间是多对多关系:
学生张三 ——选课——> 数据库
学生张三 ——选课——> 操作系统
学生李四 ——选课——> 数据库
教师王老师 ——授课——> 数据库
教师刘老师 ——授课——> 操作系统
这里“学生”和“课程”通过“选课”联系连接;“教师”和“课程”通过“授课”联系连接。一个学生可以连接多门课程,一门课程也可以连接多个学生。网状模型通过“系”来表达这种图状联系。
3. 关系模型
特点:用二维表表示数据,行是元组,列是属性,通过主键和外键建立联系。
典型系统:MySQL、Oracle、SQL Server、PostgreSQL。
例子:
学生选课系统可以设计为三张表:
学生表 Student
| 学号 | 姓名 | 专业 |
|---|---|---|
| 1001 | 张三 | 软件工程 |
| 1002 | 李四 | 计算机科学 |
课程表 Course
| 课程号 | 课程名 | 教师 |
|---|---|---|
| C01 | 数据库 | 王老师 |
| C02 | 操作系统 | 刘老师 |
选课表 SC
| 学号 | 课程号 | 成绩 |
|---|---|---|
| 1001 | C01 | 90 |
| 1001 | C02 | 85 |
| 1002 | C01 | 88 |
其中,SC 表通过 学号 和 课程号 两个外键,把学生和课程的多对多关系拆成两个一对多关系。关系模型结构简单、查询方便,是目前最主流的数据库模型。
4. 面向对象模型
特点:把数据和对数据的操作封装成对象,支持类、继承、封装、多态和对象标识。适合表达复杂数据类型和复杂联系。
典型系统:ObjectStore、Versant、db4o;对象关系型数据库如 PostgreSQL、Oracle 也支持部分对象特性。
例子:
可以定义类层次:
Person
├── Student
│ ├── 属性:学号、姓名、专业
│ └── 方法:选课()
└── Teacher
├── 属性:工号、姓名、职称
└── 方法:授课()
再定义课程类:
Course
├── 属性:课程号、课程名、学分
└── 方法:添加学生()
一个 Student 对象可以直接包含一个课程对象集合:
Student 张三
- 学号:1001
- 姓名:张三
- 已选课程:数据库、操作系统
在 CAD、多媒体、地理信息系统等场景中,面向对象模型很常见。例如 CAD 中可以定义:
图形
├── 圆
├── 矩形
└── 多边形
每个图形对象有自己的属性和绘制方法。
简单对比
| 模型 | 数据结构 | 联系表达能力 | 典型例子 |
|---|---|---|---|
| 层次模型 | 树 | 一对多 | 学校—学院—系—学生 |
| 网状模型 | 图 | 多对多 | 学生—选课—课程 |
| 关系模型 | 二维表 | 通过主外键表达 | MySQL 中的学生表、课程表、选课表 |
| 面向对象模型 | 对象、类 | 继承、引用、复杂对象 | CAD 图形对象、多媒体对象 |
总体来看,层次模型和网状模型属于早期导航式模型;关系模型因简单、理论成熟而成为主流;面向对象模型更适合复杂数据结构和对象行为封装。现实中很多系统采用关系模型,并结合面向对象思想,如 ORM 框架或对象关系数据库。
=======================我是分割线=================================
反规范化技术
反规范化是在数据库规范化设计的基础上,为了提高查询性能、减少连接或聚合计算,而有意引入冗余、合并或分割表的技术。下面用电商系统常见的“客户—订单—商品”场景举例说明四种常见反规范化技术。
1. 增加冗余列
定义:在表中存储原本可以通过连接其他表得到的列,避免频繁 JOIN。
常见例子:订单表原本只存 customer_id,查询订单列表时还要连接客户表获取客户姓名和电话。为了减少连接,直接在订单表中冗余存储 customer_name、customer_phone。
规范化设计:
Customer(customer_id, name, phone)
Orders(order_id, customer_id, order_date, amount)
查询订单时需连接:
sql
SELECT o.order_id, c.name, c.phone, o.order_date, o.amount
FROM Orders o
JOIN Customer c ON o.customer_id = c.customer_id;
反规范化后:
Orders(order_id, customer_id, customer_name, customer_phone, order_date, amount)
查询时不再需要连接客户表:
sql
SELECT order_id, customer_name, customer_phone, order_date, amount
FROM Orders;
优点:减少 JOIN,提高查询速度。
缺点:客户改名或换电话时,需要同步更新所有历史订单,否则数据不一致。
2. 增加派生列
定义:存储可以由其他列计算得出的列,避免每次查询时进行聚合或复杂计算。
常见例子:订单总金额原本需要根据订单明细中的 quantity price 汇总得到。为了快速查询,在订单表中增加 total_amount 派生列。
规范化设计:
Orders(order_id, customer_id, order_date)
OrderItem(order_id, product_id, quantity, price)
查询订单总金额:
sql
SELECT o.order_id, SUM(oi.quantity oi.price) AS total_amount
FROM Orders o
JOIN OrderItem oi ON o.order_id = oi.order_id
GROUP BY o.order_id;
反规范化后:
Orders(order_id, customer_id, order_date, total_amount)
下单或修改明细时,由程序或触发器更新 total_amount。
优点:查询订单总金额时无需聚合,速度快。
缺点:订单明细变更时必须同步维护 total_amount,否则会出错。
3. 重新组表
定义:将经常一起查询的两个或多个表合并成一张表,减少连接操作。常发生在 1:1 关系或频繁联合查询的场景。
常见例子:用户基本信息和用户扩展信息原本分为两张表,但每次查询用户资料都要连接。可以合并为一张宽表。
规范化设计:
User(user_id, username, password_hash)
UserProfile(user_id, nickname, avatar, bio, email, phone)
查询用户完整信息:
sql
SELECT u.user_id, u.username, p.nickname, p.avatar, p.bio, p.email, p.phone
FROM User u
JOIN UserProfile p ON u.user_id = p.user_id;
反规范化后:
User(user_id, username, password_hash, nickname, avatar, bio, email, phone)
查询时只需一张表:
sql
SELECT user_id, username, nickname, avatar, bio, email, phone
FROM User;
优点:减少连接,查询简单。
缺点:表变宽,可能出现大量 NULL;若原本是 1:N 关系,合并会导致大量重复数据。
4. 分割表
定义:将一张大表按列或按行拆分成多张较小的表。按列拆叫垂直分割,按行拆叫水平分割。分割表主要是为了减少 I/O、提高查询和管理效率,常与反规范化一起讨论。
4.1 垂直分割
常见例子:商品表中,description、image_url 等大字段不常查询,而商品列表只需要 product_id、product_name、price。可以把商品表拆成基本表和详情表。
原表:
Product(product_id, product_name, category_id, price, description, image_url)
垂直分割后:
ProductBase(product_id, product_name, category_id, price)
ProductDetail(product_id, description, image_url)
查询商品列表时只访问 ProductBase,速度快。
4.2 水平分割
常见例子:订单表数据量巨大,按年份拆分成多个表。
原表:
Orders(order_id, customer_id, order_date, total_amount)
水平分割后:
Orders_2023(order_id, customer_id, order_date, total_amount)
Orders_2024(order_id, customer_id, order_date, total_amount)
Orders_2025(order_id, customer_id, order_date, total_amount)
查询 2025 年订单时只扫描 Orders_2025,减少 I/O。现代数据库通常用分区表实现水平分割。
优点:单表变小,查询和维护更快。
缺点:跨分割查询复杂,需要应用层或数据库分区功能支持。
总结
| 反规范化技术 | 常见例子 | 主要目的 |
|---|---|---|
| 增加冗余列 | 订单表增加客户姓名、电话 | 减少 JOIN |
| 增加派生列 | 订单表增加总金额、商品表增加库存总值 | 避免聚合计算 |
| 重新组表 | 用户表和用户资料表合并 | 减少连接,简化查询 |
| 分割表 | 商品表垂直拆分;订单表按年份水平拆分 | 减少 I/O,提高查询效率 |
反规范化的核心是用空间换时间,但会带来数据一致性维护成本。实际项目中通常先规范化设计,再根据查询性能瓶颈有针对性地反规范化。
=======================我是分割线=================================
并发操作问题
下面用“银行账户余额”和“商品库存”这两个常见场景,说明三种并发操作问题。
1. 丢失更新
定义:两个事务同时读取同一数据,各自基于读到的值进行修改,后提交的事务覆盖了先提交的事务,导致先前的更新丢失。
常见例子:商品库存扣减
初始库存 quantity = 10。
| 时刻 | 事务 T1 | 事务 T2 | 说明 |
|---|---|---|---|
| 1 | BEGIN; SELECT quantity = 10 | T1 读到库存 10 | |
| 2 | BEGIN; SELECT quantity = 10 | T2 也读到库存 10 | |
| 3 | UPDATE quantity = 10 - 1 = 9; COMMIT | T1 卖出 1 件,库存改为 9 | |
| 4 | UPDATE quantity = 10 - 2 = 8; COMMIT | T2 卖出 2 件,基于旧值 10 改为 8 |
最终库存是 8,但正确结果应该是:
10 - 1 - 2 = 7
T1 的更新被 T2 覆盖了,这就是丢失更新。
常见解决思路:
悲观锁:SELECT ... FOR UPDATE
乐观锁:增加版本号,更新时检查版本
原子更新:UPDATE product SET quantity = quantity 1 WHERE id = 1
2. 不可重复读
定义:同一个事务内,两次读取同一行数据,由于另一个事务修改并提交了该数据,导致两次读取结果不一致。
常见例子:同一事务内两次查询账户余额
初始余额 balance = 100。
| 时刻 | 事务 T1 | 事务 T2 | 说明 |
|---|---|---|---|
| 1 | BEGIN; SELECT balance = 100 | T1 第一次读,余额 100 | |
| 2 | BEGIN; UPDATE balance = 200; COMMIT | T2 将余额改为 200 并提交 | |
| 3 | SELECT balance = 200 | T1 第二次读,余额变成 200 |
T1 在同一个事务中,第一次读到 100,第二次读到 200,前后不一致,这就是不可重复读。
常见解决思路:
将隔离级别提高到“可重复读”
对读取的行加共享锁,防止其他事务修改
3. 读脏数据
定义:一个事务读取了另一个事务尚未提交的修改。如果那个事务后来回滚,那么读到的数据就是无效的“脏数据”。
常见例子:转账中间状态被读到
初始余额 balance = 100。
| 时刻 | 事务 T1 | 事务 T2 | 说明 |
|---|---|---|---|
| 1 | BEGIN; UPDATE balance = 200; | T1 把余额改为 200,但未提交 | |
| 2 | BEGIN; SELECT balance = 200; | T2 读到了 T1 未提交的值 200 | |
| 3 | ROLLBACK; | T1 回滚,余额恢复为 100 | |
| 4 | T2 之前读到的 200 是脏数据 |
T2 读到的 200 实际上从未真正生效,这就是读脏数据,也叫脏读。
常见解决思路:
将隔离级别提高到“读已提交”或更高
避免读取其他事务未提交的数据
总结对比
| 并发问题 | 核心原因 | 常见例子 | 后果 |
|---|---|---|---|
| 丢失更新 | 两个事务都写,后写覆盖先写 | 库存扣减、计数器累加 | 一次更新被覆盖 |
| 不可重复读 | 同一事务内两次读,期间数据被提交修改 | 同一事务内两次查余额 | 前后读取结果不一致 |
| 读脏数据 | 读到其他事务未提交的数据 | 读到转账中间状态,对方又回滚 | 读到无效数据 |
简单记忆:
丢失更新:两个人同时改,一个把另一个盖掉了。
不可重复读:同一个人两次读,结果不一样。
读脏数据:读到了别人还没确认、最后又反悔的数据。
=======================我是分割线=================================
共享锁和排他锁
共享锁(S锁)和排它锁(X锁)是数据库中最基本的两种封锁类型,主要用来控制并发事务对同一数据的读写。
共享锁:又叫读锁。多个事务可以同时读同一数据,但都不能修改。
排它锁:又叫写锁。一个事务加了排它锁后,其他事务既不能读也不能写,必须等待。
它们的兼容关系如下:
| 当前锁 \ 请求锁 | 共享锁 S | 排它锁 X |
|---|---|---|
| 共享锁 S | 兼容 | 不兼容 |
| 排它锁 X | 不兼容 | 不兼容 |
简单说:共享锁之间可以共存,排它锁和任何锁都不能共存。
1. 图书馆阅览的例子
把数据库中的一行数据想象成图书馆里的一本书。
共享锁:多个读者可以同时坐在阅览室里看同一本书。大家只是读,不会改动内容,所以可以共存。
排它锁:如果有一个读者要把这本书借走,或者要在书上做笔记、修改内容,他就需要独占这本书。此时其他读者不能再借,也不能再读,必须等他处理完。
对应到数据库:
事务 A 对某行加共享锁,事务 B 也可以加共享锁,两个事务都能读。
事务 C 想修改这行,需要加排它锁,但必须等 A、B 释放共享锁。
如果事务 C 先加了排它锁,事务 A、B 想加共享锁也会被阻塞。
2. 银行账户查询与修改
假设账户表 account 中有一行:
| id | balance |
|---|---|
| 1 | 1000 |
场景一:两个事务同时查询余额
事务 A:
BEGIN;
SELECT FROM account WHERE id = 1 LOCK IN SHARE MODE;
事务 B:
BEGIN;
SELECT FROM account WHERE id = 1 LOCK IN SHARE MODE;
这两个查询都加共享锁,可以同时执行,都能读到余额 1000。因为共享锁之间兼容。
场景二:一个事务要修改余额
事务 A 先执行:
BEGIN;
SELECT FROM account WHERE id = 1 FOR UPDATE;
这里 FOR UPDATE 加了排它锁。此时事务 B 如果执行:
SELECT FROM account WHERE id = 1 LOCK IN SHARE MODE;
就会被阻塞,直到事务 A 提交或回滚。
同样,如果事务 B 执行:
UPDATE account SET balance = balance 100 WHERE id = 1;
也会被阻塞,因为 UPDATE 需要排它锁,而排它锁与排它锁不兼容。
3. 商品库存扣减
商品表 product:
| id | name | stock |
|---|---|---|
| 1 | 手机 | 10 |
事务 A 要扣减库存:
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
UPDATE product SET stock = stock 1 WHERE id = 1;
COMMIT;
事务 A 对 id=1 的商品行加了排它锁。此时事务 B 如果也想扣减库存:
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
事务 B 会等待,直到事务 A 提交。这样可以避免两个事务同时读到 stock=10,然后都减 1,最终只减了 1 的丢失更新问题。
如果事务 B 只是普通查询,不加锁:
SELECT stock FROM product WHERE id = 1;
在 READ COMMITTED 等隔离级别下,普通 SELECT 通常不加锁,可能读到快照数据,不会阻塞。但如果事务 B 也加共享锁或排它锁,就会受到锁兼容性的限制。
4. SQL 中常见加锁方式
| 操作 | 加锁类型 | 说明 |
|---|---|---|
SELECT ... LOCK IN SHARE MODE 或 SELECT ... FOR SHARE | 共享锁 | 读数据,并阻止其他事务修改 |
SELECT ... FOR UPDATE | 排它锁 | 读数据,并准备修改,独占该行 |
UPDATE、DELETE、INSERT | 排它锁 | 写操作自动加排它锁 |
普通 SELECT | 通常不加锁 | 取决于隔离级别,可能读快照 |
总结
| 锁类型 | 别名 | 允许其他事务读 | 允许其他事务写 | 常见例子 |
|---|---|---|---|---|
| 共享锁 S | 读锁 | 可以加共享锁读 | 不可以 | 多人同时查看同一账户余额 |
| 排它锁 X | 写锁 | 不可以 | 不可以 | 修改余额、扣减库存、转账 |
一句话记忆:
共享锁:大家都能看,但谁也别改。
排它锁:我要改,谁都别动,读也不行。
=======================我是分割线=================================
数据库封锁协议和封锁粒度
数据库中的封锁协议规定“什么时候加锁、什么时候释放锁”,用来解决并发事务之间的干扰;封锁粒度规定“锁住多大的数据范围”,用来在并发度和开销之间做权衡。下面分别用常见例子说明。
一、封锁协议
最常见的封锁协议是三级封锁协议和两阶段封锁协议。以银行转账为例:
账户表:
| 账户 | 余额 |
|---|---|
| A | 1000 |
| B | 1000 |
事务 T1:从 A 转 100 给 B。
事务 T2:从 A 转 200 给 C。
1. 一级封锁协议
规则:事务修改数据前必须先加排它锁 X,直到事务结束才释放。读数据不加锁。
例子:
T1 要修改 A,先对 A 加 X 锁。
T2 也要修改 A,必须等 T1 释放 X 锁后才能修改。
因此不会出现两个事务都读到 A=1000,然后分别改成 900 和 800,最后只减了 100 的丢失更新问题。
能解决:丢失更新。
不能解决:读脏数据、不可重复读。
例如:T1 修改 B=1100 但未提交,T2 读 B 得到 1100;随后 T1 回滚,T2 读到的就是脏数据。
2. 二级封锁协议
规则:在一级基础上,事务读数据前必须加共享锁 S,读完即可释放。
例子:
T1 修改 B 并加了 X 锁,尚未提交。
T2 想读 B,必须先加 S 锁。但 S 锁与 X 锁不兼容,所以 T2 会等待。
直到 T1 提交或回滚后,T2 才能读 B。
这样 T2 不会读到 T1 未提交的 B 值,避免了脏读。
能解决:丢失更新、读脏数据。
不能解决:不可重复读。
例如:T2 第一次读 A=1000,加 S 锁读完后释放;T1 接着修改 A=900 并提交;T2 再读 A 时变成 900,前后不一致。
3. 三级封锁协议
规则:在一级基础上,事务读数据前必须加 S 锁,并且直到事务结束才释放。
例子:
T2 第一次读 A 时加 S 锁,并且一直持有到事务结束。
T1 想修改 A,必须加 X 锁,但 X 锁与 S 锁不兼容,所以 T1 必须等 T2 结束。
因此 T2 在整个事务中两次读 A 都是 1000,不会出现不可重复读。
能解决:丢失更新、读脏数据、不可重复读。
缺点:锁持有时间长,并发度降低。
4. 两阶段封锁协议(2PL)
规则:事务分为两个阶段:
增长阶段:只能加锁,不能解锁。
缩减阶段:只能解锁,不能加锁。
例子:
T1 正确顺序:
加锁 A → 加锁 B → 修改 A → 修改 B → 解锁 A → 解锁 B
T1 错误顺序:
加锁 A → 加锁 B → 解锁 A → 加锁 C // 不允许,解锁后不能再加锁
两阶段封锁协议可以保证事务的可串行化调度,但可能产生死锁。严格两阶段封锁还要求所有锁都在事务结束时才释放。
封锁协议小结
| 协议 | 加锁规则 | 能防止 | 常见问题 |
|---|---|---|---|
| 一级封锁 | 修改前加 X 锁,事务结束释放 | 丢失更新 | 脏读、不可重复读 |
| 二级封锁 | 一级 + 读前加 S 锁,读完释放 | 丢失更新、脏读 | 不可重复读 |
| 三级封锁 | 一级 + 读前加 S 锁,事务结束释放 | 丢失更新、脏读、不可重复读 | 并发度低 |
| 两阶段封锁 | 增长阶段只加锁,缩减阶段只解锁 | 保证可串行化 | 可能死锁 |
二、封锁粒度
封锁粒度指封锁对象的大小,常见有行级、页级、表级、数据库级,以及多粒度封锁。
以商品表 product 为例:
| id | name | stock | price |
|---|---|---|---|
| 1 | 手机 | 10 | 5000 |
| 2 | 电脑 | 5 | 8000 |
| 3 | 耳机 | 20 | 500 |
1. 行级锁
例子:
事务 T1:
UPDATE product SET stock = stock 1 WHERE id = 1;
只锁住 id=1 这一行。
事务 T2:
UPDATE product SET stock = stock 1 WHERE id = 2;
可以同时执行,因为锁的是不同行。
优点:并发度高。
缺点:锁数量多,管理开销大。
2. 页级锁
例子:
假设数据库一页存放 id=1~100 的商品。T1 更新 id=1,锁住整页。
T2 想更新 id=2,虽然和 T1 不是同一行,但在同一页,也必须等待。
优点:开销比行级锁小。
缺点:并发度比行级锁低,可能出现“锁不相关数据”的情况。
3. 表级锁
例子:
事务 T1 要批量调价:
UPDATE product SET price = price 0.9;
对整张 product 表加排它锁。
事务 T2 想查询商品:
SELECT FROM product WHERE id = 1;
必须等待 T1 完成。
优点:开销小,实现简单。
缺点:并发度低。
4. 数据库级锁
例子:
数据库管理员做全库备份时,对整个数据库加锁。
此时其他用户不能对任何表进行读写,直到备份完成。
优点:保证备份一致性。
缺点:并发度极低,通常只在特殊维护时使用。
5. 多粒度封锁与意向锁
多粒度封锁允许不同事务在不同粒度上加锁。为了协调表锁和行锁,引入了意向锁。
例子:
事务 T1 要修改 product 表中 id=1 的行:
1. 先对 product 表加意向排它锁 IX,表示“我打算在表内某些行上加 X 锁”。
2. 再对 id=1 行加排它锁 X。
事务 T2 要读整张 product 表:
1. 对 product 表加共享锁 S。
2. 发现表上已有 IX 锁,而 S 与 IX 不兼容,所以 T2 等待。
这样数据库不用逐行检查,就能快速判断表级锁和行级锁是否冲突。
封锁粒度小结
| 封锁粒度 | 例子 | 并发度 | 开销 |
|---|---|---|---|
| 行级锁 | 扣减某个商品库存 | 高 | 大 |
| 页级锁 | 锁住包含多行的一页 | 中 | 中 |
| 表级锁 | 批量调价、整表统计 | 低 | 小 |
| 数据库级锁 | 全库备份 | 极低 | 很小 |
| 多粒度封锁 | 先表级意向锁,再行级锁 | 灵活 | 较复杂 |
总结
封锁协议解决的是“加锁和解锁的时机”问题:
一级防丢失更新;
二级防脏读;
三级防不可重复读;
两阶段封锁保证可串行化。
封锁粒度解决的是“锁多大范围”的问题:
粒度越细,并发越高,但开销越大;
粒度越粗,开销越小,但并发越低;
多粒度封锁通过意向锁协调不同粒度的锁。
实际数据库如 MySQL InnoDB 默认常用行级锁,并配合多粒度锁和两阶段封锁来兼顾并发与一致性。
=======================我是分割线=================================
死锁预防和解除
死锁是指两个或多个事务互相持有对方需要的锁,并等待对方释放,形成循环等待,导致谁也无法继续执行。
下面用“银行账户转账”这个常见例子说明。
账户表:
| 账户 | id | 余额 |
|---|---|---|
| A | 1 | 1000 |
| B | 2 | 1000 |
事务 T1:从 A 转 100 给 B,需要锁 A 和 B。
事务 T2:从 B 转 200 给 A,需要锁 B 和 A。
不加控制时可能发生:
| 时刻 | T1 | T2 |
|---|---|---|
| 1 | 对 A 加排它锁 | |
| 2 | 对 B 加排它锁 | |
| 3 | 请求 B 的锁,等待 T2 | |
| 4 | 请求 A 的锁,等待 T1 |
此时 T1 等 T2,T2 等 T1,形成循环等待,这就是死锁。
一、死锁的预防法
1. 顺序申请法
做法:规定所有事务必须按同一个固定顺序申请锁。例如按账户 id 从小到大加锁。
例子:
规定:所有转账事务必须先锁 id 小的账户,再锁 id 大的账户。
A 的 id = 1,B 的 id = 2。
T1 转账 A→B:先锁 A,再锁 B。
T2 转账 B→A:也必须先锁 A,再锁 B。
执行过程:
| 时刻 | T1 | T2 |
|---|---|---|
| 1 | 锁住 A | |
| 2 | 请求锁 A,但 A 已被 T1 锁住,等待 | |
| 3 | 再锁 B,执行转账 | |
| 4 | 提交,释放 A、B | |
| 5 | 获得 A,再获得 B,执行转账 |
T2 在等待 A 时并没有持有 B,所以不会出现“T1 持有 A 等 B,T2 持有 B 等 A”的循环等待。这样就从根源上预防了死锁。
本质:破坏死锁的“循环等待”条件。
2. 一次申请法
做法:事务开始前,一次性申请它所需要的全部锁。如果有一个锁拿不到,就整体等待,不持有任何锁。
例子:
T1 和 T2 都需要锁 A 和 B。
T1 开始时,一次性申请 A 和 B。
T2 开始时,也一次性申请 A 和 B。
系统要么把 A、B 都分配给 T1,要么都不分配。假设 T1 先申请成功:
| 时刻 | T1 | T2 |
|---|---|---|
| 1 | 一次性申请 A、B,成功 | |
| 2 | 执行转账 | 一次性申请 A、B,失败,整体等待 |
| 3 | 提交,释放 A、B | |
| 4 | 获得 A、B,执行转账 |
T2 在拿不到全部锁时,不会先持有 A 再等 B,也不会先持有 B 再等 A,因此不会形成“持有并等待”的局面。
本质:破坏死锁的“请求与保持”条件。
缺点:事务开始前可能不知道需要哪些锁;资源利用率低,并发度下降。
二、死锁的解除法
死锁预防有时代价较高,实际数据库系统常用“检测 + 解除”的方式。
1. 死锁检测程序
做法:维护一张等待图。节点表示事务,边表示等待关系。如果图中出现环,就说明发生了死锁。
例子:
上面的转账死锁中:
T1 等待 T2 释放 B,所以有边 T1 → T2。
T2 等待 T1 释放 A,所以有边 T2 → T1。
等待图:
T1 → T2
↑ ↓
└────┘
形成环:
T1 → T2 → T1
死锁检测程序发现这个环后,就判定 T1 和 T2 发生死锁。
实际数据库中,检测程序可能周期性运行,也可能在事务等待超过一定时间后触发。例如 MySQL InnoDB 就具有死锁检测机制。
2. 解锁程序
做法:检测到死锁后,选择一个“牺牲者”事务,把它回滚,释放它持有的锁,从而打破循环等待,让其他事务继续执行。
例子:
等待图发现 T1 和 T2 死锁。解锁程序选择回滚 T2,因为 T2 执行时间短、持有锁少、回滚代价小。
执行过程:
| 时刻 | 操作 |
|---|---|
| 1 | 检测到 T1、T2 死锁 |
| 2 | 选择 T2 作为牺牲者 |
| 3 | 回滚 T2,释放 T2 持有的 B 锁,撤销 T2 的修改 |
| 4 | T1 获得 B 锁,继续完成转账 |
| 5 | T2 稍后重新执行 |
选择牺牲者时通常考虑:
事务已执行的时间;
事务持有锁的数量;
回滚代价大小;
事务优先级;
是否已经修改过数据。
回滚后,解锁程序还要负责恢复数据一致性,并释放相关锁。
总结
| 类别 | 方法 | 核心思想 | 例子 |
|---|---|---|---|
| 死锁预防 | 顺序申请法 | 所有事务按同一顺序申请锁,破坏循环等待 | 转账都先锁 id 小的账户,再锁 id 大的账户 |
| 死锁预防 | 一次申请法 | 事务开始前一次性申请全部锁,破坏请求与保持 | T1、T2 都一次性申请 A、B 两把锁,拿不到就整体等待 |
| 死锁解除 | 死锁检测程序 | 用等待图检测是否存在环 | T1 等 T2,T2 等 T1,等待图成环 |
| 死锁解除 | 解锁程序 | 选牺牲者回滚,释放锁,打破循环 | 回滚 T2,释放 B,让 T1 继续执行 |
简单记忆:
顺序申请法:大家都按同一个顺序拿锁,避免互相卡住。
一次申请法:要么全拿,要么不拿,不半路占着等。
死锁检测程序:画等待图,找环。
解锁程序:挑一个事务回滚,把锁放掉,打破死锁。