背景
Binlog 是 MySQL 中一个很重要的日志,主要用于 MySQL 主从间的数据同步复制。正是因为 Binlog 的这项功用,它也被用于 MySQL 向其它类型数据库同步数据,以及业务流程的事件驱动设计。通过研究分析,我们发现使用 MySQL Binlog 实现事件驱动设计并没有想象中那么简单,所以接下来带大家了解 MySQL 的 Binlog、Redo Log、数据更新内部流程,并通过对这些技术原理的介绍,来分析对业务流程可能造成的问题,以及如何避免这些问题。希望通过本文的解析,能够帮助大家了解到 MySQL 的一些原理,从而帮助大家能够更顺利地使用 MySQL 这个流行的数据库技术。
基于 Binlog 的事件驱动
首先介绍一下会员订单系统的设计,订单系统直接向 MQ 发送消息,通过异步消息驱动后续业务流程,以实现消息驱动的设计。大致的业务流程示意图如下:
图 1:直接发送消息的订单事件驱动
这种设计需要保证数据库操作和消息操作的数据一致性,即数据保存和消息发送要全部成功或者全部失败。显然在数据保存前和事务中进行消息发送都是不合适的。我们是在数据更新操作后,数据库事务外发送消息。如果数据保存成功,但消息发送失败,支付系统需要重新通知(上图步骤 1),直至通知成功。这种设计虽然实现了功能和对可用性的基本要求,但存在如下缺点:
业务系统直接依赖消息中间件 :
消息中间件的故障,不仅会影响支付通知的处理也可能影响业务系统上的其它接口。
业务系统必须实现可靠的重试 :
不论是请求发起方还是请求接收方都必须实现可靠重试才能实现最大努力通知的目标。
重试间隔增大会造成业务延迟 :
随着重试次数增加,每次重试的间隔通常也越来越大,这成为 Exponential Backoff(指数级退避)。这种设计能够让请求接收方的故障处理更加从容,避免因密集重试造成请求接收方服务难以恢复。但这样做可能会使请求接收方在恢复服务之后很长时间后才处理完积压的消息,从而造成业务延迟。我们可以采用类似 Hystrix 的自适应设计,在请求接收方服务恢复后回到到正常的请求速率。但这样的设计显然会复杂许多。
为了解决上述问题,简化技术架构,我们采用事件表的设计思想,将订单表作为事件表。通过订阅订单表的 Binlog,生成订单事件,驱动后续业务流程。在系统架构上,业务系统不用直接依赖消息中间件,只需专注数据库操作。而通过引入一个接收 Binlog 的独立的系统,将 MySQL 数据变化转换成业务事件驱动后续流程。具体流程如下:
图 2:基于 Binlog 的订单事件驱动
暗藏问题
上文提到,虽然基于 Binlog 的订单事件驱动设计存在诸多优点,但后来发现其实暗藏问题。经过实验,我们发现偶尔会有订单履约延迟的现象。
在正常流程中,订单履约服务收到订单支付事件后,会检查订单状态,如果此时订单状态为已支付,则进行履约流程的处理。但对于有履约延迟的订单,订单履约服务收到此订单的支付事件后,查询数据库发现此订单并非支付状态。经过调查,我们排除了数据并发覆盖问题,并且订单状态查询是发生在主库上,也不存在主从同步延迟问题。
那究竟是什么原因导致业务系统收到根据 Binlog 生成的订单支付事件后,再查询主库得到的订单数据却是未支付状态的?
对于此问题的原因我们先放下不谈,先来看看 MySQL 在更新数据时的内部原理。
MySQL 数据更新相关原理
本节将向大家介绍 MySQL 数据更新相关原理,以及在这一过程中最重要的两种日志:Redo Log 和 Binlog。
Redo Log 和 Binlog
先来介绍 Redo Log 和 Binary Log(Binlog):
Redo Log :Redo Log 是 InnoDB 存储引擎提供的一种物理日志结构,用来描述对底层数据页操作的具体内容,主要用于实现 crash-safe,并提升磁盘操作效率。
Binlog :Binlog 是 MySQL 本身提供的一种逻辑日志,和具体存储引擎无关,描述的是数据库所执行的 SQL 语句或数据变更情况,主要用于数据复制。
InnoDB 引入 Redo Log 的目的在于实现 crash-safe 和提升数据更新效率。如果 InnoDB 每次数据写操作都要直接持久化到磁盘上的数据页中,那样会大量增加磁盘随机 IO 次数。引入 Redo Log 后,在对数据写操作时,会将部分随机 IO 写变为顺序写。因为磁盘的顺序 IO 效率远高于随机 IO,因此引入 Redo Log 机制有助于提升更新数据时的性能(如何实现 crash-safe 将在下一节介绍)。
下面的表格说明了两种日志的作用和它们的不同:
Redo LogBinlog
日志类型物理日志,即数据页中的真实二级制数据,恢复速度快逻辑日志,SQL 语句 (statement) 或数据逻辑变化 (row),恢复速度慢
存储格式基于 InnoDB 数据页格式进行存储SQL 语句或数据变化内容
用途重做数据页数据复制
层级InnoDB 存储引擎层MySQL Server 层
记录方式循环写追加写
这时问题来了,现在 MySQL 中存在了两种日志结构:Redo Log 和 Binlog。虽然它们的结构和功能有所不同,但却记录着相同的数据。如何保证这两种日志数据的一致性,以及如何实现 crash-safe 呢?这就引出了两阶段提交设计。
两阶段提交
两阶段提交不是 Redo Log 或 InnoDB 中的设计,而是 MySQL 服务器的设计(但通常说到两阶段提交时都和 Redo Log 放在一起)。因为 MySQL 采用插件化的存储引擎设计,事务提交时,服务器本身和存储引擎都需要提交数据。所以从 MySQL 服务器角度看,其本身就面临着分布式事务问题。
为解决此问题,MySQL 引入了两阶段提交。在两阶段提交过程中,Redo Log 会有两次操作:Prepare 和 Commit。而 Binlog 写操作则夹在 Redo Log 的 Prepare 和 Commit 操作之间。我们可以设想一下不同失败场景下两阶段提交的设计是如何保证数据一致的:
Redo Log Prepare 成功,在写 Binlog 前崩溃:在故障恢复后事务就会回滚。这样 Redo Log 和 Binlog 的内容还是一致的。这种情况比较简单,比较复杂的是下一种情况,即在写 Binlog 和 Redo Log Commit 中间崩溃时,MySQL 是如何处理的?
在写 Binlog 之后,但 Redo Log 还没有 Commit 之前崩溃
如果 Redo Log 有 Commit 标识,说明 Redo Log 其实已经 Commit 成功。这时直接提交事务;
如果 Redo Log 没有 Commit 标识,则使用 XID(事务 ID)查询 Binlog 相应日志,并检查日志的完整。如果 Binlog 是完整的,则提交事务,否则回滚;
如何判断 Binlog 是否完整?简单来说 Statement 格式的 Binlog 最后有 Commit,或 Row 格式的 Binlog 有 XID Event,那 Binlog 就是完整的。
MySQL 数据更新流程
接下来看一下 MySQL 执行器和 InnoDB 存储引擎在执行简单 update 语句 update t set n = n + 1 where id = 2 时的流程(因为此例只执行单条更新语句,所以其自身就是一个事务)。
执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
引擎将这行新数据更新到内存中。然后将对内存数据页的更新内容记录在 Redo Log Buffer 中(这里不详细介绍 Redo Log Buffer。只需知道对 Redo Log 的操作并不会直接写在文件上,而是先记录在内存中,然后在特定时刻才会写入磁盘)。此时完成了数据更新操作。
接下来要进行事务提交的操作。事务提交时,Redo Log 被标记为 Prepare 状态。通常此时,Redo Log 会从 Buffer 写入磁盘(innodb_flush_log_at_trx_commit,值为 1 时,每次提交事务 Redo Log 都会写入磁盘)。然后 InnoDB 告知执行器执行完成,可以提交事务。
执行器生成本次操作的 Binlog,并把 Binlog 写入磁盘。
执行器调用引擎的提交事务接口,引擎把刚刚写入的 Redo Log 改成提交 Commit 状态,更新完成。
图中描述了 update 语句执行过程中 MySQL 执行器、InnoDB,以及 Binlog、Redo Log 交互过程(图中深绿底色的是 MySQL 执行器负责的阶段,浅绿底色是 InnoDB 负责的阶段)
问题解析
从上面对 MySQL 原理的介绍我们得知,写 Binlog 发生在事务提交阶段,但是 MySQL 因为在 Server 层和存储引擎层都引入了不同的日志结构,从而引入了两阶段提交。Binlog 的写入发生在存储引擎真正提交事务之前,这导致理论上通过 Binlog 同步数据的系统(MySQL 从库、其它数据库或业务系统)有可能早于 MySQL 主库使最新提交的数据生效。
所以上面提到的订单履约服务在收到基于 Binlog 的订单支付事件后却查到相应订单是未支付的,原因很可能是订单履约服务在查询数据时,订单支付数据更新操作在 MySQL 内部尚未彻底完成事务的提交。
我们通过开发验证程序重现了这一现象。验证程序接收到事务提交完成后的完整 Binlog 时会再次在 MySQL 主库上查询对应的记录,结果会有一定概览获得事务提交前的数据。
另外经过了解,也有同行反映遇到过从库早于主库看到数据提交的问题。
问题的解决方法
在了解问题背后的原因之后,我们需要思考如何解决此问题。目前解决此问题有两个方法:重试和直接使用 Binlog 数据。
重试这种做法简单粗暴,既然问题原因是 Binlog 早于事务提交,那等一下再重试查询自然就解决了。但在实践中,需要考虑重试的实现方法、以及是否会因为重试过多甚至无限重试导致服务异常。对于重试的实现,可使用的方法有线程 Sleep 大法和消息重投等方式。线程 Sleep 大法通常是不被推荐的,因为它会导致线程利用率降低,甚至导致服务无法响应。但考虑到本次问题出现概率较低,我们认为线程 Sleep 大法是可以使用的,并且此方式简单易行,可用于问题的快速修复。
第二种重试方式是消息重投,比如 RocketMQ 中 Consumer 返回 ConsumeConcurrentlyStatus.RECONSUME_LATER 即可触发消息重投。但这种重试方法成本较前一种方法高,另外重试间隔也相对较大,对时间敏感的业务影响也较大,因此是否采用此方法需从业务和技术两个角度综合考虑。
除了考虑用何种方式重试,还要考虑 ABA 问题,即状态变化按照 A->B->A 的方式进行。业务系统期待的状态是 B,但实际可能没办法再变成 B 了。因此在用重试解决此问题之前,需要先排除业务系统存在 ABA 问题的可能。对于状态 ABA 问题,可用状态机等方式解决,这里不再展开讨论。
除了重试,另一种方法就是直接使用 Binlog。因为 Binlog (row 格式) 直接反映了数据的变化情况,其中可以记录事务提交涉及到的完整数据,因此可直接用作业务处理。这样还可以降低数据库 QPS。如果是新设计的系统,我认为这样做法比较理想。但对于已有系统,这种方式改动可能较大,是否采用需权衡成本和收益。
网友评论