MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

MySQL锁与并发控制策略

2023-01-167.3k 阅读

MySQL 锁概述

在多用户并发访问的数据库环境中,锁是一种至关重要的机制,用于保证数据的一致性和完整性。MySQL 作为广泛使用的关系型数据库管理系统,提供了多种类型的锁来满足不同场景下的并发控制需求。

MySQL 的锁可以从不同维度进行分类。从锁的粒度来看,可分为表级锁、行级锁和页级锁;从锁的应用方式来看,有共享锁(读锁)和排他锁(写锁)。

表级锁

表级锁是 MySQL 中锁定粒度最大的一种锁机制。当对一个表施加表级锁时,整个表都会被锁定。这意味着在锁释放之前,其他事务无法对该表进行任何操作(根据锁的类型限制读或写操作)。

表级锁的优点是加锁和解锁的速度非常快,因为它只需要锁定一个对象(表)。缺点也很明显,由于锁定粒度大,并发性能较差,在高并发场景下,很容易出现锁争用的情况。

在 MySQL 中,可以使用 LOCK TABLES 语句来显式地获取表级锁。例如:

-- 获取表 t1 的读锁
LOCK TABLES t1 READ;
-- 进行一些读操作
SELECT * FROM t1;
-- 释放锁
UNLOCK TABLES;

-- 获取表 t2 的写锁
LOCK TABLES t2 WRITE;
-- 进行一些写操作
INSERT INTO t2 (column1, column2) VALUES ('value1', 'value2');
-- 释放锁
UNLOCK TABLES;

上述代码中,LOCK TABLES t1 READ 语句获取了表 t1 的读锁,此时其他事务可以并发地读取 t1,但不能写入。LOCK TABLES t2 WRITE 获取了表 t2 的写锁,其他事务既不能读也不能写 t2,直到 UNLOCK TABLES 释放锁。

行级锁

行级锁是 MySQL 中锁定粒度最小的一种锁机制,它只锁定需要操作的行。这使得在高并发场景下,多个事务可以同时对不同的行进行操作,大大提高了并发性能。

行级锁的优点是并发性能好,缺点是加锁和解锁的开销相对较大,因为需要精确地定位到具体的行。

MySQL 的 InnoDB 存储引擎默认支持行级锁。例如,在一个简单的更新操作中:

START TRANSACTION;
UPDATE users SET age = age + 1 WHERE user_id = 1;
COMMIT;

在上述代码中,当执行 UPDATE 语句时,InnoDB 会自动对 user_id1 的这一行数据加上排他锁,以确保在事务提交之前,其他事务不能对这一行进行修改。

页级锁

页级锁是介于表级锁和行级锁之间的一种锁机制,它锁定的粒度是一页数据(通常是 16KB)。页级锁的并发性能介于表级锁和行级锁之间,加锁和解锁的开销也适中。

在 MySQL 的 BDB 存储引擎中支持页级锁,但由于 InnoDB 存储引擎在大多数场景下表现更优,BDB 存储引擎使用相对较少,页级锁的应用场景也逐渐减少。

共享锁与排他锁

共享锁(Shared Lock,简称 S 锁)和排他锁(Exclusive Lock,简称 X 锁)是 MySQL 中两种基本的锁类型,它们在并发控制中起着关键作用。

共享锁(读锁)

共享锁允许多个事务同时读取同一数据,因为读操作不会修改数据,所以多个读操作之间不会产生数据一致性问题。当一个事务对数据加上共享锁后,其他事务可以继续对该数据加共享锁,但不能加排他锁。

在 MySQL 中,可以使用 SELECT ... LOCK IN SHARE MODE 语句来获取共享锁。例如:

START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1 LOCK IN SHARE MODE;
-- 这里可以进行一些基于读取数据的操作,比如计算余额等
COMMIT;

上述代码中,SELECT ... LOCK IN SHARE MODE 语句对 accounts 表中 account_id1 的记录加上了共享锁。在事务提交之前,其他事务可以读取这条记录,但不能修改它。

排他锁(写锁)

排他锁用于保护数据的修改操作,当一个事务对数据加上排他锁后,其他事务既不能对该数据加共享锁,也不能加排他锁,直到该排他锁被释放。这保证了在任何时刻,只有一个事务可以修改数据,从而避免了数据冲突。

在 MySQL 中,可以使用 SELECT ... FOR UPDATE 语句来获取排他锁。例如:

START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1 FOR UPDATE;
-- 假设这里进行一些修改余额的操作
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
COMMIT;

上述代码中,SELECT ... FOR UPDATE 语句对 accounts 表中 account_id1 的记录加上了排他锁。在事务提交之前,其他事务不能读取或修改这条记录,确保了对余额修改操作的原子性和一致性。

锁的争用与死锁

在多事务并发执行的环境中,锁的争用和死锁是常见的问题,需要深入理解并采取有效的策略来避免。

锁争用

锁争用是指多个事务同时请求同一资源的锁,由于锁的排他性,只能有一个事务获得锁,其他事务需要等待,这就导致了锁争用的发生。锁争用会降低系统的并发性能,严重时甚至会导致系统性能瓶颈。

例如,假设有两个事务 T1T2,它们都要对同一张表 orders 中的某一行数据进行修改:

-- 事务 T1
START TRANSACTION;
SELECT * FROM orders WHERE order_id = 1 FOR UPDATE;
-- 假设这里有一些复杂的业务逻辑处理,导致事务执行时间较长
UPDATE orders SET status = 'processed' WHERE order_id = 1;
COMMIT;

-- 事务 T2
START TRANSACTION;
SELECT * FROM orders WHERE order_id = 1 FOR UPDATE;
-- 由于 T1 已经获取了排他锁,T2 在这里等待
UPDATE orders SET amount = amount + 100 WHERE order_id = 1;
COMMIT;

在上述场景中,T2 因为 T1 持有排他锁而等待,这就是锁争用的情况。为了减少锁争用,可以采取以下策略:

  1. 优化事务逻辑:尽量缩短事务的执行时间,减少锁的持有时间。在上面的例子中,如果 T1 中的业务逻辑可以优化,尽快提交事务,T2 就能更快地获取锁。
  2. 合理设计数据库架构:避免不必要的锁竞争,例如通过分区表等方式,将不同的数据分散到不同的分区,减少对同一资源的争用。

死锁

死锁是一种更为严重的情况,它发生在两个或多个事务相互等待对方释放锁,形成一种循环等待的局面,导致所有事务都无法继续执行。

例如,假设有两个事务 T1T2,分别对两张表 table1table2 进行操作:

-- 事务 T1
START TRANSACTION;
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
SELECT * FROM table2 WHERE id = 1 FOR UPDATE;
UPDATE table2 SET column1 = 'value1' WHERE id = 1;
UPDATE table1 SET column2 = 'value2' WHERE id = 1;
COMMIT;

-- 事务 T2
START TRANSACTION;
SELECT * FROM table2 WHERE id = 1 FOR UPDATE;
SELECT * FROM table1 WHERE id = 1 FOR UPDATE;
UPDATE table1 SET column2 = 'value3' WHERE id = 1;
UPDATE table2 SET column1 = 'value4' WHERE id = 1;
COMMIT;

在上述场景中,如果 T1 先获取了 table1id = 1 的排他锁,同时 T2 先获取了 table2id = 1 的排他锁,然后 T1 尝试获取 table2id = 1 的锁,T2 尝试获取 table1id = 1 的锁,就会形成死锁。

MySQL 的 InnoDB 存储引擎提供了死锁检测机制,当检测到死锁时,会自动选择一个事务(通常是回滚代价较小的事务)进行回滚,以打破死锁局面。为了进一步预防死锁,可以采取以下措施:

  1. 按照相同顺序访问资源:在上面的例子中,如果 T1T2 都按照先访问 table1 再访问 table2 的顺序获取锁,就可以避免死锁。
  2. 设置合理的锁超时时间:当一个事务等待锁的时间超过一定阈值时,自动回滚该事务,避免无限期等待。

乐观锁与悲观锁

在并发控制策略中,除了基于锁机制的传统方法外,还有乐观锁和悲观锁两种不同的思想。

悲观锁

悲观锁基于一种悲观的假设,认为在数据处理过程中很可能会发生并发冲突,因此在每次读取数据时就获取锁,以防止其他事务对数据进行修改。前面提到的共享锁和排他锁都属于悲观锁的范畴。

例如,在一个库存管理系统中,要减少商品库存:

START TRANSACTION;
SELECT stock FROM products WHERE product_id = 1 FOR UPDATE;
-- 假设这里检查库存是否足够
UPDATE products SET stock = stock - 1 WHERE product_id = 1;
COMMIT;

上述代码使用 SELECT ... FOR UPDATE 获取排他锁,确保在事务处理库存减少的过程中,其他事务不能修改库存数据,这就是悲观锁的应用。

乐观锁

乐观锁则基于一种乐观的假设,认为在大多数情况下,数据处理过程中不会发生并发冲突,因此在读取数据时并不获取锁。只有在更新数据时,才检查数据在读取之后是否被其他事务修改过。如果没有被修改过,则执行更新操作;如果被修改过,则放弃更新或者重新读取数据再尝试更新。

实现乐观锁通常需要借助版本号(version)或者时间戳(timestamp)机制。例如,在 products 表中增加一个 version 字段:

-- 读取数据及版本号
SELECT stock, version FROM products WHERE product_id = 1;
-- 假设这里计算出需要更新的库存数量
UPDATE products SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = [之前读取的版本号];
-- 检查更新的行数,如果为 0,表示数据已被其他事务修改,需要重新处理

上述代码中,在更新数据时,通过 version 字段来检查数据是否被修改。只有当 version 与之前读取的版本号一致时,才执行更新操作,这就是乐观锁的应用。

乐观锁适用于读操作较多、写操作较少的场景,因为它减少了锁的使用,提高了并发性能。但在写操作频繁的场景下,可能会导致大量的更新失败,需要不断重试,反而降低了性能。

InnoDB 存储引擎的锁机制

InnoDB 是 MySQL 中最常用的存储引擎之一,它提供了丰富且高效的锁机制来支持高并发环境下的数据一致性。

InnoDB 的行锁实现

InnoDB 的行锁是基于索引实现的。当执行一条 UPDATEDELETESELECT ... FOR UPDATE 语句时,InnoDB 会根据语句中的条件,在相应的索引上查找匹配的行,并对这些行加上锁。

例如,对于以下 UPDATE 语句:

UPDATE users SET age = age + 1 WHERE user_id = 1;

如果 user_id 列上有索引,InnoDB 会通过该索引快速定位到 user_id1 的行,并对该行加上排他锁。如果 user_id 列上没有索引,InnoDB 会对全表进行扫描,并对每一行加上锁,这将大大降低并发性能。

意向锁

InnoDB 引入了意向锁(Intention Lock)来协调表锁和行锁之间的关系。意向锁分为意向共享锁(Intention Shared Lock,简称 IS 锁)和意向排他锁(Intention Exclusive Lock,简称 IX 锁)。

当一个事务要对某一行加共享锁时,首先会在表上获取意向共享锁;当要对某一行加排他锁时,首先会在表上获取意向排他锁。这样,当其他事务尝试获取表级锁时,通过检查意向锁的类型,就可以快速判断是否会产生冲突。

例如,假设有一个事务 T1 要对 users 表中的某一行加排他锁:

START TRANSACTION;
-- 首先获取表的意向排他锁
SELECT * FROM users WHERE user_id = 1 FOR UPDATE;
-- 对匹配的行加排他锁
COMMIT;

在上述过程中,T1 会先获取 users 表的意向排他锁,然后再对 user_id1 的行加排他锁。如果此时另一个事务 T2 尝试获取 users 表的共享锁,由于 T1 已经持有意向排他锁,T2 的请求会被拒绝,从而避免了锁冲突。

自增长锁

在 InnoDB 中,对于使用自增长列(通常是 AUTO_INCREMENT 类型)的表,存在一种特殊的锁——自增长锁(Auto - increment Lock)。当一个事务向包含自增长列的表中插入数据时,会获取自增长锁,以确保自增长值的唯一性。

自增长锁是一种表级锁,并且是排他锁。这意味着在一个事务持有自增长锁期间,其他事务不能向该表插入数据,这在一定程度上会影响并发性能。为了优化自增长锁的性能,InnoDB 从 5.1 版本开始引入了一种“轻量级”自增长锁机制,在某些情况下可以减少锁的持有时间,提高并发插入的性能。

并发控制策略实践

在实际应用中,需要根据具体的业务场景选择合适的并发控制策略,以达到最佳的性能和数据一致性。

读多写少场景

对于读多写少的场景,如新闻网站、博客平台等,乐观锁是一个不错的选择。可以在数据表中添加版本号字段,在读取数据时不获取锁,在更新数据时通过版本号检查数据是否被修改。

例如,在一个博客文章表 articles 中,有 content 字段和 version 字段:

-- 读取文章内容
SELECT content, version FROM articles WHERE article_id = 1;
-- 用户修改文章内容后提交
UPDATE articles SET content = [新内容], version = version + 1 WHERE article_id = 1 AND version = [之前读取的版本号];

这样可以大大提高读操作的并发性能,同时在写操作时保证数据的一致性。

写多读少场景

在写多读少的场景,如订单处理系统、库存管理系统等,悲观锁更为合适。可以使用 SELECT ... FOR UPDATE 语句在读取数据时就获取排他锁,确保在数据修改过程中不会被其他事务干扰。

例如,在一个订单处理系统中:

START TRANSACTION;
SELECT * FROM orders WHERE order_id = 1 FOR UPDATE;
-- 检查订单状态并进行处理
UPDATE orders SET status = 'processed' WHERE order_id = 1;
COMMIT;

通过这种方式,可以保证订单处理的原子性和数据一致性,但可能会因为锁争用而影响并发性能,因此需要合理设计事务逻辑,尽量缩短锁的持有时间。

混合场景

在一些业务场景中,读操作和写操作的比例较为均衡,这时需要综合考虑使用多种并发控制策略。可以根据具体的业务操作类型,灵活选择乐观锁或悲观锁。

例如,在一个电商系统中,对于商品查询操作,可以使用乐观锁,因为商品信息修改相对较少;而对于订单创建和支付操作,由于涉及到资金等关键数据,需要使用悲观锁来保证数据的一致性。

同时,还可以通过合理的数据库架构设计,如使用缓存(如 Redis)来减轻数据库的读压力,使用分库分表来提高并发处理能力,进一步优化并发性能。

总之,在 MySQL 中,锁与并发控制策略是一个复杂而关键的领域,需要深入理解各种锁机制和并发控制思想,并结合具体的业务场景进行合理应用,以实现高效、稳定的数据库系统。通过不断优化和实践,可以在保证数据一致性的前提下,最大程度地提高系统的并发性能。