MySQL Binlog 技术原理和业务应用案例分析
导语
MySQL Binlog用于记录用户对数据库操作的结构化查询语言(Structured Query Language,SQL)语句信息。是MySQL数据库的二进制日志,可以使用mysqlbin命令查看二进制日志的内容。爱奇艺在会员订单系统使用到了 MySQL Binlog,用来实现订单事件驱动。在使用Binlog 后在简化系统设计的同时帮助系统提升了可用性和数据一致性。
本文将从实际应用角度出发理解 MySQL中的相关技术原理,从技术原理和工作实践相结合,帮助大家以及在相关设计中存在的潜在问题,希望能给大家有所帮助和启发,共同进步。
作者介绍:作者帆叔目前主要负责爱奇艺会员交易系统的技术和架构工作,专注异步编程、服务治理、代码重构等领域,热爱技术,乐于分享。
Binlog 是 MySQL 中一个很重要的日志,主要用于 MySQL 主从间的数据同步复制。正是因为 Binlog 的这项功用,它也被用于 MySQL 向其它类型数据库同步数据,以及业务流程的事件驱动设计。通过研究分析,我们发现使用 MySQL Binlog 实现事件驱动设计并没有想象中那么简单,所以接下来带大家了解 MySQL 的 Binlog、Redo Log、数据更新内部流程,并通过对这些技术原理的介绍,来分析对业务流程可能造成的问题,以及如何避免这些问题。希望通过本文的解析,能够帮助大家了解到 MySQL 的一些原理,从而帮助大家能够更顺利地使用 MySQL 这个流行的数据库技术。
基于 Binlog 的事件驱动
首先介绍一下会员订单系统的设计,订单系统直接向 MQ 发送消息,通过异步消息驱动后续业务流程,以实现消息驱动的设计。大致的业务流程示意图如下:
图1:直接发送消息的订单事件驱动
图2:基于 Binlog 的订单事件驱动
暗 藏 问 题
上文提到,虽然基于 Binlog 的订单事件驱动设计存在诸多优点,但后来发现其实暗藏问题。经过实验,我们发现偶尔会有订单履约延迟的现象。
在正常流程中,订单履约服务收到订单支付事件后,会检查订单状态,如果此时订单状态为已支付,则进行履约流程的处理。但对于有履约延迟的订单,订单履约服务收到此订单的支付事件后,查询数据库发现此订单并非支付状态。经过调查,我们排除了数据并发覆盖问题,并且订单状态查询是发生在主库上,也不存在主从同步延迟问题。
那究竟是什么原因导致业务系统收到根据 Binlog 生成的订单支付事件后,再查询主库得到的订单数据却是未支付状态的?
对于此问题的原因我们先放下不谈,先来看看 MySQL 在更新数据时的内部原理。
MySQL 数据更新相关原理
Redo Log | Binlog | |
日志类型 | 物理日志,即数据页中的真实二级制数据,恢复速度快 | 逻辑日志,SQL 语句 (statement) 或数据逻辑变化 (row),恢复速度慢 |
存储格式 | 基于 InnoDB 数据页格式进行存储 | SQL 语句或数据变化内容 |
用途 | 重做数据页 | 数据复制 |
层级 | InnoDB 存储引擎层 | MySQL Server 层 |
记录方式 | 循环写 | 追加写 |
>>>> 两阶段提交
如果 Redo Log 有 Commit 标识,说明 Redo Log 其实已经 Commit 成功。这时直接提交事务; 如果 Redo Log 没有 Commit 标识,则使用 XID(事务 ID)查询 Binlog 相应日志,并检查日志的完整。如果 Binlog 是完整的,则提交事务,否则回滚;
>>>> MySQL 数据更新流程
执行器先找引擎取 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。如果是新设计的系统,我认为这样做法比较理想。但对于已有系统,这种方式改动可能较大,是否采用需权衡成本和收益。
招聘信息
爱奇艺会员开发团队诚招 Java 资深工程师/技术专家。会员业务是爱奇艺核心业务之一,我们致力于通过技术手段服务核心业务,研发通用化、高可用的业务系统,同时我们也需要擅长如数据库、服务治理、MQ 等技术的人才。欢迎感兴趣的同学发送简历至:luodi@qiyi.com(邮件标题请注明:会员开发)