前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >数据库并发处理 - 上的一把好"锁"

数据库并发处理 - 上的一把好"锁"

作者头像
Java_老男孩
发布2020-02-18 12:35:39
1.3K0
发布2020-02-18 12:35:39
举报

为什么要有锁?

我们都是知道,数据库中锁的设计是解决多用户同时访问共享资源时的并发问题。在访问共享资源时,锁定义了用户访问的规则。根据加锁的范围,MySQL 中的锁可大致分成全局锁,表级锁和行锁三类。在本篇文章中,会依次介绍三种类型的锁。在阅读本篇文章后,应该掌握如下的内容:

  1. 为什么要在备份时使用全局锁?
  2. 为什么推荐使用 InnoDB 作为引擎进行备份?
  3. 设置全局只读的方法
  4. 表级锁的两种类型
  5. MDL 导致数据库挂掉的问题
  6. 如何利用两段锁协议减少锁冲突
  7. 如何解决死锁
  8. 对于热点表,如何避免死锁检测的损耗?

全局锁

什么是全局锁?

全局锁会让整个库处于只读状态,其他线程语句(DML,DDL,更新事务类)的语句都被会阻塞。

使用全局锁的场景

在做全库逻辑备份时,会把整库进行 select 然后保存成文本。

为什么要使用全局锁?

想象这样一个场景,要备份一个购买系统,其中购买操作设计到更新账号余额表和用户课程表。

现在进行逻辑备份,在备份过程中,一位用户购买了一门课程,这时需要在余额表扣掉余额,然后在购买的课程中加上一门课。正确的顺序肯定是先进行购买操作,减少余额和增加课程然后在进行备份。但却有可能出现这样的问题:

  1. 如果在时间顺序上先备份余额表 (u_account),然后用户购买(操作两张表),再备份用户课程表(u_course)? 这时用备份的数据做恢复时,会发现用户没花钱却买了一堂课。原因在于,先备份余额表,说明用户余额不变。之后才进行购买操作,余额表减钱,课程表增加一门课程。接着备份课程表,课程表课程加一。购买操作在已经备份完的余额表后进行。
  2. 如果在时间顺序上先备份用户课程表(u_course),然后用户购买(操作两张表),再备份余额表 (u_account)? 同样的,如果先备份课程表,课程没有增加,因为没有进行购买操作。之后进行购买操作后,余额表减钱,然后被备份。就出现了,用户花钱却没有购买成功的情况。

也就是说,不加锁的话,备份系统的得到的库不是一个逻辑时间点,这个视图是逻辑不一致。

如何解决视图逻辑不一致的问题?

对于不支持事务的引擎,像 MyISAM. 通过使用 Flush tables with read lock (FTWRL) 命令来开启全局锁。

但使用 FTWRL 存在的问题是:

  1. 在主库上备份时,备份期间不能执行更新,业务基本暂停。
  2. 在从库上备份,备份期间从库不能执行主库同步过来的 binlog,导致主从延迟。

对于支持事务并且开启一致性视图(可重复读级别)下配合上 MVCC 的功能的引擎(InnoDB),备份就很简单了。

使用官方的 mysqldump 工具时,加上 --single-transaction 选项,再导出数据前就会启动一个事务,来确保拿到一致性视图。并且由于 MVCC 的支持,同时可以进行更新操作。

全库只读设置方法的比较

为什么不推荐使用 set global readonly=true ,要使用 FTWRL :

  1. 在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,不建议使用。
  2. 在异常处理机制上有差异。 执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。 将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。

表级锁

什么是表级锁?

表级锁的作用域是对某张表进行加锁,在 MySQL 中表级别的锁有两种,一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁

与 FTWRL 类似,可以使用 lock tables … read/write 来锁定某张表。在释放时,可以使用 unlock tables 来释放锁或者断开连接时,主动释放。

需要注意的是,这样方式的锁表,不但会限制其他线程的读写,也限定了自己线程的操作对象。

假如,线程 A 执行 lock tables t1 read, t2 write; 操作。

这时对于表 t1 来说,其他线程只能只读,线程 A 也只能只读,不能写。

对于表 t2 来说,只允许线程 A 读写,其他线程读写都会被阻塞。

元数据锁

与表锁手动加锁不同,元数据锁会自动加上。

为什么要有 MDL?

MDL 保证的就是读写的正确性,比如在查询一个中的数据时,此时另一个线程改变了表结构,查询的结果和表结构不一致肯定不行。简单来说,MDL 就是解决 DML 和 DDL 之间同时操作的问题。

在 MySQL 5.5 引入了 MDL,在对一个进行 DML 时,会加 DML 读锁。进行 DDL 时,会加 MDL写锁。

读锁间不互斥,允许多个线程同时对同一张表进行 DML。

读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。

  1. 如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
  2. 如果一个线程要读,另一个线程要写。根据访问表的时间,一个操作进行完之后,另一个才可以进行。

MDL 引发的问题?

给表加字段,却导致库挂了?

由于 MDL 是自动加的,并且在给表加字段或者修改字段或者加索引时,需要扫描全表的数据。所以在对大表操作时,要非常小心,以免对线上的服务造成影响。但实际上,操作小表时,也可能出问题。假设 t 是小表。按照下图所示,打开四个 session.

MySQL 5.7.27

假设有一张叫 sync_test 的表:

mysql> desc sync_test;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+
2 rows in set (0.00 sec)

开启事务1, 插入数据。对于事务 1 来说,自动申请了表 sync_test 的 MDL 读锁:

开启事务2,插入数据。对于事务 2 来说,自动申请了表 sync_test 的 MDL 读锁:

开启事务3,改变表结构。对于事务 3 来说,会申请表 sync_test 的 MDL 写锁,这时由于读写锁互斥,被阻塞:

开启事务 4,插入数据。对于事务 4 来说,会申请 sync_test 的 MDL 读锁,由于之前事务 3 提前申请了写锁,互斥所以被阻塞:

这时如果在这张表上的查询语句很频繁,而且客户端有重连机制,在超时后会再起一个新 session 请求,这个库的线程就很快会爆满了。

如何安全的给表加资源

通过上面的例子也可以看到,MDL 会直到事务提交才释放,在做表结构变更的时候,一定要小心不要导致锁住线上查询和更新。在开启事务后,并没有在短时间内结束,也就是由于所谓的长事务造成的。如果想对某个表进行 DDL 的操作时,可以先查询下是否有长事务的运行(information_schema 下的 innodb_trx 表),可以先 kill 这个事务,然后做 DDL 操作。

但有时 kill 也未必可以,在表被频繁使用时,新的事务可能马上就来了。比较理想的情况,在 alter table 中设定等待时间,如果在时间内拿到最好,否则就放弃,不要阻塞语句。之后再重复这个操作。

MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。

ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...

行级锁

什么是行级锁?

MySQL 的行锁是由引擎层自己实现的,所有不是所有的引擎都执行行锁,比如在 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能用表锁,这就造成了在同一时刻只有一个更新在执行,就影响到了业务的并发度。InnoDB 支持行锁是让 MyISAM 被取代的重要原因。

行锁就是对数据库表中行记录的锁。比如事务 A,B 同时想要更新一行数据,在更新时一定会按照一定的顺序进行,而不能同时更新。

行锁的目的就是减少像表级别的锁冲突,来提升业务的并发度。

两阶段锁协议

在 InnoDB 的事务中,行锁是在需要的时候在加上,但并不是使用完就释放,而是在事务结束后才释放,这就是两阶段锁协议。

假设有一个表 t,事务 A, B 操作表 t 的过程如下:

在事务 A 的两条语句更新后,事务 B 更新操作会被阻塞。直到事务 A 中执行 commit 操作后才能执行。

两阶段锁在事务上的帮助

由于两阶段锁的特点,在事务结束时才会释放锁,所以需要遵循的一个原则是事务中需要锁多个行时,把有可能造成锁冲突,最可能影响并发度的锁尽量向后放。

比如购买课程的例子,顾客 A 购买培训机构 B 一门课程。涉及到操作:

  1. 顾客 A 的余额减少
  2. 培训机构 B 所在的余额增加。
  3. 插入一条交易信息的操作。

对于第二个操作,当有许多人同时购买时并发度就较高,出现锁冲突的情况也较高。所以将操作 2 放置一个事务的最后就更好。

当有时并发度过大时,我们会发现一种现象 CPU 的使用率接近 100%,但事务执行数量却很少。这就可能出现了死锁。

死锁的检查

当并发系统中不同的线程出现循环的资源依赖,等待别的线程释放资源时,就会让涉及的线程处于一直等待的情况。这就称为死锁。

如上图中,事务 A 对id =1 的所在行,加入了行锁。等待 id=2 的行锁。事务 B 对 id = 2 的行,加入了行锁。等待 id=1 的行锁。事务 A,B 等待对方资源的释放。

如何解决死锁

方式 一: 设置死锁的等待时间 innodb_lock_wait_timeout

还是 sync_test 这张表,模拟简单的锁等待情况,注意这里并不是死锁。开启两个事务 A,B. 同时对 id=1 这行进行更新。

事务 A 更新操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update sync_test set name="dead_lock_test" where id = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

事务 B 更新操作:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update sync_test set name="dead_lock_test2" where id = 1;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

可以看到事务 B 抛出了死锁等待的错误。

设置等待时间的问题

在 InnoDB 中,MySQL 默认的死锁等待时间是 50s. 意味着在出现死锁后,被锁住的线程要过 50s 被能退出,这对于在线服务说,等待时间过长。但如果把值设置的过小,如果是像上述例子这样是简单的锁等待呢,并不是死锁怎么办,就会出现误伤的情况。

方式二:发起死锁检测,发现死锁后,主动回滚某个事务,让其他事务继续执行。

MySQL 中默认就是打开状态,能够快速发现死锁的情况。

set innodb_deadlock_detect=on

事务 A,B 互相依赖,造成死锁的例子:

开启事务 A:

mysql> begin;
mysql> update sync_test set name="dead_lock_test1" where id = 1;

开启事务 A:

mysql> begin;
mysql> update sync_test set name="dead_lock_test3" where id = 3;

继续操作事务 A:

mysql> update sync_test set name="dead_lock_test3_1" where id = 3;

# 会出现阻塞的情况

继续操作事务 B:

mysql> update sync_test set name="dead_lock_test1_2" where id = 1;
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

此时事务 A 阻塞取消,执行成功。

不过检测死锁也是有额外负担的,每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。如果是所有事务都要更新同一行的场景呢?每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 1000*1000=100 万这个量级的。

所以,对于更新频繁并发量大的表,死锁检测会导致消耗大量的 CPU.

如何避免死锁检测的损耗

方法一:如果保证业务一定不会出现死锁,可以临时把死锁检查关掉。

但这样存在一定的风险,因为业务设计时不会把死锁当做严重的问题,出现死锁后回滚后,再重试就没有问题了。但关掉死锁检测后,可能出现大量超时的情况。

方法二:控制并发度。

如果对于并发量能控制,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。具体来说在客户端做并发控制,但对于客户端较多的应用,也无法控制。所以并发控制在数据库服务端,如果有中间件,也可以考虑在中间件中实现。

方法三:降低死锁的概率

将一行统计的结构,拆成多行累计的结构。比如将之前某个教学机构的金额由一行拆成 10 行,总收入就等于这 10 行数据的累计。这样原来锁冲突的概率变为原来的 1/10, 也就减少了死锁检测的 CPU 消耗。但在一部分行记录变成0 时,代码需要特殊处理。

总结

本篇文章中,依次介绍了全局锁、表级锁和行锁的概念。

对于全局锁来说,使用 InnoDB 引擎 在 RR 级别和 MVCC 的帮助下,可以让其在备份的同事更新数据。

对于表级锁来说,对于更新热点表的表结构时,要注意 MDL 读写锁互斥的情况,造成数据库挂掉。

对于行级锁来说,合理的利用两段锁协议,降低锁的冲突。并要注意死锁发生的情况,采取合适的死锁检测手段。

欢迎关注公众号:老男孩的成长之路,精选干货每周定期奉上!

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 全局锁
    • 什么是全局锁?
      • 使用全局锁的场景
        • 为什么要使用全局锁?
          • 如何解决视图逻辑不一致的问题?
            • 全库只读设置方法的比较
            • 表级锁
              • 什么是表级锁?
                • 表锁
                  • 元数据锁
                    • 如何安全的给表加资源
                    • 行级锁
                      • 什么是行级锁?
                        • 两阶段锁协议
                          • 两阶段锁在事务上的帮助
                            • 死锁的检查
                              • 如何解决死锁
                                • 如何避免死锁检测的损耗
                                • 总结
                                相关产品与服务
                                云数据库 SQL Server
                                腾讯云数据库 SQL Server (TencentDB for SQL Server)是业界最常用的商用数据库之一,对基于 Windows 架构的应用程序具有完美的支持。TencentDB for SQL Server 拥有微软正版授权,可持续为用户提供最新的功能,避免未授权使用软件的风险。具有即开即用、稳定可靠、安全运行、弹性扩缩等特点。
                                领券
                                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档