图片
当前数据库仍为单机部署,根据一些云厂商的基准测试结果,在4核8GB的机器上运行MySQL 5.7时,大概可以支撑500TPS和10000QPS。运营负责人表示正在准备双十一活动,并且公司层面会继续加大在全渠道的推广投入,这无疑会引发查询量的大幅增加。今天我们将讨论如何通过主从分离来解决查询请求量激增的问题。
主从读写分离
大部分系统的访问模型是读多写少,读写请求量的差距通常可以达到几个数量级。这一点很容易理解,比如刷朋友圈的请求量肯定比发朋友圈的要大,淘宝商品的浏览量通常也远大于下单量。因此,我们的首要任务是让数据库能够应对更高的查询请求。为了实现这一点,首先需要将读写流量区分开来,因为只有这样,才能对读流量进行单独扩展,这就是我们所说的主从读写分离。
其实,这本质上是一个流量分离的问题,就像道路交通管制一样,我们将一个四车道的大马路划出三个车道供领导外宾使用,剩下一个车道供普通车辆行驶,优先保证领导先行,原理类似。主从读写分离是一个常规的做法,在面对数据库突发读流量时也是一种有效的应对策略。在我目前的项目中,曾出现过前端流量激增导致从库负载过高的情况,这时DBA同事会优先做从库扩容,分担读流量,将负载分散到多个从库上,减轻了从库的压力,接下来研发团队则会考虑其他方案来进一步优化数据库层的流量处理。
主从读写的两个技术关键点
一般来说,在主从读写分离机制中,我们将一个数据库的数据拷贝为一份或多份,并将其写入到其他数据库服务器中。原始数据库被称为主库,主要负责数据的写入;而拷贝的目标数据库称为从库,主要负责支持数据查询。
可以看到,主从读写分离有两个技术上的关键点:
- 数据的拷贝,也就是主从复制。
- 如何屏蔽主从分离带来的访问数据库方式的变化,使得开发人员在使用时,感觉像是在操作单一数据库一样。
1. 主从复制
我先以 MySQL 为例介绍一下主从复制。
MySQL 的主从复制依赖于 binlog,即将 MySQL 上的所有变化记录下来,并以二进制形式保存在磁盘上的二进制日志文件中。主从复制的过程是将 binlog 中的变化从主库传输到从库,通常这个过程是异步的,也就是说,主库上的操作不会等待 binlog 同步完成。
主从复制的具体过程如下:首先,从库在连接到主库时,会创建一个 IO 线程,用于请求主库更新的 binlog,并将接收到的 binlog 内容写入到一个名为 relay log 的日志文件中;与此同时,主库会创建一个 log dump 线程,负责将 binlog 发送给从库。然后,从库还会创建一个 SQL 线程,读取 relay log 中的内容,并在从库中进行回放,最终实现主从一致性。
这种主从复制方式是比较常见的。在此方案中,使用独立的 log dump 线程以异步的方式进行数据传输,可以避免影响主库的主体更新流程。并且从库接收到信息后,并不是直接写入从库的存储,而是写入 relay log,这样可以避免直接写入存储带来的性能开销,从而避免主从延迟过长。
图片
你会发现,基于性能考虑,主库的写入流程并不会等待主从同步完成后才返回结果。因此,在极端情况下,比如主库上的 binlog 还没有来得及刷新到磁盘上,就发生磁盘损坏或机器掉电的情况,就可能导致 binlog 丢失,从而造成主从数据的不一致。不过,这种情况出现的概率较低,对于大多数互联网项目来说是可以容忍的。
在实现主从复制后,我们可以在写入时只操作主库,而在读数据时只读取从库。这样,即使写请求会锁表或锁记录,也不会影响读请求的执行。同时,在读流量较大的情况下,可以部署多个从库共同承担读流量,这就是所谓的“一主多从”部署方式。比如在你的垂直电商项目中,可以通过这种方式来应对较高的并发读流量。此外,从库还可以作为备库使用,避免主库故障导致数据丢失。
那么,你可能会问,是否可以通过无限增加从库的数量来抵抗大量的并发请求呢?实际上,并不是这样。随着从库数量的增加,连接到每个从库的 IO 线程也会增多,主库需要创建更多的 log dump 线程来处理这些复制请求,导致主库的资源消耗增加。而且,由于受限于主库的网络带宽,实际上一个主库最多只能连接 3~5 个从库。
解决这个问题的思路有很多,核心思想就是尽量避免从库查询数据。以刚才的例子为基础,我有三种解决方案:
第一种方案:数据冗余
你可以在发送消息队列时,不仅仅发送微博 ID,而是将队列处理机需要的所有微博信息一并发送。这样就避免了从数据库中重新查询数据。
第二种方案:使用缓存
在同步写入数据库的同时,将微博数据也写入到 Memcached 等缓存中。这样队列处理机在获取微博信息时,会优先查询缓存,从而确保数据的一致性。
第三种方案:查询主库
队列处理机可以选择查询主库,而不是从库。不过,这种方式需要谨慎使用,必须确保查询量级不会过大,能够在主库的承受范围内,否则可能对主库造成过大的压力。
在这三种方案中,我通常会优先考虑第一种方案,因为它相对简单,虽然可能会导致单条消息较大,增加消息发送的带宽和时间,但其简洁性和可控性较高。缓存方案适合用于新增数据的场景,但在更新数据时,可能会引发数据不一致的问题。例如,如果两个线程同时更新缓存,可能会导致缓存中的数据与数据库中的数据不一致。查询主库的方案,我会尽量避免使用,除非没有其他选择。原因是如果为队列处理机提供查询主库的接口,很难保证团队中的其他成员不会滥用该接口,导致主库承受过多的读请求,最终影响系统的稳定性。
因此,选择哪种方案,还是要根据实际的项目需求和系统架构来决定
另外,主从同步的延迟,是我们排查问题时很容易忽略的一个问题。有时候我们遇到从数据库中获取不到信息的诡异问题时,会纠结于代码中是否有一些逻辑会把之前写入的内容删除,但是你又会发现,过了一段时间再去查询时又可以读到数据了,这基本上就是主从延迟在作怪。所以,一般我们会把从库落后的时间作为一个重点的数据库指标做监控和报警,正常的时间是在毫秒级别,一旦落后的时间达到了秒级别就需要告警了。
2. 如何访问数据库
我们已经通过主从复制技术将数据复制到多个节点,并实现了数据库的读写分离。此时,数据库的使用方式发生了变化:过去只需要使用一个数据库地址,现在需要配置主库地址和多个从库地址,同时区分写入操作和查询操作。如果再结合“分库分表”的技术,复杂度会进一步增加。为了降低实现的复杂度,业界涌现了很多数据库中间件来解决数据库的访问问题,这些中间件大致可以分为两类。
第一类:内嵌式数据库中间件
这一类中间件以淘宝的 TDDL(Taobao Distributed Data Layer)为代表,它以代码形式内嵌在应用程序内部。可以把它看作是一种数据源代理,配置管理多个数据源,每个数据源对应一个数据库,可能是主库,也可能是从库。
当有数据库请求时,中间件将 SQL 语句发给某个指定的数据源处理,并返回结果。这类中间件的优点是简单易用,没有额外的部署成本,因为它直接植入到应用程序内部,与应用程序一起运行,适合运维能力较弱的小团队使用。缺点是缺乏多语言支持,目前主流的方案如 TDDL 和早期的网易 DDB 都是基于 Java 开发的,无法支持其他语言。此外,版本升级依赖使用方更新,管理起来较为困难。
第二类:独立部署的代理层中间件
这一类中间件包括早期阿里巴巴开源的 Cobar、基于 Cobar 开发的 Mycat、360 开源的 Atlas、美团开源的 DBProxy 等等。这些中间件部署在独立的服务器上,业务代码像使用单一数据库一样使用它,但它内部管理着多个数据源。当有数据库请求时,代理层会对 SQL 语句进行必要的改写,并将其发送到指定的数据源。这类中间件使用标准的 MySQL 通信协议,因此能很好地支持多语言。而且,因为它是独立部署的,升级和维护也较为方便,适合有一定运维能力的大中型团队使用。它的缺点是所有 SQL 请求都需要跨越两次网络:从应用到代理层,再从代理层到数据源,因此在性能上会有所损耗。
图片
这些中间件对你来说可能并不陌生,但我想强调的是,在使用任何中间件时,一定要对它有足够深入的了解。否则,一旦遇到问题,无法快速解决的话,后果可能会很严重。举个例子,我之前有一个项目中,团队一直使用自研组件来实现分库分表,后来发现这套组件偶尔会产生多余的数据库连接。于是,团队讨论后决定将其替换为 Sharding-JDBC。我们原本以为这只是一次简单的组件切换,结果上线后遇到了两个问题:一是因为使用方式不当,偶尔会出现分库分表不生效的情况,导致扫描所有库表;二是偶尔出现查询延时达到秒级别。由于当时对 Sharding-JDBC 的了解不够深入,这两个问题没能很快解决。最后,我们只得切回原来的组件,待找到问题后再进行切换。