Skip to main content

第1章 冷热分离

本书讲的第一个场景是冷热分离。简单来说,就是将常用的"热"数据和不常使用的"冷"数据分开存储。本章要考虑的重点是锁的机制批量处理以及失败重试的数据一致性问题。这部分内容在实际开发中的"陷阱"还是不少的。

冷热分离概念

1.1 业务场景:几千万数据量的工单表如何快速优化

这次项目优化的是一个邮件客服系统。它是一个 SaaS(通过网络提供软件服务)系统,但是大客户只有两三家,最主要的客户是一家大型媒体集团。

这个系统的主要功能是:它会对接客户的邮件服务器,自动收取发到几个特定客服邮箱的邮件,每收到一封客服邮件,就自动生成一个工单。之后系统就会根据一些规则将工单分派给不同的客服专员处理。

这个系统是支持多租户的,每个租户使用自己的数据库(MySQL)。

这家媒体集团客户两年多产生了近 2000万 的工单,工单的操作记录近 1亿。平时客服在工单页面操作时,打开或者刷新工单列表需要 10秒钟 左右。

业务问题

项目组详细分析了当时的数据状况:

数据项数据量
工单表3000万条数据
工单处理记录表1.5亿条数据
每日新增工单10万条

当时系统性能已经严重影响了客服的处理效率,需要放在第一优先级解决,客户给的期限是 1周

在客户提出需求之前,项目组已经通过优化表结构、业务代码、索引、SQL语句等办法来提高系统响应速度,但这次只能尝试其他方案。

1.2 数据库分区,从学习到放弃

什么是数据库分区

分区并不是生成新的数据表,而是将表的数据均衡分配到不同的硬盘、系统或不同的服务器存储介质中,实际上还是一张表。

数据库分区有以下优点:

  1. 存储扩展:比起单个文件系统或硬盘,分区可以存储更多的数据
  2. 数据清理:在清理数据时,可以直接删除废弃数据所在的分区
  3. 查询优化:可以大幅度地优化特定的查询,让这些查询语句只去扫描特定分区的数据

为什么放弃数据库分区

通过跟客服的交流,项目组发现:

  • 一般工单被关闭以后,客服查询的概率就很低了
  • 对于那些关闭超过一个月的工单,基本上一年都打开不了几次

调研到这里,基本的思路是增加一个状态:归档。但相关开发人员并没有用过数据库分区功能,而当时只有 1 周的时间来解决问题,工单表是系统最核心的数据表,不能出问题。

因此,项目组放弃了数据库分区,并决定基于同样的分区理念,使用自己熟悉的技术来实现这个功能:

  • 新建一个冷数据库,将 1 个月前已经完结的工单数据都移动到这个新的数据库
  • 当前的数据库保留热数据,保留正常处理的较新的工单数据

1.3 冷热分离简介

1.3.1 什么是冷热分离

冷热分离就是在处理数据时将数据库分成冷库热库

  • 冷库:存放那些走到终态、不常使用的数据
  • 热库:存放还需要修改、经常使用的数据

1.3.2 什么情况下使用冷热分离

假设业务需求出现了以下情况,就可以考虑使用冷热分离的解决方案:

  1. 数据走到终态后只有读没有写的需求,比如订单完结状态
  2. 用户能接受新旧数据分开查询,比如有些电商网站默认只让查询 3 个月内的订单

1.4 冷热分离一期实现思路:冷热数据都用 MySQL

冷热分离一期有一个主导原则:热数据跟冷数据使用一样的存储(MySQL)和数据结构,这样工作量最少。

在冷热分离一期的实际操作过程中,需要考虑以下问题:

  1. 如何判断一个数据是冷数据还是热数据?
  2. 如何触发冷热数据分离?
  3. 如何实现冷热数据分离?
  4. 如何使用冷热数据?
  5. 历史数据如何迁移?

1.4.1 如何判断一个数据到底是冷数据还是热数据

一般而言,主要采用主表里一个字段或多个字段的组合作为区分标识:

维度示例
时间维度把 3 个月前的订单数据当作冷数据
状态维度将已完结的订单当作冷数据
组合字段下单时间小于 3 个月且状态为"已完结"的订单

关于判断冷热数据的逻辑,有两个要点必须说明:

  1. 如果一个数据被标识为冷数据,业务代码不会再对它进行写操作
  2. 不会同时存在读取冷、热数据的需求

1.4.2 如何触发冷热数据分离

一般来说,冷热数据分离的触发逻辑分为 3 种:

三种触发方式对比

1. 直接修改业务代码

每次修改数据时触发冷热分离(比如每次更新订单的状态时,就去触发这个逻辑)。

适用场景:业务代码比较简单,并且不按照时间区分冷热数据时使用。

2. 监听数据库变更日志(binlog)

另外创建一个服务,专门用来监控数据库的 binlog,一旦发现表有变动,就将变动的数据发送到队列,触发冷热分离逻辑。

适用场景:业务代码比较复杂,不能随意变更,并且不按时间区分冷热数据时使用。

3. 定时扫描数据库

通过 quartz 配置本地定时任务,或者通过类似于 xxl-job 的分布式调度平台配置定时任务。

适用场景:按照时间区分冷热数据时使用。

触发方式优点缺点
修改业务代码实时性高、直接明确代码侵入、维护成本高
监听 Binlog无代码侵入、通用性强复杂性高、需额外部署
定时扫描无需外部依赖、可靠稳定实时性差、可能数据延迟

1.4.3 如何分离冷热数据

分离冷热数据的基本逻辑如下:

  1. 判断数据是冷是热
  2. 将要分离的数据插入冷数据库中
  3. 从热数据库中删除分离的数据

冷热数据分离流程

这个逻辑看起来简单,而实际做方案时,以下 3 点都要考虑在内:

1. 一致性:如何保证数据的一致性?

解决方案为保证每一步都可以重试且操作都有幂等性,具体逻辑分为 4 步:

  1. 标记待迁移数据:在热数据库中给需要迁移的数据加标识 ColdFlag=WaittingForMove
  2. 查询待迁移数据:找出所有待迁移的数据
  3. 插入冷数据库:在冷数据库中保存一份数据,保存逻辑中需要加判断来保证幂等性
  4. 删除热数据:从热数据库中删除对应的数据

2. 数据量:是否需要使用批量处理?

如果采用定时扫描的逻辑,就需要考虑数据量问题。实现逻辑:

  1. 在热数据库中给需要的数据添加标识
  2. 找出前 1000 条待迁移的数据
  3. 在冷数据库中保存一份数据
  4. 从热数据库中删除对应的数据
  5. 循环执行 2~4

3. 并发性:多线程处理时的锁机制

当采用多线程同时迁移冷热数据时,需要考虑:

获取锁的原子性

使用一条 Update...Where... 语句,利用 MySQL 的更新锁机制来实现原子性:

Update Set LockThread=当前线程ID, LockTime=当前时间
Where LockThread为空 Or LockTime<N秒

锁超时处理

给锁设置一个合理的超时时间,如果锁超时了还未释放,其他线程可正常处理该数据。

幂等性保证

使用 MySQL 的 Insert...On Duplicate Key Update 语句实现幂等操作。

1.4.4 如何使用冷热数据

在功能设计的查询界面上,一般都会有一个选项用来选择需要查询冷数据还是热数据。

注意

在判断是冷数据还是热数据时,必须确保用户没有同时读取冷热数据的需求。

1.4.5 历史数据如何迁移

因为前面的分离逻辑在考虑失败重试的场景时刚好覆盖了这个问题,所以解决方案很简单:只需要批量给所有符合冷数据条件的历史数据加上标识 ColdFlag=WaittingForMove,程序就会自动迁移了。

1.4.6 整体方案

整体解决方案包括 5 个部分:

  1. 冷热数据判断逻辑:根据业务规则判断数据冷热状态
  2. 冷热数据的触发逻辑:定时扫描触发
  3. 冷热数据分离实现思路:批量处理 + 幂等性保证
  4. 冷热数据库使用:界面提供选择
  5. 历史数据迁移:标记后自动迁移

这个项目的完成花费了 10 天,上线以后,在客服的常规工单处理页面中,查询基本可以在 1 秒左右完成,大大提升了客服的工作效率。

1.5 冷热分离二期实现思路:冷数据存放到 HBase

1.5.1 冷热分离一期解决方案的不足

冷热分离一期的解决方案虽然能解决写操作慢和热数据慢的问题,但仍然存在诸多不足:

  1. 用户查询冷数据的速度依旧很慢
  2. 冷数据库偶尔会告警

归档的数据库里面,工单表仍然有 3000 多万的工单数据,工单处理记录表仍然有数亿的数据。

1.5.2 归档工单的使用场景

对于归档的工单,基本只有以下几个查询动作:

  1. 根据客户的邮箱查询归档工单
  2. 根据工单 ID 查出该工单所有的处理记录

这些操作转化成技术需求:

  1. 可以存放上亿甚至数亿的数据
  2. 支持简单的组合关键字查询,查询慢一些可以接受
  3. 存放的数据不再需要变更

最后,项目组决定使用 HBase 来保存归档工单。

1.5.3 HBase 原理介绍

HBase 结构

HBase 的基本数据结构

HBase 里面有这些概念:Table、Row、Column、ColumnFamily、ColumnQualifier、Cell、TimeStamp。

  • ColumnFamily(列簇):一开始就要定义好,类似于关系型数据库里面的列
  • ColumnQualifier(列限定符):每个 ColumnFamily 可以灵活增加,不需要在创建表时定义
  • Cell:每一个 RowKey、TimeStamp 以及 Key-Value 值
列簇存储特性

虽然不同的 ColumnFamily 属于同一个表,但它们物理上是分开存储的。这也是 HBase 的一张表可以存放上百亿数据的原因。但也是因为这个特性,HBase 基本实现不了复杂的查询。

HBase 的物理存储模型

  • Region:一个表会被水平切割成多个 Region,每个 Region 会包含多个 Row
  • RegionServer:每个 RegionServer 是一个服务器节点,会包含多个 Region(大概 1000 个)
  • MemStore:一个 Region 会包含多个 MemStore,每个 MemStore 存储一个 ColumnFamily
  • HFile:一个 MemStore 会把数据写入多个 HFile

HBase 的写操作流程

  1. 客户端访问 ZooKeeper,读取元数据
  2. 根据 namespace、表名、RowKey 找到数据对应的 Region
  3. 访问 Region 对应的 RegionServer
  4. 写入 WAL(Write Ahead Log)
  5. 写入 MemStore
  6. 通知客户端写入完成

HBase 的读操作流程

  1. 客户端访问 ZooKeeper,读取元数据
  2. 根据 namespace、表名、RowKey 找到数据对应的 Region
  3. 访问 Region 对应的 RegionServer
  4. 查找对应的 Region
  5. 查询 MemStore
  6. 找到 BlockCache(读缓存)
  7. 如果没有找到所有的 Cell,则会到多个 HFile 中去查找

1.5.4 HBase 的表结构设计

项目组从 HBase 的说明文档中得出了以下设计要点:

  1. RowKey 设计[MD5(customeremail)][ticketID],前面的邮箱名长度是 16 字节,后面的工单 ID 是固定长度
  2. ColumnFamily:只使用一个 i(HBase 推荐短列名,省空间)
  3. 工单处理记录:将每个工单下面的处理记录全部序列化成一组 JSON 数据,保存在一个 ColumnKey 中

1.5.5 二期的代码改造

二期和一期的主要区别就是冷数据库使用了 HBase,主要的代码逻辑有一个变化:事务处理

一期的批量逻辑

  1. 取出 300 条工单
  2. 通过单事务包围的 BATCH SQL 语句插入冷数据库
  3. 通过单事务包围的 BATCH SQL 语句从热数据库中删除数据

二期的批量逻辑(HBase 不支持类似事务):

  1. 取出 50 条工单,先处理第一个工单
  2. 将当前工单的各个 ColumnKey 值插入 HBase
  3. 通过单事务包围的 SQL 语句删除热数据库中该工单对应的数据
  4. 循环执行第 2 步和第 3 步

二期花费了 3 周左右才上线,之后查询归档工单的性能好了很多,特别是单个归档工单的打开操作响应快了不少。

1.6 小结

整体方案总结

冷热分离方案只是刚好适用于这个场景,它有一些明显的不足。如果碰到下面的任何一个场景,这个方案就不适用了:

  1. 数据没有"归档"这一特点,经常需要修改
  2. 所有数据都需要支持复杂的查询,并且需要非常快的响应速度
  3. 需要实时地对数据进行各种统计

如果碰到以上场景,又该用什么方案来解决?请看下一章:查询分离