TDengine 读缓存(Read Cache)实战指南:用 CACHEMODEL / CACHESIZE 将 LAST / LAST_ROW 当前值查询提速数倍
【免费下载链接】TDengineHigh-performance, scalable time-series database designed for Industrial IoT (IIoT) scenarios项目地址: https://gitcode.com/GitHub_Trending/tde/TDengine
读缓存(Read Cache)是 TDengine 面向"当前值查询"场景的内置加速机制:把每个子表的最新数据常驻 vnode 内存,命中时LAST/LAST_ROW无需再扫描磁盘上的历史数据文件。本文以官方文档 Read Cache 为主体,结合数据库参数参考与源码实现,完整讲解CACHEMODEL/CACHESIZE的语义、配置方法、验证手段、性能对比实验,以及底层的规划器优化与 LRU 缓存原理。读完本文,你可以独立为任意业务库开启并调优读缓存,用一条 SQL 确认命中效果,并依据cacheload指标判断容量是否充足。
读缓存是什么:为"最新一条数据"而生的内存加速层
在工业物联网(IIoT)与设备监控场景中,最频繁的查询往往不是历史聚合,而是"这台设备现在的最新读数是多少"。TDengine 的读缓存把每个子表(subtable)最近写入的数据存放在 vnode 内存中,当查询命中缓存时,LAST/LAST_ROW不需要读取磁盘上的历史数据文件,从而显著降低响应延迟。典型使用场景包括:
- 展示最新设备读数的实时仪表盘(Dashboard);
- 每张表"最新状态"的轮询式刷新(如告警状态、在线状态);
- 高并发下的
LAST/LAST_ROW聚合查询。
需要区分的是,读缓存只是 TDengine 缓存体系中的一环。写缓存(BUFFER)、元数据缓存(PAGES/PAGESIZE)、WAL 相关的文件系统缓存(WAL_LEVEL/WAL_FSYNC_PERIOD)分别解决不同问题,完整机制参见 Data Caching 与 Architecture。
先分清两个函数:LAST 与 LAST_ROW
读缓存主要服务于两个聚合函数,它们的语义不同,因此需要匹配不同的CACHEMODEL取值:
| 函数 | 语义(概述) | 完整参考 |
|---|---|---|
LAST | 某列最后写入的非 NULL 值(可以按列分别取值) | LAST |
LAST_ROW | 表 / 超表的最后一行(该行各列值可能为 NULL) | LAST_ROW |
两者都属于管道聚合函数(Pipeline Aggregate Function)。LAST返回的是"时间戳最大且非 NULL"的列值;LAST_ROW返回的是整条"时间戳最大的记录",这一行中的某些列完全可能是 NULL。选择CACHEMODEL时,应依据业务真正使用的函数:只查整行最新记录用last_row,按列取最新非 NULL 值用last_value,两者兼有则用both。
配置读缓存:CACHEMODEL 与 CACHESIZE
读缓存由数据库级参数CACHEMODEL和CACHESIZE控制,可在CREATE DATABASE时设置,也可通过ALTER DATABASE动态调整。完整语法见 Databases(其中database_option包含CACHEMODEL {'none' | 'last_row' | 'last_value' | 'both'}与CACHESIZE value)。
CACHEMODEL:四种缓存模式
| 取值 | 含义 | 主要加速对象 |
|---|---|---|
none | 不缓存(默认值) | — |
last_row | 缓存每个子表最近的一行数据 | LAST_ROW |
last_value | 缓存每列最近的非 NULL 值 | 不受WHERE、ORDER BY、GROUP BY、INTERVAL等影响的LAST |
both | 同时缓存最近一行与最近各列值 | LAST_ROW以及满足上述条件的LAST |
需要注意的使用要点:
- 频繁切换
CACHEMODEL可能导致LAST/LAST_ROW结果短暂不准,请谨慎操作,建议开启后保持稳定。 - 带过滤、排序、分组或时间窗口的
LAST查询通常无法完全命中last_value缓存——这一点在规划器层面有严格检查(见下文源码分析)。 - 开启读缓存后,写路径需要同步维护缓存(每次写入都要更新该子表的 last_row / last_value),可能影响写入性能。对于高吞吐写入场景,建议把
both降为last_row或last_value,详见 Ingesting Data Efficiently 中关于cachemodel影响写路径的说明。
CACHESIZE:每个 vnode 的缓存内存上限
CACHESIZE设置每个 vnode用于缓存子表最新数据的内存大小,默认值为1,取值范围[1, 65536],单位为 MB。应根据机器内存与表规模综合设置:表越多、单条记录越大,需要的容量越大。
如何判断容量是否足够(来自 Modify CACHESIZE 的操作方法):
- 查看配置值:
SELECT * FROM INFORMATION_SCHEMA.INS_DATABASES;可显示每个库配置的CACHESIZE(MB)。 - 查看实际占用:
SHOW db_name.VGROUPS;的输出包含cacheload列,表示当前 last-cache 的实际使用量(字节)。 - 判定原则:若
cacheload非常接近cachesize,说明容量可能偏小,缓存频繁淘汰、命中率下降;若明显小于cachesize,则容量充足。调整幅度可结合系统可用内存决定,例如翻倍或数倍扩容。
进阶参数:CACHESHARDBITS(缓存分片)
在数据库参数体系中,还有一个与读缓存并发性能强相关的参数CACHESHARDBITS(见 Databases),它控制 last-value LRU 缓存的分片位数,即内部锁粒度。默认值-1(按 CACHESIZE 自动计算),取值范围[-1, 19]:
- 实际分片数 =
2^CACHESHARDBITS。例如CACHESHARDBITS=3表示 8 个分片,CACHESHARDBITS=6表示 64 个分片。 - 自动计算规则:每个分片至少 512 KB,理论最大分片数 =
CACHESIZE / 512KB;分片位数 =floor(log₂(理论最大分片数)),上限为 6(最多 64 个分片);当CACHESIZE < 512 KB时位数为 0(单分片)。
| CACHESIZE | 理论最大分片数 | 分片位数 | 实际分片数 |
|---|---|---|---|
| 1 MB | 2 | 1 | 2 |
| 4 MB | 8 | 3 | 8 |
| 32 MB | 64 | 6 | 64 |
| 256 MB | 512 | 6(封顶) | 64 |
更多分片可降低并发写缓存时的锁竞争,适合高并发场景;但分片过多会增加内存管理开销。注意:修改CACHESHARDBITS会立即令库内所有 vnode 的 last-value 缓存条目全部失效,后续查询需从磁盘重新加载,可能短暂抬升查询延迟——这与源码中"重建 last cache"的逻辑一致(见下文)。
创建与修改示例
-- 建库时开启 CREATE DATABASE power CACHEMODEL 'both' CACHESIZE 16; -- 对已有库开启或调整 ALTER DATABASE power CACHEMODEL 'both'; ALTER DATABASE power CACHESIZE 32;开启后,使用SHOW CREATE DATABASE确认参数已生效,使用SHOW VGROUPS查看各 vnode 的cacheload(当前 last-cache 使用量,字节)。在 command.c 中可以看到,SHOW CREATE DATABASE的还原语句会把CACHESIZE %d CACHEMODEL '%s' CACHESHARDBITS %d一并输出,便于迁移时完整复刻配置。
实战演练:用 taosBenchmark 对比读缓存前后的查询延迟
官方文档给出了一套可复现的对比实验:使用智能电表数据,对比开启读缓存前后LAST/LAST_ROW的延迟。
第一步:生成测试数据
taosBenchmark -d power -Q --start-timestamp=1600000000000 --tables=10000 --records=10000 --time-step=10000 -y该命令创建数据库power和超表meters,共约 1 亿行数据:10,000 个子表、每表 10,000 行,起始时间戳1600000000000(即2020-09-13T20:26:40+08:00),时间间隔 10 秒。此时CACHEMODEL为默认值none(不缓存)。
第二步:无缓存基线查询
taos> SELECT LAST(ts, current) FROM meters; last(ts) | last(current) | ================================================= 2020-09-15 00:13:10.000 | 1.1294620 | Query OK, 1 row(s) in set (0.353815s) taos> SELECT LAST_ROW(ts, current) FROM meters; last_row(ts) | last_row(current) | ================================================= 2020-09-15 00:13:10.000 | 1.1294620 | Query OK, 1 row(s) in set (0.344070s)未开启缓存时,两条查询分别耗时约 353 ms 与 344 ms——每次都要扫描全表定位最新的行。
第三步:开启读缓存并确认生效
taos> ALTER DATABASE power CACHEMODEL 'both'; Query OK, 0 row(s) affected (0.046092s) taos> SHOW CREATE DATABASE power\G; *************************** 1.row *************************** Database: power Create Database: CREATE DATABASE `power` BUFFER 256 CACHESIZE 1 CACHEMODEL 'both' COMP 2 DURATION 14400m WAL_FSYNC_PERIOD 3000 MAXROWS 4096 MINROWS 100 STT_TRIGGER 2 KEEP 5256000m,5256000m,5256000m PAGES 256 PAGESIZE 4 PRECISION 'ms' REPLICA 1 WAL_LEVEL 1 VGROUPS 10 SINGLE_STABLE 0 TABLE_PREFIX 0 TABLE_SUFFIX 0 TSDB_PAGESIZE 4 WAL_RETENTION_PERIOD 3600 WAL_RETENTION_SIZE 0 KEEP_TIME_OFFSET 0 Query OK, 1 row(s) in set (0.000282s)SHOW CREATE DATABASE输出中已出现CACHESIZE 1 CACHEMODEL 'both',说明配置生效。
第四步:再次查询对比
第一次查询会填充缓存,后续查询通常能观察到明显更低的延迟:
taos> SELECT LAST(ts, current) FROM meters; last(ts) | last(current) | ================================================= 2020-09-15 00:13:10.000 | 1.1294620 | Query OK, 1 row(s) in set (0.044021s) taos> SELECT LAST_ROW(ts, current) FROM meters; last_row(ts) | last_row(current) | ================================================= 2020-09-15 00:13:10.000 | 1.1294620 | Query OK, 1 row(s) in set (0.046682s)本实验中,延迟从约 353 / 344 ms 降至约 44 ms,提速约 8 倍。实际效果取决于数据规模、硬件配置与并发负载,但方向是一致的:查询越频繁、数据越热,读缓存收益越明显。
源码视角:读缓存何时真正生效
读缓存并非无条件加速所有LAST/LAST_ROW查询,规划器与存储层都有一整套判定与落盘逻辑。
规划器:只有在合适的查询形态下才走缓存扫描
在 planOptimizer.c 中,hasSuitableCache根据CACHEMODEL与查询中是否出现LAST_ROW/LAST决定能否应用lastRowScan优化:
TSDB_CACHE_MODEL_NONE:一律不优化;TSDB_CACHE_MODEL_LAST_ROW:仅当查询含LAST_ROW时优化;TSDB_CACHE_MODEL_LAST_VALUE:仅当查询含LAST时优化;TSDB_CACHE_MODEL_BOTH:两者皆可。
同时,lastRowScanOptCheckFuncList(planOptimizer.c)还会检查函数列表中是否混入了其他函数、WHERE/ORDER BY/GROUP BY/INTERVAL等条件,一旦出现这类"特殊效果",查询就不会被优化为缓存扫描,而是回退到常规扫描路径——这正是文档中"带过滤、排序、分组或窗口的LAST查询往往无法完全使用last_value缓存"的源码依据。
存储层:LRU 缓存 + 惰性加载 + 写路径维护
在存储引擎 tsdbCache.c 中,last-cache 的载体是每个 vnode 的 LRU 缓存(pTsdb->lruCache),其行为与 Architecture: last/last_row Cache 的描述完全对应:
- 惰性加载(lazy loading):对某张表的首次查询从内存池与磁盘读出所需值,写入 LRU 缓存后返回;后续插入或删除只更新已存在的缓存条目,未缓存的表写入不会强制进缓存。对子表查询只加载该子表,对超表查询可能加载其全部子表。
- 状态标记:缓存条目带有
ELastCacheStatus(tsdb.h),区分"缓存有效"(TSDB_LAST_CACHE_VALID)与"无缓存但 tsdb 可能有数据"(TSDB_LAST_CACHE_NO_CACHE)。 - 容量控制:
tsdbCacheSetCapacity(tsdbCache.c)把CACHESIZE(MB)换算成字节设置 LRU 容量;tsdbCacheGetUsage返回当前使用量,正是SHOW VGROUPS中cacheload指标的数据来源。 - 配置变更联动:在 vnodeSvr.c 中可以看到,
CACHESIZE变化会立即调用tsdbCacheSetCapacity调整 LRU 容量;CACHESHARDBITS变化则加锁调用tsdbRebuildLastCache重建整个 last cache,并打印 "rebuilding last cache" 日志——印证了文档中"修改分片位会立即失效所有缓存条目"的警告。
写路径开销的量化指标
读缓存开启后,每次写入都需要更新对应子表的 last_row / last_value。vnode 的写入指标中维护了last_cache_commit_time与last_cache_commit_count(见 vnodeInt.h),在 vnodeCommit.c 中通过METRICS_TIMING_BLOCK统计每次缓存提交的耗时。这些指标可以直接印证"读缓存由写路径维护、影响写入性能"的结论——这也是官方建议高吞吐场景将both降级为last_row或last_value的原因。
运维建议与最佳实践
- 按查询形态选模式:纯"最新状态"仪表盘用
last_row;按列取最新非 NULL 值(如LAST(current))用last_value;两者都高频出现再用both,避免无谓的写路径开销。 - 开启后保持稳定:避免频繁在
none/last_row/last_value/both之间切换,防止LAST/LAST_ROW结果短暂不准。 - 定期观察 cacheload:用
SHOW VGROUPS对比cacheload与CACHESIZE,接近即扩容;扩容可结合CACHESHARDBITS的自动分片规则一起评估。 - 与写缓存等机制配合:读缓存只解决"最新值"查询;高频写入吞吐主要受
BUFFER、VGROUPS、WAL_LEVEL/WAL_FSYNC_PERIOD影响,需要整体规划(参见 Data Caching)。 - 验证生效:
SHOW CREATE DATABASE确认参数、SHOW VGROUPS观察cacheload增长、以及查询延迟对比,三步即可完成上线验证。
相关文档
- LAST / LAST_ROW:函数语义与使用约束
- CACHEMODEL / CACHESIZE / CACHESHARDBITS:完整参数参考与容量判定方法
- Data Caching:写缓存、元数据缓存、WAL 等其他缓存类型
- Architecture: last/last_row Cache:LRU、惰性加载与配置联动机制
- Ingesting Data Efficiently:读缓存对写路径的影响与高吞吐调优
【免费下载链接】TDengineHigh-performance, scalable time-series database designed for Industrial IoT (IIoT) scenarios项目地址: https://gitcode.com/GitHub_Trending/tde/TDengine
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考