上云03 | 数据库事务

应用开发约束

MySQL线程池

云数据库抛弃了MySQL传统连接模式——每连接每线程,这种连接模式会导致巨大的系统开销进而影响整个性能。

因此,云数据库使用线程池技术来解决最大连接数限制问题以及过多线程带来的系统开销。

使用线程池的理由有如下两个:

1、减少线程重复创建与销毁部分的开销,提高性能

线程池技术通过预先创建一定数量的线程,在监听到有新的请求的时候,线程池直接从现有的线程中分配一个线程来提供服务,服务结束后这个线程不会直接销毁,而是又去处理其他的请求。这样就避免了线程和内存对象频繁创建和销毁,减少了上下文切换,提高了资源利用率,从而在一定程度上提高了系统的性能和稳定性。

2、对系统起到保护作用

线程池技术限制了并发线程数,相当于限制了MySQL的runing线程数,无论系统目前有多少连接或者请求,超过最大设置的线程数的都需要排队,让系统保持高性能水平。从而防止DB出现雪崩,对底层DB起到保护作用。

线程池和连接池的区别

可能有的DBA会把线程池和连接池混淆,其实两者是有很大区别的,连接池一般在客户端设置,而线程池是在DB服务器上配置

另外连接池可以取到避免了连接频繁创建和销毁,但是无法取到控制MySQL活动线程数的目标,在高并发场景下,无法取到保护DB的作用。比较好的方式是将连接池和线程池结合起来使用。

MySQL默认的连接控制方式采用的是每个连接使用一个线程执行客户端的请求。MySQL的线程池是包含在企业版里面的服务器插件。使用线程池的目的是为了改善大量并发连接所带来的性能下降。在大量并发连接的工作负载下,使用线程池可以解决无法利用CPU缓存、上下文切换开销过大以及资源争用等问题。

在这里插入图片描述
线程池是由一定数量的线程组Thread Group(默认为16个通过thread_pool_size 进行配置)构成,每个线程组管理一组客户端连接,最大连接数为4096。连接创建之后会以轮询的方式分配给线程组。连接池打破了每个连接与线程一一对应的关系,这一点与MySQL默认的线程控制方式不同,默认方式将一个线程与一个连接相关联,以便给定的线程从其连接执行所有的语句。

默认情况下,线程池试图确保每个组中每次最多执行一个线程,但有时为了获得最佳性能,允许临时执行多个线程。每组里面有一个监听线程,负责监听分配给该组的连接。线程会选择立即执行或稍后执行连接里面的语句,如果语句是唯一接收到的,并且当前没有排队或正在执行的语句,该语句就会立即执行。其它情况则会选择稍后执行。当该语句被判断为立即执行时,监听线程负责执行该语句,如果能够快速完成执行,该线程会返回监听状态,如果执行语句时间过长产生停滞,线程组会开启一个新的监听线程。线程池插件使用一个后台线程监控线程组状态,以确保线程组不会因为停滞的语句阻塞线程组。

禁用存储过程、函数、触发器、视图

随着分布式、微服务、集群等占据主流,对于分布式的数据库,对于存储过程的舍弃逐渐成为了必然。

数据库集群一旦走分布布就面临着N种问题,数据库一致性,查询,事务等。整个架构能力要求水平突然就上升到非常专业的高度。

现在互联网普遍的规则是使用业务代码来代替储存过程,包括阿里的最佳实践也是推荐业务代码而不是储存过程。

数据库专注于数据的存储及安全,而数据处理集中在业务程序。把算力集群消耗在WEB端减轻数据库负载。

储存过程优势:

  • 代码执行更快,储存过程代码经过预编译天生就是快男。
  • 数据不需要DB服务器与WEB服务器之间转换,直接就被处理,占有网络优势。
  • 保护核心数据,数据不出库。特别在银行项目,通过储存过程来调用可以防止开发人员直接接触数据保护数据安全。

但是大趋势让我们不得不面临存储过程的改造,将数据处理迁移回业务逻辑。

存储过程全流程发生在数据库,不对外产生任何的输入和输出,相对是个异步并且独立的事件。但是对于业务逻辑,将存储过程转化为一条一条的sql之后,数据处理计算的负载压力是在数据库侧还是在业务逻辑处理侧?存储过程一般都比较的大,对于一般的sql而言是大事务,大事务也会带来很多问题,比如binlog超过1G,执行时间超时等?

首先探究一下sql的执行原理

SQL执行原理

通过一个简单的查询语句举例:

SELECT 
    city.city_name AS "City",
    COUNT(*) AS "citizen_cnt"
FROM citizen
  JOIN city ON citizen.city_id = city.city_id 
WHERE city.city_name != '上海'
GROUP BY city.city_name
HAVING COUNT(*) >= 2
ORDER BY city.city_name ASC
LIMIT 2

执行步骤

上面SQL查询语句的书写书序是:

SELECT ... FROM ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...

但是执行顺序并不是这样,具体的执行顺序如下步骤所示:

  1. 获取数据 (From, Join)

  2. 过滤数据 (Where)

  3. 分组 (Group by)

  4. 分组过滤 (Having)

  5. 返回查询字段 (Select)

  6. 排序与分页 (Order by & Limit / Offset)

尖叫提示:本文旨在说明通用的SQL执行底层原理,对于其优化技术不做考虑,比如谓词下推、投影下推等等。

执行的底层原理

其实上面所说的SQL执行顺序就是所谓的底层原理,当我们在执行SELECT语句时,每个步骤都会产生一张虚拟表(virtual table),在执行下一步骤时,会将该虚拟表作为输入。指的注意的是,这些过程是对用户透明的。

你可以注意到,SELECT 是先从FROM 这一步开始执行的。在这个阶段,如果是多张表进行JOIN,还会经历下面的几个步骤:

获取数据 (From, Join)

  • 首先会通过 CROSS JOIN 求笛卡尔积,相当于得到虚拟表 vt1-1;
  • 接着通过ON 条件进行筛选,虚拟表 vt1-1 作为输入,输出虚拟表 vt1-2;
  • 添加外部行。我们使用的是左连接、右链接或者全连接,就会涉及到外部行,也就是在虚拟表 vt1-2 的基础上增加外部行,得到虚拟表 vt1-3

过滤数据 (Where)

经过上面的步骤,我们得到了一张最终的虚拟表vt1,在此表之上作用where过滤,通过筛选条件过滤掉不满足条件的数据,从而得到虚拟表vt2。

分组 (Group by)

经过where过滤操作之后,得到vt2。接下来进行GROUP BY操作,得到中间的虚拟表vt3。

分组过滤 (Having)

在虚拟表vt3的基础之上,使用having过滤掉不满足条件的聚合数据,得到vt4。

返回查询字段 (Select)

当我们完成了条件筛选部分之后,就可以筛选表中提取的字段,也就是进入到 SELECT 和 DISTINCT 阶段。首先在 SELECT 阶段会提取目标字段,然后在 DISTINCT 阶段过滤掉重复的行,分别得到中间的虚拟表 vt5-1 和 vt5-2。

排序与分页 (Order by & Limit / Offset)

当我们提取了想要的字段数据之后,就可以按照指定的字段进行排序,也就是 ORDER BY 阶段,得到虚拟表 vt6。最后在 vt6 的基础上,取出指定行的记录,也就是 LIMIT 阶段,得到最终的结果,对应的是虚拟表 vt7

详细执行步骤分析

Step 1:获取数据 (From, Join)
FROM citizen
JOIN city 

该过程的第一步是执行From子句中的语句,然后执行Join子句。这些操作的结果是得到两个表的笛卡尔积。

在FROM和JOIN执行结束之后,会按照JOIN的ON条件,筛选所需要的行

ON citizen.city_id = city.city_id
Step 2:过滤数据 (Where)

获得满足条件的行后,将传递给Where子句。这将使用条件表达式评估每一行。如果行的计算结果不为true,则会将其从集合中删除。

WHERE city.city_name != '上海'
Step 3:分组 (Group by)

下一步是执行Group by子句,它将具有相同值的行分为一组。此后,将按组对所有Select表达式进行评估,而不是按行进行评估。

GROUP BY city.city_name
Step 4:分组过滤 (Having)

对分组后的数据使用Having子句所包含的谓词进行过滤

HAVING COUNT(*) >= 2
Step 5:返回查询字段 (Select)

在此步骤中,处理器将评估查询结果将要打印的内容,以及是否有一些函数要对数据运行,例如Distinct,Max,Sqrt,Date,Lower等等。

本案例中,SELECT子句只会打印城市名称和其对应分组的count(*)值,并使用标识符“ City”作为city_name列的别名。

SELECT 
    city.city_name AS "City",
    COUNT(*) AS "citizen_cnt"
Step 6:排序与分页 (Order by & Limit / Offset)

查询的最后处理步骤涉及结果集的排序与输出大小。在我们的示例中,按照字母顺序升序排列,并输出两条数据结果。

ORDER BY city.city_name ASC
LIMIT 2

大事务

在这里插入图片描述

什么是大事务

运行时间比较长,操作的数据比较多的事务我们称之为大事务。

例如,执行超过5s,10s,1min…

大事务风险

  • 锁定太多的数据,造成大量的阻塞和锁超时,回滚所需要的时间比较长。

  • 执行时间长,容易造成主从延迟。

  • undo log膨胀

大事务一般会对数据库造成什么问题?

1.锁定数据过多,容易造成大量的死锁和锁超时

当系统中不同事务之间出现循环资源依赖,涉及的事务都在等待别的事务释放资源时,就会导致这几个事务都进入无限等待的状态,比如下面这个场景:

在这里插入图片描述

事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态

首先我们知道,有两种策略可以处理死锁:

  • 等待死锁超时。超时时间(innodb_lock_wait_timeout)默认是50s,这时间可以说真的是太长了,但是如果改小了吧,又可能会影响到本可以正常消除的死锁
  • 死锁检测。死锁检测的配置默认是开启的。死锁检测就是每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了死锁

但是死锁检测可能会存在一个问题:假如所有事务都要更新同一行的时候。每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n)的操作。假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是100万这个量级 的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。因此,你就会看到 CPU利用率很高,但是每秒却执行不了几个事务。

2.回滚记录占用大量存储空间,事务回滚时间长

在MySQL中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。假设一个值从1被按顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录。

在这里插入图片描述
当前值是4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的read-view。如图中看到的,在视图A、B、C里面,这一个记录的值分别是1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。

对于read-view A,要得到1,就必须将当前值依次执行图中所有的回滚操作得到。同时你会发现,即使现在有另外一个事务正在将4改成5,这个事务跟read-view A、B、C对应的 事务是不会冲突的。

你一定会问,回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的read-view的时候,换一种说法就是在这些事物提交之后。

3. 执行时间长,容易造成主从延迟

因为主库上必须等事务执行完成才会写入binlog,再传给备库。所以,如果一个主库上的语句执行10分钟,那这个事务很可能就会导致从库延迟10分钟

如何解决大事务带来的问题

1.基于两阶段锁协议

两阶段锁协议是什么?在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放

基于两阶段锁协议我们可以做这样的优化: 如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放

假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个 业务需要涉及到以下操作:

1、从顾客A账户余额中扣除电影票价;

2、给影院B的账户余额增加这张电影票价;

3、记录一条交易日志。

也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的 顺序呢?

试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。

所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额 这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。

2. 基于死锁检测

想要解决死锁检测的问题那么就只能控制O(n)的数量,当同一行并发数小的时候死锁检测的成本就会很低了

不过这个并发数还挺不好控制:

  • 分布式系统中客户端数量是不确定的,所以不能在客户端做限制
  • 在MySQL端做的话需要修改源码,这个就非大牛而不可为了
  • 参考JDK1.7的ConcurrentHashMap的分段锁设计,将一行数据改成逻辑上的多行数据来减少锁冲突 其中第三个方案还是有点意思的,接着以影院的账户为例,可以将一个账号信息放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗。

这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理。

3. 基于事务的隔离级别
我们知道MySQL的事务隔离级别默认是可重复读,在这个隔离级别下写数据的时候会有这些问题:

如果有索引(包括主键索引)的时候,以索引列为条件更新数据,会存在间隙锁、行锁、下一键锁的问题,从而锁住一些行

如果没有索引,更新数据时会锁住整张表

但是如果把隔离级别改为读提交就不存在这两个问题了,每次写数据只会锁一行

但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置 为row

其它解决方案

  • 一些只读的操作就没有必要开启事物了

  • 通过SETMAX_EXECUTION_TIME命令, 来控制每个语句执行的最长时间,避免单个语句意外执行太长时间 监控

  • information_schema.Innodb_trx表,设置长事务阈值,超过就报警/或者kill

  • 在业务功能测试阶段要求输出所有的general_log,分析日志行为提前发现问题

  • 设置innodb_undo_tablespaces值,将undo log分离到独立的表空间。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

  • 13
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值