利用空间局部性原理,将数据库查询量从71.7万/秒降低到1.4万/秒,缓存命中率高达97.95%。
原文标题:71.7万/秒到1.4万/秒!数据库查询优化实战
原文作者:阿里云开发者
冷月清谈:
文章以菜鸟消费者运营策略平台为例,阐述了如何利用空间局部性优化数据库查询。由于该平台需要存储和查询大量的用户状态数据,因此面临着巨大的数据量、写入量和查询量挑战。文章介绍了针对数据量和写入量的优化措施,例如选择合适的数据库、批量写入和数据裁剪等。
对于查询量大的问题,文章提出了利用空间局部性进行优化。通过将缓存加载粒度设置为 (userId, graphId) 和 (userId),分别将数据库查询量级降低到原来的 23.5% 和 1.95%,并显著提升了查询性能。文章还详细分析了不同缓存加载粒度下的性能指标,并讨论了潜在的风险和相应的监控措施。最后,文章总结了如何定义“相邻”数据和确定缓存加载最小数据单位,并强调了设置硬限制以控制内存压力的重要性。
怜星夜思:
2、文章提到了Lindorm数据库,它有哪些特性使得它适合这个场景?与其他数据库相比,它的优势在哪里?
3、文章中提到的“吵闹邻居”问题是什么?如何避免或减轻这种问题的影响?
原文内容
1.局部性原理
程序的局部性原理是指程序在执行时呈现出局部性规律,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
-
时间局部性:时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某数据被访问,则不久之后该数据可能再次被访问。比如在函数调用的时候,前不久才使用过的函数参数或局部变量容易再度被调取使用。
-
空间局部性:空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。空间局部性比较常见于循环中,比如在一个数组中,如果第 3 个元素在上一个循环中使用,则本次循环中极有可能会使用第 4 个元素。
二、缓存
三、分布式系统中如何利用局部性原理
3.1 在分布式系统中利用时间局部性
低功耗核心 (Icestorm)
|
高性能核心 (Firestorm)
|
|
L1 指令
|
128 KB × 4
|
192 KB × 4
|
L1 数据
|
64 KB × 4
|
128 KB × 4
|
L2
|
4 MB (低功耗核心共享)
|
12 MB (高性能核心共享)
|
SLC
|
8MB (全片共享)
|
其具备以下特点:
3.2 在分布式系统中利用空间局部性
-
是:由于高速缓存一般对指令集透明,其必须要和内存保持一致的按字节寻址的特性,因此对于缓存的访问而言,其原子单元和内存一致,为字节
-
不是:但对于缓存的加载而言,其原子单元却并不是和内存一致的字节,而是缓存行(Cache Line),以上面提到的 M1 处理器为例,其缓存行大小(Cache Line Size)为 128 字节。也就是说,如果出现缓存不命中,需要从内存中加载数据到缓存中时,即使我访问的只是内存中的一个字节,那么缓存也会把这个字节所在的缓存行,也就是 128 个字节的数据全部加载到缓存中。
-
缓存行的设计可以充分利用现在 DRAM 的突发模式(Burst mode)特性,大幅提高访存吞吐量和减少延迟,这里具体不展开。
-
如果能提升系统的整体性能,一定程度的局部浪费是完全可以接受的。比如 CPU 中的分支预测特性也体现了类似的思路。
-
浪费的程度实际上取决于程序的空间局部性是否明显,局部性越明显,那么浪费就越小。
-
理论上缓存行的大小越大,那么对于局部性强的程序的性能提升也就越明显。当然,缓存行的大小还要受到其他各种物理因素的制约,并不能想多大就多大,也不是越大越好。
-
怎么定义“相邻”:内存的相邻很好定义,就是物理地址相邻,但是对于数据库中的业务数据而言,情况会复杂得多,甚至同一张表中的数据,在不同的使用场景下,其相邻的定义也不尽相同;
-
缓存加载的最小数据单位怎么确定:也就是对应 CPU 高速缓存的缓存行大小。这个值太小对空间局部性的利用程度有限,这个值太大会对缓存的加载开销和空间占用造成较大的压力。
四、实践案例
4.1 背景与挑战
-
数据量巨大:预估数据量级将达 10 亿以上,并且后续会随平台使用情况、业务量级等因素继续增长。
-
写入量级巨大:预计写入 TPS 将达 1 万以上,同样会随平台使用情况、业务量级等因素继续增长。
-
查询量级更大:预计查询 QPS 将达 10 万以上,同样会随平台使用情况、业务量级等因素继续增长。
4.2 应对措施
-
在数据库选型阶段选择了 Lindorm 以支撑海量的数据,同时利用其 TTL 机制,可以很方便的实现历史数据的清理。
-
同时为了节省成本,选择了公共集群,但公共集群会存在“吵闹邻居(Noisy neighbour)”的问题,会面临一些偶发的抖动,需要做一些额外的容错措施。
-
Lindorm 数据库本身基于 LSM Tree 数据结构,此结构将全量的写入请求都转化为对硬盘的顺序写入操作。众所周知,无论是固态硬盘还是机械硬盘,其顺序写入性能都是其随机写入性能的数倍。因此,Lindorm 数据库本身可以提供强大的写入性能。
-
状态数据批量合并写入,对状态数据进行一定程度的合并处理再批量写入,以降低写入 TPS 提升吞吐量。
-
状态数据裁剪,在写入前对状态数据进行过滤,仅保留被其他节点依赖的节点的状态数据。
-
另外,针对公共集群的偶发抖动,在写入时采取了通过 MetaQ 重试,同时利用 Lindorm 的时间戳多版本特性来避免重试导致的写入乱序而造成的数据不一致。
-
在一次请求处理过程中,反复访问同一个节点的状态数据的情况有,但可以预期的此缓存的命中率不会高。
-
如果引入多级缓存策略,无非就是将查询 QPS 分摊一部分至 Tair 之类的存储,这将带来成本的上涨、链路依赖增多导致的 SLA 下降等问题。
缓存加载粒度 | 加载数据行数 | 加载数据量 | 数据库查询延迟 | 空间局部性 |
内存存储压力 |
(userId, graphId, vertexId) | 一行 | 小 |
低 |
无 |
小 |
(userId, graphId) | 相邻多行 |
中 |
中 |
中 |
中 |
(userId) | 相邻多行 | 大 | 高 |
强 |
大 |
4.3 初探空间局部性
缓存加载粒度
|
缓存查询量级
|
缓存加载量级
|
单次缓存加载耗时
|
均摊缓存查询耗时*
|
(userId, graphId, vertexId)
|
6.8 万/秒
|
6.8 万/秒
|
1 毫秒/次
|
1 毫秒/次
|
(userId, graphId)
|
6.8 万/秒
|
1.6 万/秒
|
1.5 毫秒/次
|
0.35 毫秒/次
|
均摊缓存查询耗时*:指将总的缓存加载耗时均摊到总的缓存查询量级上。即:单次缓存加载耗时 × 缓存加载量级 ÷ 缓存查询量级
-
我们将数据库的查询量级下降到仅原来的 23.5%。
-
但由于每次加载的数据量增多,单次加载耗时增加到原来的 150%,但绝对值仍然处于非常优秀的水平。
-
另外,如果我们将总的加载耗时均摊到总的查询量级上,均摊查询耗时下降到仅原来的 35%,也就是说,整体性能获得了 65% 的巨大提升。
4.4 推向极限
缓存加载粒度
|
缓存查询量级
|
缓存加载量级
|
单次缓存加载耗时
|
均摊缓存查询耗时*
|
(userId, graphId, vertexId)
|
6.8 万/秒
|
6.8 万/秒
|
1 毫秒/次
|
1 毫秒/次
|
(userId, graphId)
|
6.8 万/秒
|
1.6 万/秒
|
1.5 毫秒/次
|
0.35 毫秒/次
|
(userId)
|
6.8 万/秒
|
0.28 万/秒
|
3.9 毫秒/次
|
0.16 毫秒/次
|
可以看到,将空间局部性的挖掘做到极致以后:
-
我们将缓存的加载量级下降到仅相当于查询量级 4.12% 的水平!
-
由于每次加载的数据量继续增多,单次加载耗时增加到接近原来的 4 倍,但绝对值仍然处于可接受的水平
-
同时,如果我们将总的加载耗时均摊到总的查询量级上,均摊查询耗时下降到仅原来的 16%,也就是说,整体的查询性能也获得了将近 84% 的巨大提升!
4.5 长期表现
时间节点
|
缓存加载粒度
|
缓存查询量级
|
缓存加载量级
|
单次缓存加载耗时
|
均摊缓存查询耗时*
|
上线
|
(userId)
|
6.8 万/秒
|
0.28 万/秒
|
3.9 毫秒/次
|
0.16 毫秒/次
|
现在
|
(userId)
|
71.7 万/秒
|
1.4 万/秒
|
1.17 毫秒/次
|
0.02 毫秒/次
|
通过最新的数据我们可以观察到:
-
缓存加载量级仅为查询量级的 1.95%,同时此缓存的命中率达 97.95%!
-
单次缓存加载耗时降至 1.17 毫秒/次!此指标下降主要得益于前文提到的写入侧的数据裁剪,导致数据库整体的数据量下降,因此这里需要加载的数据量也有较大的下降,从而加载耗时也大幅减小
-
将总的加载耗时均摊到总的查询量级上,均摊缓存查询耗时来到了惊人的 0.02 毫秒/次。
4.6 风险
-
这么激进的缓存加载策略,会不会把内存打爆?
-
会不会存在某个用户 ID 下有海量的数据?
-
GC 压力如何?
-
对单机内存的要求会不会很高?
-
本地缓存使用数量、查询命中率、查询量级、加载量级
-
本地缓存平均加载时长、加载失败率、驱逐量级
-
为了监控单个用户 ID 名下数据量过大之类的风险,我们还对数据库的批量写入大小和批量读取大小进行了监控
-
单机的 GC 表现
-
状态缓存的有效期为加载后 1 秒内。
-
所有单机的规格均为 4 核 CPU + 8 GiB RAM 的 x86 容器。
-
状态缓存的峰值利用率在单机最大 600 项左右,并不多,且距离 2048 的最大项数还有较大距离。
-
状态缓存在进行数据加载时,单用户名下的最大数据量通常在 12 条左右,并且长期保持稳定,不存在任何尖刺。
-
单机的 GC 表现在 AJDK 21 和 G1 GC 的配合下表现优异,每分钟 GC 次数为 2-5 次,单次 GC 耗时 20 毫秒左右,且均为 Young GC。
-
堆内存上限 4 GiB,Young GC 后堆内存使用率能下探到 20% 左右。
4.7 回顾
-
如何定义“相邻”:在这个案例中,无论是按照 (userId, graphId) 还是 (userId) 的粒度来进行缓存加载,其都是符合数据库主键的最左前缀匹配的,这意味着这些数据在实际存储时,在物理层面,也都是相邻的,这样在查询时能够很好的利用数据库的特性,减少索引开销,利用硬盘的顺序读取特性,从而优化批量查询时的性能开销
-
缓存加载最小数据单位:不同于 CPU 中的缓存行,面临晶体管数量等严格的物理限制,在分布式系统中,我们面临的物理限制通常来自于内存大小,而这类限制相比之下宽松得多。因此在这个案例中,我们可以以 (userId, graphId) 甚至 (userId) 这样的动态粒度来作为缓存加载的最小数据单位,从而将空间局部性挖掘到极致,这在 CPU 层面将是难以想象的。