众所周知,在分布式数据库中,除了本身的基础性能外,最重要的就是充分利用所有节点能力,避免让单个节点成为瓶颈。但随着业务场景的复杂性,各节点上的数据读写访问热度,总是无法保证均衡的,热点现象就此产生。严重的热点问题,会导致单个节点成为资源瓶颈,进而影响整个系统的吞吐能力。如果不能很好的解决热点问题,数据库的水平扩展能力及稳定性也就无法得到保障。TiDB 作为一个分布式数据库,虽然会自动且动态的进行数据的重新分布以到达尽可能的均衡,但是有时候由于业务特性或者业务负载的突变,仍然会产生热点,这时候往往就会出现性能瓶颈。
TiDB 是一个分布式的数据库,在表结构设计的时候需要考虑的事情和传统的单机数据库有所区别,需要开发者能够带着「这个表的数据会分散在不同的机器上」这个前提,才能做更好的设计。
初步怀疑集群存在热点问题时,可以通过 TiDB 3.0 Grafana 提供的 TiKV-Trouble-Shooting 的 Dashboard 的 Hot Read 和 Hot Write 面板来快速确认是否存在读热点或者写热点。
Hot Read 面板聚集了读取热点相关的核心指标:
Hot Write 面板聚集了写热点相关的核心指标:
通过观察 Hot Read 和 Hot Write 面板中是否存在个别 TiKV 节点的指标明显高于其他节点,可以快速判断集群是否存在读热点或者写热点。
确定是个别 TiKV 实例的热点问题后,需要进一步确认是哪张表的热点,是否是索引热点,是读还是写。从 TiDB 3.0 开始,推荐在热点现场通过 SQL 查询 information_schema.TIDB_HOT_REGIONS 表定位热点表/索引:
-- TYPE 用于过滤热点的类型,read 表示读热点,write 表示写热点
SQL> select * from information_schema.TIDB_HOT_REGIONS where type = 'read'\G
*************************** 1. row ***************************
TABLE_ID: 21
INDEX_ID: NULL
DB_NAME: mysql
TABLE_NAME: stats_histograms
INDEX_NAME: NULL
REGION_ID: 44
TYPE: read
MAX_HOT_DEGREE: 17
REGION_COUNT: 0
FLOW_BYTES: 248548
1 row in set (0.02 sec)
或者通过 pd-ctl 用于查询读、写流量最大的 Region:
$ pd-ctl -u http://{pd}:2379 -d region topread [limit]
$ pd-ctl -u http://{pd}:2379 -d region topwrite [limit]
上面的命令中,输出信息中最核心的内容就是 region_id:
$ pd-ctl -u http://{pd}:2379 -d region topread 1
{
"as_peer": null,
"as_leader": {
"1": {
"total_flow_bytes": 248535,
"regions_count": 1,
"statistics": [
{
"region_id": 44,
"flow_bytes": 248548,
"hot_degree": 15, -- 每分钟统计 1 次,如果是热点,degree+1
"last_update_time": "2020-03-07T16:14:51.186168306+08:00",
"AntiCount": 1,
"Version": 11,
"Stats": null
}
]
}
}
}
再从 Region ID 定位到表或索引:
$ curl http://{TiDBIP}:10080/regions/{RegionId}
{
"region_id": 44,
"start_key": "dIAAAAAAAAAV",
"end_key": "dIAAAAAAAAAX",
"frames": [
{
"db_name": "mysql",
"table_name": "stats_histograms",
"table_id": 21,
"is_record": false,
"index_name": "tbl",
"index_id": 1
},
{
"db_name": "mysql",
"table_name": "stats_histograms",
"table_id": 21,
"is_record": true
}
]
}
最后根据热点表表结构和业务沟通改造方案。如果热点现场已过,可以通过业务监控提取问题表和问题 SQL。未来 TiDB 4.0 即将提供的 Key Visualizer 功能,直观展示整个数据库的不同位置数据访问频度和流量,快速定位热点,具体可参考本书的”识别集群热点和业务模式”章节。
TiDB 读取热点产生的原因通常是 TiKV 的 BlockCache 命中率下降或者是小表的并发读取过大。检查 TiKV-Details Dashboard 里 RocksDB KV 面板里的 Block cache hit 命中率,如果发现命中率出现大幅度下降或抖动,基本可以定位为慢 SQL 问题;否则倾向于怀疑是小表的大量并发读取导致的。
BlockCache 命中率下降 BlockCache 的命中率下降或者抖动时可能是存在全表扫描的 SQL、执行计划选择不正确的 SQL、存在大量 count(*) 操作的 SQL 等等。可以参照本书 “快速定位慢 SQL” 的章节来定位,通过添加索引或者优化 SQL 语句执行计划、增加 SQL_NO_CACHE 的 Hints 等手段来进行优化。
Region 并发读取过高 有些表数据量不大、包含的 Region 数量较少,但业务查询频繁的命中个别 Region 最终导致单个 TiKV 节点性能达到极限。目前可以通过改造小表为 hash 分区表来保证数据均匀地分散到一定数量的分区来解决。从 SHOW TABLE REGIONS 命令可以观察到新建的 hash 分区表已经提前创建了4个分区:
SQL> CREATE TABLE t1(
-> id INT NOT NULL,
-> name VARCHAR(30),
-> hired DATE NOT NULL DEFAULT '1970-01-01',
-> separated DATE NOT NULL DEFAULT '9999-12-31',
-> store_id INT
-> )
-> PARTITION BY HASH(store_id)
-> PARTITIONS 4;
Query OK, 0 rows affected (1.29 sec)
SQL> show table t1 regions\G
*************************** 1. row ***************************
REGION_ID: 840
START_KEY: t_194_
END_KEY: t_195_
LEADER_ID: 843
LEADER_STORE_ID: 9
PEERS: 841, 842, 843
SCATTERING: 0
WRITTEN_BYTES: 35
READ_BYTES: 0
APPROXIMATE_SIZE(MB): 1
APPROXIMATE_KEYS: 0
*************************** 2. row ***************************
REGION_ID: 844
START_KEY: t_195_
END_KEY: t_196_
LEADER_ID: 847
LEADER_STORE_ID: 9
PEERS: 845, 846, 847
SCATTERING: 0
WRITTEN_BYTES: 35
READ_BYTES: 0
APPROXIMATE_SIZE(MB): 1
APPROXIMATE_KEYS: 0
*************************** 3. row ***************************
REGION_ID: 848
START_KEY: t_196_
END_KEY: t_197_
LEADER_ID: 851
LEADER_STORE_ID: 9
PEERS: 849, 850, 851
SCATTERING: 0
WRITTEN_BYTES: 35
READ_BYTES: 0
APPROXIMATE_SIZE(MB): 1
APPROXIMATE_KEYS: 0
*************************** 4. row ***************************
REGION_ID: 3
START_KEY: t_197_
END_KEY:
LEADER_ID: 475
LEADER_STORE_ID: 9
PEERS: 458, 471, 475
SCATTERING: 0
WRITTEN_BYTES: 217
READ_BYTES: 0
APPROXIMATE_SIZE(MB): 1
APPROXIMATE_KEYS: 0
4 rows in set (0.05 sec)
除了可以通过改造为 hash 表之外,TiDB 3.1 版本提供的 Follower Read 功能增加了集群的吞吐能力,也能在一定程度上缓解读热点。未来在 TiDB 4.0,PD 会提供 Load Base Splitting 策略,除了根据 Region 的大小进行 Region 分裂之外,还会根据访问 QPS 负载自动分裂频繁访问的小表的 Region,具体参考本书”弹性调度”章节。
TiDB 写入热点的业务场景通常有:
MySQL 为了提高顺序写入的性能,通常都建议业务使用自增 ID 作为主键。而TiDB 中数据按照主键的 Key 切分成很多 Region,每个 Region 的数据只会保存在一个节点上面。业务带着自增主键迁移到 TiDB 后,最新写入的数据大概率都在同一个 Region 上,也就是同一个 TiKV节点上,从而引起热点。
从 MySQL 迁移到 TiDB 的时候,建议去掉自增主键,同时通过设置 SHARD_ROW_ID_BITS,把 rowid 打散写入多个不同的 Region,缓解写入热点。但是设置的过大会造成 RPC 请求数放大,增加 CPU 和网络开销。
语句示例:
CREATE TABLE:CREATE TABLE t (c int) SHARD_ROW_ID_BITS = 4;
ALTER TABLE:ALTER TABLE t SHARD_ROW_ID_BITS = 4;
SHARD_ROW_ID_BITS 的值可以动态修改,每次修改之后,只对新写入的数据生效。未来 TiDB 4.0 会引入 auto_random 特性,彻底解决数据打散问题。
如果业务的表没有主键或主键不是 int 类型或联合主键时,TiDB 内部会自动生成隐式的 _tidb_rowid 列作为行 ID。在不使用 SHARD_ROW_ID_BITS 的情况下,_tidb_rowid 列的值基本也为单调递增,大量 INSERT 时数据会集中写入单个 Region,造成热点。
要避免由 _tidb_rowid 带来的写入热点问题,可以在建表时,使用 SHARD_ROW_ID_BITS 和 PRE_SPLIT_REGIONS 两个建表选项。SHARD_ROW_ID_BITS 用于将 _tidb_rowid 列生成的行 ID 随机打散。pre_split_regions 用于在建完表后预先进行 Split region。
注意: pre_split_regions 必须小于或等于 shard_row_id_bits。
示例:
create table t2 (a int, b int) shard_row_id_bits = 4 pre_split_regions=2;
类似小表的并发读取过高场景,小表并发更新过高导致的写入热点,4.0 之前,需要通过手工切分热点 Region 来临时解决。定位到热点 Region,命令示例如下:
$ pd-ctl -u http://{PDIP}:2379 -i
// 将 Region 1 对半拆分成两个 Region,基于粗略估计值
> operator add split-region {hotRegionId} --policy=approximate
// 将 Region 1 对半拆分成两个 Region,基于精确扫描值
> operator add split-region {hotRegionId}1 --policy=scan
注意: 手工切分的 Region 在经过 split-merge-interval 之后,可能会被合并,split-merge-interval 控制对同一个 Region 做 split 和 merge 操作的间隔,即对于新 split 的 Region 一段时间内不会被 merge,这个参数可以通过 pd-ctl 来更改,默认值是 1h。
中长期的解决方式是通过改造小表为 hash 分区表解决,或者是业务改造后通过队列缓存更新后批量提交等方式解决。未来 TiDB 4.0 版本的解决方式在 Region 并发读取过高时已经说明,此处略。
当表上存在时间字段的索引、或者订单 ID 等单调递增的索引导致的写入热点,目前可以通过手工切分热点 Region 来临时解决。切分的方式在高并发更新小表时有过说明。
秒杀减库存等单行热点更新的场景下,大量请求并发更新同一行记录,推荐调整为 TiDB 的悲观锁。目前对于这类“极端”场景,建议通过异步队列或者缓存在来解决,TiDB 上云之后,可以考虑通过弹性调度隔离到高性能机器来解决。
上面介绍了针对不同热点场景的解决方案,下面说一下通用的通过 PD 调度策略来缓解热点的思路,可以作为热点问题解决方案的补充。PD 对于写热点,热点调度会同时尝试打散热点 Region 的 Peer 和 Leader;对于读热点,由于只有 Leader 承载读压力,热点调度会尝试将热点 Region 的 Leader 打散。
热点调度对应的调度器是 hot-region-scheduler-limit,根据 Store 上报的信息,统计出持续一段时间读或写流量超过一定阈值的 Region,并用与负载均衡类似的方式把这些 Region 分散开来,对于写热点,热点调度会同时尝试打散热点 Region 的 Peer 和 Leader;对于读热点,由于只有 Leader 承载读压力,热点调度会尝试将热点 Region 的 Leader 打散。
在 TiDB 3.0 版本开始,将热点调度并发度从 region-schedule-limit 拆出到 hot-region-schedule-limit ,可以通过加大 hot-region-schedule-limit、并减少其他调度器的 limit 配额,加速热点调度。还可调小 hot-region-cache-hits-threshold 使 PD 更快响应流量的变化。
// 调大 hot-region-schedule-limit
config set hot-region-schedule-limit 8
// 调小其他调度器 limit 配额
config set merge-schedule-limit 2
config set region-schedule-limit 2
在之前所述的读写热点解决方案都无法打散热点时,可以尝试在业务低峰时间添加 scatter-range-scheduler 调度器使这个 table 的所有 Region 均匀分布。添加 scatter-range-scheduler 调度器示例:
curl -X POST http://{TiDBIP}:10080/tables/{db}/{table}/scatter
清理 scatter-range-scheduler 调度器示例:
curl -X POST http://{TiDBIP}:10080/tables/{db}/{table}/stop-scatter
核心业务有 2 个表,一个是业务数据表 A ,一个是相对应流水表 B 。开始做设计时业务 A 和 B 都是一个字符型主键加一个时间索引。A 的数据大小 1.5K 。B 的数据比较小。按照这种设计模式,表 A、B 的数据本身和相对应的时间索引都是热点。另外 2 个自定义的主键也可能存在热点。
考虑到 TiDB 2.1 版本单 TiKV 只有一个线程处理 raft 消息。并且 A 的数据比较大。最终只对 A 表的主键进行改造,把原主键的一部分拿出来做成 bigint 类型,并且在最前面 1 位做成 0-9 的随机数。人为的把 A 表的数据分成 10 片。因为 A 表数据比较大。此时,其他几部分的热点相对于整个业务来说,虽然是热点,但是成为不了瓶颈。