对于分布式数据库来说,热点和事务冲突是两个需要避免的场景,在很多客户测试的案例中,经常出现热点引起的性能未达预期的情况。本文借近期遇到的几个客户场景,对热点问题在 TiDB 中的表现形式和影响,以及如何应对做一个记录。
何为热点
热点可以理解为热点数据,或者说热点 region,TiDB 自带的 grafana 监控指标中也有 hot region write / read 的 metrics。但是我的理解热点问题更准确的表现形式,其实是 某(几)个tikv 节点的 corprocessor / scheduler (负责读/写模块的线程)消耗资源过高,而剩下的 kv 节点资源白白闲置。对于分布式系统来说,优点突出但是可能存在木桶效应,某一个组件/服务器资源瓶颈,会影响到整个集群的表现。
判断热点现象也很简单,查看 tikv 监控页面的 thread CPU 监控项,如果发现 corprocessor 或 scheduler 中各 kv 实例的 CPU 消耗十分不均匀,那大概率就是碰到热点现象了。
产生热点的原因
产生热点现象的原因有多种,大致总结可分为以下:
1、部分小表例如配置表的频繁访问引起热点
2、MySQL 中自增主键的高并发写入
3、非自增,但时间相关的顺序插入
4、无主键,或主键为非 int 类型
5、时间相关字段的索引
6、业务逻辑/数据分布产生的热点读写
7、执行计划不合理引起的非必要全表/错误的索引扫描
如何规避
热点的解决思路有两种,一是加快单次处理的速度,二是将频繁请求的数据分散到不同的 region,然后通过 pd 调度或手工的方式,将 region 的 leader 调度到多个kv 实例中。
针对上面的情况,逐一分析。
第一种小表频繁访问的场景,因为数据量少,而 TiKV 默认的 region 大小为64M,基本上这些数据都会分在一个 region,如果业务高并发访问,势必会引起热点。这种主要是通过业务手段来规避,比较常见的做法是将配置表数据放到缓存中。从数据库角度优化,可以通过 pd-ctl 找到热点 region,确认对应的配置表后,可以手动将热点 region split 为多个,后续 pd 就可以通过调度算法将这几个不同的 region leader 调度到不同的 kv 节点,缓解热点情况。
第二~四种场景也是比较常见的,一般 MySQL 中都会建议采用 auto_increment 字段作为主键,或者有些业务例如订单系统虽然没有用自增主键,但是基于时间戳来生成一个业务 ID 作为主键,这种对于TiDB 来说跟自增的场景也比较类似。另外还有些时候,客户会选择非 int 类型的字段作为主键,例如手机号码存为 varchar 等。对于这种非 int 类型的主键,TiDB 内部会做一个转换,添加一个自增的 bigint 类型作为主键。所以这几个场景如果出现高并发的大量写入,目前2.0/2.1版本中,基本上单表 TPS 超过1W 就有可能会产生明显的热点效应了。如果想解决可以对表结构做一些改造,将原主键设为 Unique Key,这样 TiDB 内部会添加一个自增的伪列 _tidb_rowid。我们可以通过 alter table t shard_row_id_bits = 4; 的语句来将数据打散。此语法只对没有显示指定 PK 或 PK 为非整数类型时才有效,打散后插入效率可以大大提升,但是会带来一定的副作用就是进行范围 scan 的时候,打散前可能只需要扫一个 region,打散后可能需要扫多个 region。
第五种时间字段的索引,在目前2.0版本中,并没有太好的解决办法。未来版本中即将开放的 partition table 这个新的特性,对于这种场景会有一定的缓解。但是在范围分区表中,就不能以时间作为分区键了,可能需要找另外一个字段作为分区键,这样才能够将基于时间的顺序写入切分为多张表来操作以缓解热点情况。
但是这可能会有两个问题,一是这样就不能利用到时间范围分区的最大便利之一的快速归档功能,二是如果基于时间的范围查找,需要将所有分表都通过索引 scan 一遍再 union 之后返回结果。
其实可以考虑类似 Oracle 的组合分区功能,先按照时间范围 partition,在每个 partition 里再 hash partition 一下,这样基于时间的范围查找仍然能够定位到大的分区,大分区下面的所有 hash 子分区必须是要全部 scan 了。
第六种需要结合具体的业务场景来分析,例如某些交易系统中对公账户可能会成为热点账户,这时在业务侧进行拆分,将一个对公账户拆分为10个账户。业务访问热点账户时,可以随机选其中一个账户进行操作,这样可以有效避免热点情况的产生,但是统计的时候需要将所有账户进行归并。另外在对热点数据进行操作的时候,可以考虑在业务层进行排序/合并,降低对热点数据的访问频率。
对于第七种场景,就是上面所提到的要通过提升单次请求的效率来缓解热点问题,主要还是通过优化慢 SQL 的手段。
热点 Case 记录
case1:某平台业务压测写入瓶颈
该用户在 TiDB 上进行业务 PoC 测试,这是目前线上写入量最大的业务单元,峰值 TPS 接近4W,由于还未采用分库分表,单个 MySQL 实例几乎无法满足这么大的写入量,所以目前是通过 Hbase 的方式来做。
在测试 TiDB 的过程中,发现在3个 tikv 实例的集群中,写入量能达到4W,由于咱们是支持写扩展的,加到6个 tikv 实例,但是发现写入量还是只有4W。在另外一套12个 kv 节点的集群上进行测试,写入也只有4W,无法提升。虽然满足目前线上峰值的需求,但是写入的水平扩展能力并没有得到验证。
通过观察 grafana 监控,发现负责写入的 scheduler 线程有明显的热点情况。确认客户的表结构,是一个业务无关的自增 ID 作为主键, 通过上面shard 的方式打散表后测试写入,在6个 tikv 实例的时候写入能够达到7W,12个 tikv 实例的时候写入能够稳定在11W+,不仅写入能力大大超出客户预期,水平扩展能力也得到验证。
case2:某平台业务压测写入瓶颈
用户在 TiDB 上测试单张业务表(超过20亿数据)的写入性能,由于表结构较为复杂,平均长度达到了2kb,在使用自增 ID 的情况下,写入瓶颈在2.5W QPS。
使用上述方式修改表结构为 shard 模式后,发现仍然存在热点现象,后确认表结构存在两个时间字段的索引,上面提到索引也可能会造成热点。先去掉时间索引进行测试,发现 TPS 还是上不去,热点现象仍然存在。
跟用户确认具体的压测逻辑,业务代码自己维护了一张类似计数器的配置表,多线程并发写入数据的时候,每个线程每次取步长1000,而压测共采用了40个客户端模拟,并发量达到了2W,这样热点情况没有出现在业务表,反而出现在了负责计数器功能的配置表上。建议客户调整步长后,经过多次测试,在步长为500W 时,能够有效避免热点效应,QPS 能够达到10W。
这个 case 后面还发生了一些有意思的现象。
调整表结构为 shard,步长为500W后,虽然写入 QPS 能够达到10W,但是在稳定性测试时,发生过几分钟之后 QPS 会降到3W,热点现象又出现了。
通过分析,每次热点的现象都出现在固定的几个 kv 节点。在高强度的写入过程中region 需要不断的分裂并将 leader 调度到其他节点以平衡各 kv 节点的资源消耗。但是通过分析日志发现,每次调度 leader 的时候都会失败,失败的原因是其它副本的数据写入进度远低于该副本,于是无法调度成功。最后我们发现这几个 kv 节点的 SSD 磁盘写入性能要明显优于其它节点。通过调整集群拓扑,将写入性能差距太大的几台服务器调整为 tidb-server,热点情况消失。研发也提供了两个途径来应对这种场景,一是加快分裂的效率,二是优化切换 leader 时校验数据 gap 的策略。
测完写后,继续压测读请求,很不幸又遇到热点了。压测逻辑是在 ID 最大值和最小值之间随机取数发起读请求,按照常理推断,这种压测方式是不应该产生热点的。通过分析推断,造测试数据的时候可能出现了数据倾斜,通过 select count where id > and id < 的方式很容易得到验证。通过过滤筛选,发现在10亿至20亿之间的数据较多,修改测试代码,随机 ID 选在这个范围之内进行测试,发现热点情况消失,QPS 达到35W。
case3:某券商读业务响应慢
查看 grafana 监控,一共3个 tikv 实例,其中一个 kv 的 corprocessor 消耗 CPU 1000%+,另外两台 CPU 确一直闲置,又是一个明显的热点情况。
通过分析业务 SQL,业务表有一个基于 A+B+C 的联合主键,同时也有一个 A 的单列索引,SQL 中 where 条件是基于 A = ? and B = ? and C = ?,2.0.4版本的优化器可能存在一些缺陷,错误的选择了基于 A 的单列索引。
通过分析表发现,A 的数据分布倾斜十分严重,某些条件下需要扫描5W+ 数据,更合理的执行计划应该是选择A、B、C 的联合主键。通过 hint 方式临时规避这个问题,同时在2.0.6版本已经解决这个优化器的 BUG,建议客户升级到新版本后问题解决。