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

MySQL锁的互斥性实践指南

2024-08-026.0k 阅读

MySQL 锁的互斥性基础概念

在 MySQL 数据库中,锁是一种重要的机制,用于控制对共享资源的并发访问。锁的互斥性确保在同一时间只有一个事务可以对特定资源进行特定类型的操作,从而避免数据不一致和并发冲突问题。

共享锁(Shared Locks,S 锁)

共享锁允许多个事务同时读取同一资源。当一个事务对数据加共享锁后,其他事务也可以对该数据加共享锁,大家都能读取数据,但不能对其进行修改。只有在所有共享锁都被释放后,才能对数据进行修改操作。

例如,在银行账户查询场景中,多个用户可能同时查询账户余额,此时可以使用共享锁来确保数据的一致性读取。

排他锁(Exclusive Locks,X 锁)

排他锁则与共享锁相反,它只允许一个事务对资源进行访问。当一个事务对数据加了排他锁后,其他事务既不能对该数据加共享锁,也不能加排他锁,直到该排他锁被释放。这种锁主要用于数据修改操作,如更新、删除等,以防止其他事务在修改过程中干扰,保证数据的一致性。

以电商系统中商品库存扣减为例,当一个订单进行库存扣减操作时,需要对库存数据加排他锁,防止其他订单同时扣减库存导致数据错误。

MySQL 不同类型锁的互斥性实践

表级锁

表级锁是 MySQL 中最基本的锁类型,它会锁定整个表。这种锁的粒度较大,并发性能相对较低,但实现简单,开销小。

  1. 共享表锁

    -- 对表加共享表锁
    LOCK TABLES your_table_name READ;
    -- 执行读取操作
    SELECT * FROM your_table_name;
    -- 释放锁
    UNLOCK TABLES;
    

    在上述代码中,LOCK TABLES your_table_name READ 语句对 your_table_name 表加了共享表锁,此时其他事务可以对该表进行读取操作,但不能进行写入操作。只有当执行 UNLOCK TABLES 释放锁后,其他事务才可能获取到写锁进行写入。

  2. 排他表锁

    -- 对表加排他表锁
    LOCK TABLES your_table_name WRITE;
    -- 执行写入操作,如插入、更新、删除等
    INSERT INTO your_table_name (column1, column2) VALUES ('value1', 'value2');
    -- 释放锁
    UNLOCK TABLES;
    

    LOCK TABLES your_table_name WRITE 语句对表加了排他表锁,在锁未释放期间,其他事务无论是读还是写操作都无法对该表进行。

行级锁

行级锁是锁定表中的某一行数据,粒度最小,并发性能较高,但实现相对复杂,开销也较大。InnoDB 存储引擎支持行级锁。

  1. 共享行锁

    START TRANSACTION;
    -- 对满足条件的行加共享行锁
    SELECT * FROM your_table_name WHERE some_column ='some_value' LOCK IN SHARE MODE;
    -- 执行读取操作
    -- 提交事务,释放锁
    COMMIT;
    

    在上述代码中,SELECT... LOCK IN SHARE MODE 语句对 your_table_name 表中满足 some_column ='some_value' 条件的行加了共享行锁。其他事务可以对这些行加共享行锁读取数据,但不能加排他行锁修改数据,直到当前事务提交或回滚释放锁。

  2. 排他行锁

    START TRANSACTION;
    -- 对满足条件的行加排他行锁
    SELECT * FROM your_table_name WHERE some_column ='some_value' FOR UPDATE;
    -- 执行写入操作,如更新、删除等
    UPDATE your_table_name SET another_column = 'new_value' WHERE some_column ='some_value';
    -- 提交事务,释放锁
    COMMIT;
    

    SELECT... FOR UPDATE 语句对满足条件的行加了排他行锁。此时其他事务既不能对这些行加共享行锁,也不能加排他行锁,直至当前事务结束。

页级锁

页级锁介于表级锁和行级锁之间,它锁定的是数据页。MyISAM 存储引擎在某些情况下也会使用页级锁。页级锁的粒度比表级锁小,比行级锁大,并发性能和开销也介于两者之间。

不过,在 MySQL 实际应用中,页级锁相对使用较少,这里简单提及以完善锁类型的知识体系。

死锁与互斥性

死锁的概念

死锁是指两个或多个事务在执行过程中,因争夺资源而造成的一种互相等待的现象。如果没有外力作用,这些事务都将无法推进下去。例如,事务 A 持有资源 X 的锁并请求资源 Y 的锁,而事务 B 持有资源 Y 的锁并请求资源 X 的锁,这样就形成了死锁。

死锁与锁互斥性的关系

死锁的产生与锁的互斥性密切相关。由于锁的互斥性,事务在获取锁时可能会出现等待情况。当多个事务之间的等待形成循环依赖时,就会导致死锁。例如,事务 T1 对数据行 R1 加了排他锁,事务 T2 对数据行 R2 加了排他锁,然后 T1 试图获取 R2 的排他锁,T2 试图获取 R1 的排他锁,因为锁的互斥性,T1 和 T2 都无法获取到对方持有的锁,从而形成死锁。

死锁检测与处理

  1. 死锁检测 MySQL 的 InnoDB 存储引擎具有自动死锁检测机制。InnoDB 会在事务等待锁的时间超过一定阈值时,自动检测是否存在死锁。如果检测到死锁,InnoDB 会选择一个事务作为牺牲品(通常选择回滚代价最小的事务)进行回滚,以打破死锁局面。

  2. 死锁处理策略

    • 避免死锁的设计:在设计数据库操作和事务逻辑时,尽量按照相同的顺序访问资源。例如,如果多个事务都需要操作表 A 和表 B,那么都按照先操作表 A 再操作表 B 的顺序进行,这样可以避免循环等待导致的死锁。
    • 设置合理的锁等待超时时间:可以通过 innodb_lock_wait_timeout 参数设置事务等待锁的最长时间。如果一个事务等待锁的时间超过这个值,就会自动回滚,从而避免无限期等待。
    -- 查看当前锁等待超时时间
    SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
    -- 设置锁等待超时时间为 10 秒
    SET innodb_lock_wait_timeout = 10;
    

优化锁的互斥性以提升性能

合理选择锁粒度

  1. 读多写少场景 如果应用场景是读多写少,如新闻网站、博客平台等,适合使用共享锁和表级锁。因为共享锁允许并发读取,表级锁实现简单,开销小。可以对整个表加共享表锁,满足大量用户同时读取数据的需求。例如:
    LOCK TABLES news_articles READ;
    SELECT * FROM news_articles;
    UNLOCK TABLES;
    
  2. 读写均衡场景 对于读写均衡的场景,行级锁更为合适。以电商订单系统为例,订单的查询和修改操作都较为频繁。在查询订单时,可以使用共享行锁:
    START TRANSACTION;
    SELECT * FROM orders WHERE order_id = 123 LOCK IN SHARE MODE;
    COMMIT;
    
    在修改订单时,使用排他行锁:
    START TRANSACTION;
    SELECT * FROM orders WHERE order_id = 123 FOR UPDATE;
    UPDATE orders SET status = 'completed' WHERE order_id = 123;
    COMMIT;
    
  3. 写多读少场景 写多读少的场景,如日志记录系统,排他锁和行级锁或表级锁结合使用。如果每次写入的数据量较大,可以考虑表级排他锁;如果每次写入只是少量数据,行级排他锁更为合适。例如,对于日志表的批量插入操作:
    LOCK TABLES logs WRITE;
    INSERT INTO logs (log_message) VALUES ('log1'), ('log2');
    UNLOCK TABLES;
    

减少锁的持有时间

  1. 尽量将事务中的操作合并 在事务中,减少不必要的操作,将多个操作合并成一个。例如,在更新用户信息时,如果需要更新多个字段,不要分多次更新,而是一次性更新:

    -- 不好的做法
    START TRANSACTION;
    UPDATE users SET name = 'new_name' WHERE user_id = 1;
    UPDATE users SET age = 30 WHERE user_id = 1;
    COMMIT;
    -- 好的做法
    START TRANSACTION;
    UPDATE users SET name = 'new_name', age = 30 WHERE user_id = 1;
    COMMIT;
    

    这样可以减少锁的持有时间,提高并发性能。

  2. 尽早释放锁 在事务中,如果某些操作不再需要锁的保护,可以尽早释放锁。例如,在读取数据后,如果只是进行一些计算操作,不需要再对数据进行修改,可以在计算前提交事务释放锁:

    START TRANSACTION;
    SELECT amount FROM accounts WHERE account_id = 1 LOCK IN SHARE MODE;
    -- 这里进行计算操作,不涉及数据修改
    SET @new_amount = @old_amount * 1.1;
    COMMIT;
    -- 后续如果需要更新数据,重新开启事务
    START TRANSACTION;
    UPDATE accounts SET amount = @new_amount WHERE account_id = 1;
    COMMIT;
    

不同存储引擎下锁互斥性的差异

InnoDB 存储引擎

  1. 行级锁实现 InnoDB 的行级锁基于索引实现。如果查询语句没有使用索引,InnoDB 会将其升级为表级锁,这会大大降低并发性能。例如:
    -- 没有使用索引的查询,会升级为表级锁
    SELECT * FROM your_table_name WHERE non_indexed_column ='some_value' FOR UPDATE;
    -- 使用索引的查询,使用行级锁
    SELECT * FROM your_table_name WHERE indexed_column ='some_value' FOR UPDATE;
    
  2. 一致性非锁定读 InnoDB 支持一致性非锁定读,即事务在读取数据时,不会对数据加锁。这种机制通过多版本并发控制(MVCC)实现,大大提高了并发读的性能。例如,在一个长事务中读取数据,即使其他事务对数据进行了修改,当前事务看到的数据仍然是一致的快照。

MyISAM 存储引擎

  1. 表级锁为主 MyISAM 主要使用表级锁,不支持行级锁。这使得在高并发写操作场景下,MyISAM 的性能较差。例如,在一个频繁更新数据的表上,如果使用 MyISAM 存储引擎,每次更新操作都会锁定整个表,其他事务的读写操作都要等待锁的释放。
  2. 读写锁分离 MyISAM 虽然只有表级锁,但支持读写锁分离。读锁可以共享,写锁是排他的。当有读操作时,其他读操作可以并发进行;但当有写操作时,所有读操作和其他写操作都要等待。

Memory 存储引擎

  1. 表级锁特性 Memory 存储引擎同样使用表级锁。由于其数据存储在内存中,读写速度非常快,但锁的粒度较大,在并发场景下,可能会因为锁竞争导致性能问题。例如,在一个高并发的内存表操作场景中,频繁的写入操作会导致其他事务等待锁,降低整体性能。
  2. 锁的应用场景 Memory 存储引擎适合用于缓存数据,如网站的热门数据缓存。由于数据经常被读取,而写入操作相对较少,使用表级锁对性能影响相对较小。

实际案例分析

案例一:电商库存扣减

  1. 业务场景 在电商系统中,当用户下单时,需要扣减商品库存。如果并发下单量较大,可能会出现库存超卖的问题。

  2. 使用锁的解决方案 使用排他行锁来保证库存扣减的一致性。

    START TRANSACTION;
    SELECT stock FROM products WHERE product_id = 123 FOR UPDATE;
    -- 检查库存是否足够
    SET @stock = (SELECT stock FROM products WHERE product_id = 123);
    IF @stock >= 1 THEN
        UPDATE products SET stock = stock - 1 WHERE product_id = 123;
    ELSE
        -- 库存不足,进行相应处理,如回滚事务
        ROLLBACK;
        RETURN '库存不足';
    END IF;
    COMMIT;
    

    在上述代码中,SELECT... FOR UPDATE 对商品库存行加了排他行锁,确保在库存检查和扣减过程中,其他事务无法修改库存数据,避免了库存超卖问题。

案例二:银行转账

  1. 业务场景 在银行系统中,进行转账操作时,需要从一个账户扣除金额并转入另一个账户。这涉及到两个账户数据的修改,必须保证数据的一致性,否则可能会出现转账金额丢失或重复转账等问题。

  2. 使用锁的解决方案 为了保证转账操作的原子性和数据一致性,需要对两个账户加排他行锁。

    START TRANSACTION;
    -- 对转出账户加排他行锁
    SELECT balance FROM accounts WHERE account_id = 1001 FOR UPDATE;
    -- 对转入账户加排他行锁
    SELECT balance FROM accounts WHERE account_id = 1002 FOR UPDATE;
    -- 执行转账操作
    UPDATE accounts SET balance = balance - 100 WHERE account_id = 1001;
    UPDATE accounts SET balance = balance + 100 WHERE account_id = 1002;
    COMMIT;
    

    通过对两个账户加排他行锁,确保在转账过程中,其他事务无法对这两个账户的数据进行修改,从而保证了转账操作的正确性和数据一致性。

通过以上对 MySQL 锁互斥性的深入探讨、实践指南以及实际案例分析,相信读者对 MySQL 锁的互斥性有了更全面和深入的理解,能够在实际开发中更好地运用锁机制,优化数据库性能,确保数据的一致性和完整性。