MySQL事务与并发之:乐观锁与悲观锁的实现
大家好,今天我们来深入探讨MySQL事务并发控制中两种重要的锁机制:乐观锁和悲观锁。我们将从理论基础出发,结合实际的MySQL代码示例,详细讲解它们的原理、实现方式以及适用场景。
1. 并发控制的必要性
在多用户并发访问数据库时,如果没有适当的并发控制机制,很容易出现以下问题:
- 丢失更新(Lost Update): 多个事务同时读取同一数据并修改,后提交的事务覆盖了先提交的事务的修改。
- 脏读(Dirty Read): 一个事务读取了另一个未提交事务修改的数据。如果未提交的事务随后回滚,那么读取的数据就是无效的。
- 不可重复读(Non-Repeatable Read): 在同一事务中,多次读取同一数据,由于其他事务的修改和提交,导致每次读取的结果不一致。
- 幻读(Phantom Read): 在同一事务中,多次执行同一查询,由于其他事务的插入或删除操作,导致每次查询的结果集不一致。
为了解决这些问题,我们需要采用并发控制机制,而锁机制是并发控制的核心手段之一。
2. 锁机制概述
锁是一种同步机制,用于控制对共享资源的访问。当一个事务持有锁时,其他事务可能需要等待才能访问该资源。根据锁的粒度和锁定策略,锁可以分为多种类型。
3. 悲观锁(Pessimistic Locking)
3.1 概念
悲观锁认为在并发环境下,数据被其他事务修改的概率很高,因此在读取数据时就立即加锁,防止其他事务修改该数据。只有当持有锁的事务完成并释放锁后,其他事务才能访问该数据。
3.2 实现方式
在MySQL中,悲观锁主要通过SELECT ... FOR UPDATE
语句实现。
3.3 示例
假设我们有一个products
表,包含以下字段:
id
:商品ID(主键)name
:商品名称stock
:库存数量
CREATE TABLE products (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
stock INT NOT NULL
);
INSERT INTO products (name, stock) VALUES ('Product A', 10);
现在,我们希望减少商品Product A
的库存。使用悲观锁的代码如下:
-- 开启事务
START TRANSACTION;
-- 使用 SELECT ... FOR UPDATE 语句获取锁
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- 检查库存是否足够
SET @stock = (SELECT stock FROM products WHERE id = 1);
IF @stock > 0 THEN
-- 更新库存
UPDATE products SET stock = stock - 1 WHERE id = 1;
SELECT '库存更新成功' AS result;
ELSE
SELECT '库存不足' AS result;
END IF;
-- 提交事务
COMMIT;
-- 如果出现错误,可以回滚事务
-- ROLLBACK;
解释:
START TRANSACTION;
:开启一个新的事务。SELECT stock FROM products WHERE id = 1 FOR UPDATE;
:这条语句是悲观锁的关键。它会锁定products
表中id
为1的记录。其他事务如果尝试使用SELECT ... FOR UPDATE
语句访问同一记录,将会被阻塞,直到当前事务完成并释放锁。SET @stock = (SELECT stock FROM products WHERE id = 1);
:获取当前库存数量。IF @stock > 0 THEN ... END IF;
:判断库存是否足够,如果足够则更新库存。UPDATE products SET stock = stock - 1 WHERE id = 1;
:更新库存数量。COMMIT;
:提交事务,释放锁。
3.4 优点
- 保证数据的一致性:由于在读取数据时就加锁,可以避免其他事务修改数据,从而保证数据的一致性。
- 实现简单:
SELECT ... FOR UPDATE
语句使用简单,易于理解和实现。
3.5 缺点
- 并发性能较低:由于在读取数据时就加锁,会导致其他事务阻塞,降低并发性能。
- 可能导致死锁:如果多个事务互相等待对方释放锁,可能会导致死锁。
3.6 死锁示例
假设有两个事务,分别执行以下操作:
事务 1:
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
SELECT stock FROM products WHERE id = 2 FOR UPDATE; -- 等待事务 2 释放锁
事务 2:
START TRANSACTION;
SELECT stock FROM products WHERE id = 2 FOR UPDATE;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 等待事务 1 释放锁
在这种情况下,事务 1 正在等待事务 2 释放 id = 2
的锁,而事务 2 正在等待事务 1 释放 id = 1
的锁,从而导致死锁。
3.7 如何避免死锁
- 避免循环依赖: 保证事务以相同的顺序访问资源。在上面的例子中,可以确保所有事务都先访问
id = 1
的记录,再访问id = 2
的记录。 - 设置锁超时时间: 当事务等待锁的时间超过设定的超时时间时,自动放弃锁,避免长时间阻塞。可以通过
innodb_lock_wait_timeout
参数设置。 - 使用更细粒度的锁: 尽量减少锁的范围,避免锁住不必要的资源。
- 尽量缩短事务的持有锁时间: 尽快完成事务,释放锁,减少其他事务的等待时间。
4. 乐观锁(Optimistic Locking)
4.1 概念
乐观锁认为在并发环境下,数据被其他事务修改的概率很低,因此在读取数据时不会加锁。而是在更新数据时,检查数据是否被其他事务修改过。如果数据没有被修改过,则更新数据;否则,更新失败。
4.2 实现方式
乐观锁通常通过版本号或时间戳来实现。
4.2.1 基于版本号的乐观锁
在表中增加一个版本号字段,每次更新数据时,版本号加1。在更新数据时,检查当前版本号是否与读取时的版本号一致。
4.2.2 基于时间戳的乐观锁
在表中增加一个时间戳字段,每次更新数据时,更新时间戳。在更新数据时,检查当前时间戳是否与读取时的时间戳一致。
4.3 示例
假设我们仍然使用products
表,并增加一个version
字段:
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 0;
基于版本号的乐观锁的代码如下:
-- 开启事务
START TRANSACTION;
-- 读取数据和版本号
SELECT stock, version FROM products WHERE id = 1;
-- 检查库存是否足够
SET @stock = (SELECT stock FROM products WHERE id = 1);
SET @version = (SELECT version FROM products WHERE id = 1);
IF @stock > 0 THEN
-- 更新库存和版本号
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = @version;
-- 检查更新是否成功
IF ROW_COUNT() > 0 THEN
SELECT '库存更新成功' AS result;
ELSE
SELECT '库存更新失败,数据已被修改' AS result;
-- 回滚事务
ROLLBACK;
END IF;
ELSE
SELECT '库存不足' AS result;
END IF;
-- 提交事务
COMMIT;
-- 如果出现错误,可以回滚事务
-- ROLLBACK;
解释:
START TRANSACTION;
:开启一个新的事务。SELECT stock, version FROM products WHERE id = 1;
:读取products
表中id
为1的记录的stock
和version
。SET @stock = (SELECT stock FROM products WHERE id = 1);
和SET @version = (SELECT version FROM products WHERE id = 1);
:获取库存和版本号。IF @stock > 0 THEN ... END IF;
:判断库存是否足够,如果足够则尝试更新库存。UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1 AND version = @version;
:更新库存和版本号。关键在于WHERE version = @version
条件。 只有当当前版本号与读取时的版本号一致时,才能成功更新数据。IF ROW_COUNT() > 0 THEN ... END IF;
:检查更新是否成功。ROW_COUNT()
函数返回受影响的行数。如果更新成功,ROW_COUNT()
的值为1;否则,值为0,表示数据已被其他事务修改。COMMIT;
:提交事务。如果更新失败,需要回滚事务,并通知用户稍后重试。
4.4 优点
- 并发性能较高:由于在读取数据时不需要加锁,可以提高并发性能。
- 避免死锁:由于不需要加锁,可以避免死锁。
4.5 缺点
- 可能存在更新失败:如果多个事务同时修改同一数据,只有一个事务能够成功更新,其他事务需要重试。
- 实现复杂:需要额外的版本号或时间戳字段,并且需要在更新数据时进行版本号或时间戳的检查。
- 可能造成大量重试:在高并发环境下,可能存在大量的更新失败和重试,影响性能。
4.6 适用场景
乐观锁适用于读多写少的场景,即并发更新的概率较低的场景。
5. 乐观锁与悲观锁的比较
特性 | 悲观锁 | 乐观锁 |
---|---|---|
加锁时机 | 在读取数据时加锁 | 在更新数据时检查是否被修改 |
并发性能 | 较低 | 较高 |
死锁 | 可能导致死锁 | 不会导致死锁 |
实现复杂度 | 简单 | 复杂 |
适用场景 | 写多读少的场景,并发冲突概率较高的场景 | 读多写少的场景,并发冲突概率较低的场景 |
一致性 | 强制保证数据一致性,更新前必须获得锁 | 不强制保证数据一致性,依赖于版本号或时间戳机制,如果数据被修改,则更新失败,需要重试。 |
6. 如何选择乐观锁和悲观锁
- 并发冲突概率: 如果并发冲突的概率很高,建议使用悲观锁,以保证数据的一致性。如果并发冲突的概率很低,建议使用乐观锁,以提高并发性能。
- 性能要求: 如果对性能要求较高,建议使用乐观锁,因为乐观锁不需要加锁,可以提高并发性能。
- 业务场景: 根据具体的业务场景选择合适的锁机制。例如,在金融交易等对数据一致性要求极高的场景,建议使用悲观锁。在秒杀等高并发场景,可以考虑使用乐观锁,并结合其他优化手段,例如限流、缓存等。
7. 总结:选择合适的锁机制
乐观锁和悲观锁是MySQL事务并发控制中两种重要的锁机制。悲观锁通过在读取数据时加锁来保证数据的一致性,适用于写多读少的场景。乐观锁通过在更新数据时检查版本号或时间戳来保证数据的一致性,适用于读多写少的场景。选择合适的锁机制需要根据具体的业务场景和性能要求进行权衡。
8. 扩展:结合其他并发控制手段
除了乐观锁和悲观锁,还可以结合其他并发控制手段来提高并发性能和保证数据一致性,例如:
- MVCC(多版本并发控制): MySQL InnoDB存储引擎默认使用MVCC机制,通过维护多个版本的数据,可以实现非阻塞的读操作,提高并发性能。
- 行级锁: 尽量使用行级锁,减少锁的范围,避免锁住不必要的资源。
- 合理的事务隔离级别: 选择合适的事务隔离级别,可以根据业务需求在数据一致性和并发性能之间进行权衡。
希望今天的分享能帮助大家更好地理解和应用MySQL的锁机制。谢谢大家。