乐观锁
乐观锁是一种并发控制机制,它假设在事务提交之前没有其他事务会修改相同的数据
。对数据的操作是保持乐观态度的,因此被称为乐观锁。乐观锁通过记录版本号或者时间戳来判断数据是否被修改。回顾以前学过的知识点,在Java的CAS操作机制中也用到了乐观锁的思想来保证数据的可靠性。
那乐观锁的思想如何运用到数据库中呢,我们通过栗子来学习乐观锁。比如在我之前的电商网站课设中,使用products表来管理商品库存。每个商品记录有一个version字段用于乐观锁控制。
-- 初始库存,为了方便理解添加几条数据 INSERT INTO products (product_id, stock, version) VALUES (1, 100, 0); -- 事务A START TRANSACTION; -- 读取商品库存和版本号 SELECT stock, version FROM products WHERE product_id = 1; -- 假设此时读取到 stock=100, version=0 -- 稍后进行库存减少操作(此时其他事务未修改) UPDATE products SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = 0; -- 检查是否更新成功 IF ROW_COUNT() = 1 THEN COMMIT; ELSE ROLLBACK; END IF; -- 事务B(并发执行) START TRANSACTION; -- 读取商品库存和版本号(在同一时间,也读取到 stock=100, version=0) SELECT stock, version FROM products WHERE product_id = 1; -- 尝试减少库存(但此时version已不匹配,因为事务A它已经提交了) UPDATE products SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = 0; -- 由于version不匹配,更新失败,事务B回滚 IF ROW_COUNT() = 0 THEN ROLLBACK; END IF;
因此,从这个小栗子中可以看出,乐观锁通常是通过记录版本号或者时间戳来判断数据是否被修改的。
悲观锁
顾名思义,悲观锁在数据是否被修改上对数据持有的态度就并不那么乐观了。悲观锁假设在事务期间会发生冲突,它在操作期间持有锁来避免冲突,和乐观锁恰恰相反。
我们往订单处理系统中添加几条数据,使用orders表来管理订单状态。
-- 初始订单状态 INSERT INTO orders (order_id, status) VALUES (1, 'pending'); -- 事务A START TRANSACTION; -- 查询订单状态并加锁 这里的FOR UPDATE表示枷锁 SELECT order_id, status FROM orders WHERE order_id = 1 FOR UPDATE; -- 执行一些业务逻辑,如检查库存、支付验证等 里略过 -- 更新订单状态为处理中 UPDATE orders SET status = 'processing' WHERE order_id = 1; COMMIT; -- 如果在事务A执行期间,事务B尝试更新同一订单的状态,它需要等待事务A提交或回滚后才能继续。
由此可见,悲观锁的实现方式是通过SQL语句中的SELECT ... FOR UPDATE
(添加独占锁)或LOCK IN SHARE MODE
(共享锁)来加锁。
行级锁
行锁的定义:行级锁是对单个行加锁,确保数据行的独占访问
。
行锁和悲观锁的独占锁有着异曲同工之妙,因为SELECT ... FOR UPDATE
在InnoDB中实际上是通过行级锁来实现的。
表级锁
定义:表级锁是对整个表加锁,其他连接无法修改或读取此表的数据。在InnoDB中主要用于元数据操作。
虽然InnoDB主要使用行级锁,但在执行如ALTER TABLE
这样的DDL操作时,会隐式地对表加锁。
-- 假设需要修改orders表的结构 ALTER TABLE orders ADD COLUMN delivery_date DATE; -- 在执行此操作时,orders表被锁定,其他事务无法访问。
意向锁
意向锁是表级锁的一种,它主要用于表示事务将来对表中的行加锁的意向。
意向锁是内部机制,通常情况下是不需要用户直接操作的。它们在InnoDB内部用于协调行级锁和表级锁之间的冲突。
间隙锁
间隙锁是锁定一个范围的键,但不包括这些键的实际值,用于防止幻读。我们经常背的八股就是可重复度的隔离级别下...请往下看👇👇👇
在可重复读隔离级别下,InnoDB会自动使用间隙锁来防止幻读。
我们使用一个简单略懂的栗子来解释间隙锁:
-- test_table表有一个自增主键id,当前最大值为5 START TRANSACTION; -- 锁定id大于5的所有记录之间的间隙(任何将来可能插入的id值) SELECT * FROM test_table WHERE id > 5 FOR UPDATE; -- 此时,如果另一个事务尝试插入id=6的记录,它将被阻塞,直到当前事务提交或回滚。 -- 提交事务 COMMIT;
test_table中虽然还没有id > 5的记录,但SELECT ... FOR UPDATE
查询会锁定所有大于5的id值之间的间隙,防止其他事务在这些位置插入新记录。
Next-Key Locks
Next-Key Locks是行锁和间隙锁的组合,锁定一个索引记录以及该记录之前的间隙。
在InnoDB的默认隔离级别(可重复读)中,当使用SELECT ... FOR UPDATE
或SELECT ... LOCK IN SHARE MODE
时,InnoDB不仅会对涉及的索引记录加行锁,而且还会对索引记录之前的间隙加间隙锁,形成所谓的Next-Key Locks。这个锁确保了索引记录被锁定,并且任何
尝试插入或修改被锁定的索引记录之前间隙的记录都会被阻塞。
比方说我们有一个employees表,表里有一个id列作为主键,当前表中的数据如下图:
+----+-----------+ | id | name | +----+-----------+ | 1 | Alice | | 3 | Charlie | | 5 | Eve | +----+-----------+
事务A
START TRANSACTION; -- 锁定id=3的记录及其之前的间隙(防止插入id=2,但允许插入id>3且id<5的值) -- 同时也锁定id=3之后的间隙(防止插入id=3的重复值,但这不是Next-Key Locks的直接作用,而是行锁的效果) SELECT * FROM employees WHERE id = 3 FOR UPDATE; COMMIT;
事务B(需要并发执行) 在事务A执行期间,事务B尝试执行以下操作:
-- 尝试插入id=2的记录(被阻塞,因为事务A的Next-Key Locks锁定了id=2的间隙) INSERT INTO employees (id, name) VALUES (2, 'Bob'); -- 等待或者失败 -- 尝试插入id=4的记录(成功,因为id=4的间隙没有被事务A的Next-Key Locks直接锁定) -- 但是需要注意,如果事务A之后尝试锁定id=4或其间隙(通过另一个查询),则这个插入可能会受到影响 INSERT INTO employees (id, name) VALUES (4, 'David'); -- 可能成功,但是取决于事务A的后续操作 -- 尝试更新id=3的记录(被阻塞,因为事务A对id=3的行加了行锁) UPDATE employees SET name = 'Charlie Brown' WHERE id = 3; -- 等待事务A提交或回滚
在事务A里,通过SELECT * FROM employees WHERE id = 3 FOR UPDATE;
,InnoDB不仅锁定了id=3的行,还锁定了id=2到id=3之间的间隙(防止插入id=2),以及id=3之后的微小间隙(主要是为了防止幻读,尽管这个间隙在实际操作中通常不会由用户直接插入数据来触发阻塞,因为id=3已经是存在的记录)。
在事务B中,尝试插入id=2的记录会被阻塞,因为它试图在事务A锁定的间隙中插入数据。
尝试插入id=4的记录可能成功,因为id=4的间隙没有被事务A直接锁定。但是,如果事务A之后执行了影响id=4或其间隙的操作(如另一个SELECT ... FOR UPDATE
查询),则事务B的插入可能会受到影响。
尝试更新id=3的记录会被阻塞,因为事务A已经对该行加了行锁。