TDengine数据过期与夭折-等待策略:事务完成率提升指南
2026/9/9 23:12:56 网站建设 项目流程

你有没有遇到过这种场景:凌晨的定时任务拼命往 TDengine 里补录历史数据,结果一大半写入失败,错误码翻译过来是“表不存在”。你查了一圈发现,那张表确实还在,SHOW TABLES 还能看到它,但底层的数据文件早已被系统自动清理了——因为它超过了 KEEP 设置的保留期限。这种“元数据还在、数据已经过期”的拉扯,正是时序数据库里事务完成率被拉低的经典原因之一。

TDengine 在存储和调度层针对这类问题做了两件事:一是在数据文件层面执行严格的数据有效期(KEEP)管理,二是引入夭折-等待(Abort-and-Wait)策略来处理无法立即完成的事务。这篇文章不打算只停留在概念层面,我想从一次真实写入失败开始,把这两个机制的底层逻辑掰开揉碎,讲清楚它们是怎么配合着把事务完成率提上去的。文章也适合刚入门 TDengine 的开发者,起码看完后,你再遇到批处理写失败,不会第一反应是去骂客户端连接池。

1. 从一次凌晨批量补数的失败说起:数据过期和事务失败是怎么扯上关系的

1.1 一次凌晨批量补数的写入事故复盘

有一次我们做历史行情数据回补,任务从凌晨 2 点开始,一次性往某个库里的 1200 张表灌前一天的数据。跑了大约 20 分钟,我开始发现客户端日志里的错误码变得密集,都是写入失败。奇怪的是,第一轮写入大部分是成功的,越往后失败率越高,最后几乎全失败。

当时的反应很直接:查连接池、查驱动、查网络,全都没问题。后来一个同事提醒了一句:你看看这个库的 KEEP 是不是被调过。一查,果然,两周前有人为了省磁盘空间,把 KEEP 从 365 天改成了 7 天。我们补录的是 10 天前的数据,表元数据还在,但对应时间窗的数据文件早就被清理了。

业务方看到的现象是“表存在”,TDengine 的元数据模块也确实能查到这张表,可一旦请求落到 vnode 的数据文件层,发现对应时间窗口已经不存在任何文件,写入事务就无法构造出可提交的数据页。换句话说,这不是简单的“表不存在”,而是“表的历史时间窗已过期”。

1.2 被误解的“表不存在”:元数据和数据文件不是一回事

这里要澄清一个很多人搞混的点:在 TDengine 里,一张表(或者超级表下的子表)包含两个层面的东西,一个是元数据,包括表名、标签、schema,另一个是实际数据文件,按时间窗口组织在 vnode 上。元数据的存在周期和数据文件的存在周期不是对齐的。KEEP 控制的不是元数据,而是数据文件。

元数据是否会被清理,取决于表本身是否还有数据、是否被 drop,或者是否触发了一些内部回收逻辑。大多数情况下,数据过了 KEEP 但表不会被自动删除,你想往旧时间窗写数据,就是往一个已经被删除的文件区间里写。所以从 DBA 的视角看,问题不是“表丢了”,而是“时间窗口丢了”。

在真实运维中,这个误解非常普遍。很多人一看到“表不存在”类错误,第一反应是去检查建表语句、超级表结构,或者怀疑标签匹配错了,结果绕了一大圈才发现,元数据安然无恙,是底层按照失效策略把旧文件清掉了。

1.3 事务的视角:数据过期为什么会让整个提交流程失败

TDengine 的写入,一次提交通常是一批记录,这批记录在服务端会被封装成一组写入操作,落在同一个或多个 vnode 上。服务端处理这个提交时,会先检查这个批次的记录是否跨越了有效数据范围。如果时间戳对应的文件已经过期,这个提交单位里的记录就没有合法的落盘目标。

这时候有两种处理方式:要么把这个批次拆开,有效时间窗内的正常写,过期的直接丢弃;要么整个批次标记为失败。我在线上观察到的行为是后者——整个事务被夭折,而不是部分成功。为什么?因为时序数据往往是一个采集点不同时刻的观测值,你只写一部分,会造成数据断层,而且用户很难察觉。与其产生一个不完整的数据集,不如让调用方明确知道这批数据没写进去,然后由业务层决定是调整时间范围重试,还是放弃。

这个设计理念在写时序数据时非常重要。时序数据天然有先后顺序,查询经常要跨时间窗口做聚合,如果某个时间窗口缺了一段数据,聚合结果就是错的,而且很难被发现。数据库选择整个事务失败,实际上是在用明确报错换取数据完整性。

2. KEEP 参数背后的时间盒子:数据有效期到底管住了什么

2.1 三个参数构建的时间盒子:KEEP、DAYS、BLOCKS

先讲清楚 KEEP。创建库的时候,大多数人只会写CREATE DATABASE db,其实后面跟参数,一个典型的建库语句是:

CREATE DATABASE power KEEP 365 DAYS 10 BLOCKS 6;

KEEP 的单位是天,表示这个库里的数据最多保留多少天,默认 3650 天。DAYS 表示按多少天切分一个数据文件,默认 10。BLOCKS 表示一个文件里最多缓存多少个数据块,默认 6。这三个参数加起来,决定了存储层为一张表准备了多少个“时间盒子”。KEEP 是盒子的总寿命,DAYS 是盒子内部小格的宽度,BLOCKS 则是每个小格能装数据块的容量上限。

为什么时序数据库要搞这么复杂?因为时序数据是流式写入的,按时间戳排序存储才能高效压缩和查询。如果所有数据堆在一个文件里,查询“最近一小时”也得扫整个文件,KEEP 清理的时候也无法优雅地只删旧数据。按时间窗口切分后,清理某个旧窗口就是删除一个独立文件,既不影响新数据写入,也不产生大量存储碎片。

DAYS 设置得越小,数据文件切分越细,单次清理的粒度越精确,但文件数量也会变多,元数据管理开销随之上升。DAYS 设置得太大,文件体量变大,虽然管理简单,但清理时要整块删除,灵活性降低。对绝大多数 IoT 场景,DAYS 用默认的 10 基本够用,不必频繁调整。

2.2 过期清理是异步的:窗口期里的“幽灵表”

但这里有个关键点:过期数据的清理不是立即发生的。系统在写入时会触发 vnode 的合并、翻转和落盘,清理旧文件的动作会优先发生在系统空闲、或者写入量达到一定阈值的时候。这就导致一个时间窗口的数据已经过了 KEEP 期限,但物理文件可能还没来得及被删除,或者文件已经被标记为只读状态。

在这个窗口期,写入请求如果落在这个区间,事务会直接被 abort。如果你用并发去测试,这些 abort 的现象会被放大。我们可以把这个理解成“过期表幽灵期”:元数据版本还保留着,文件系统上还残留着文件句柄,但存储引擎在事务提交阶段检查时,已经判定这个区间不可写了。

这个“幽灵期”对运维排障的干扰很大。你在服务器上看磁盘,旧数据文件可能还在,占着空间;你写入,却报错。等到系统触发清理任务,把文件真正删掉,空间才释放出来。所以生产的经验是:调整 KEEP 之后,不要指望空间立刻释放,也不要奇怪 KEEP 到期后还有旧文件存在,这中间有一个自然的异步窗口。

2.3 数据有效期与事务完成率的第一层关联

很多运维同学以为 KEEP 只是帮我们省磁盘的,其实它的设置直接关系到事务完成率。想象一下:如果一个库的 KEEP 是 7 天,但业务端因为调度延迟,经常补录 8 天前的数据,那每一批补录都会触发一批无效事务。

无效事务虽然在服务端很快被 abort,但它们仍然占用了连接、vnode 写队列、WAL 的审批资源。当这些无效事务的数量占到总请求的一定比例时,有效事务也会跟着遭殃——写队列被挤占,延迟升高,最后超时 abort,导致连正常的实时写入都可能失败。

所以数据有效期不是一个静态的后台清理参数,它是一个会深度影响事务调度行为的前置约束。TDengine 把 KEEP 和事务处理联动起来,目标就是在数据进入写入队列之前,通过元数据判断它是有效还是无效,从而避免对事务资源的无谓占用。

3. “夭折-等待”为什么不叫“重试”:调度策略的取舍逻辑

3.1 朴素重试的代价:重试风暴和负反馈循环

面对上面的 abort,最朴素的想法是:失败了我就在客户端循环重试呗,重试到成功为止。这个思路在小流量下没什么问题,但一旦遇到批量补数据的高峰,问题就来了。

假设有 2000 张表的数据需要补录,每张表都因为过期时间窗被 abort,客户端立刻重试,abort 又立刻发生,双方就进入了一个死循环。如果客户端是 50 个线程并发重试,服务端的 vnode 写队列会被无效请求塞满,这时候正常写入反而进不来。这个现象在分布式系统里有个专门的名词,叫“重试风暴”。

更麻烦的是,重试风暴会造成两个连锁反应:一是 WAL 日志被无效事务刷得飞快,触发频繁的 fsync,磁盘 IO 升上去;二是 vnode 的合并线程被干扰,旧文件清理不了,反而延长了过期文件的存留时间。于是,越重试,事务完成率越差,形成负反馈循环。

给一个简单类比:你把一锅沸水倒进下水道,结果水管堵了,你的第一反应不是关水,而是继续倒更多热水,那结果只能是水漫厨房。重试逻辑也一样,关键是先停手,让系统消化完现有的问题,再重新尝试。

3.2 “夭折”不是失败,是快速失败的美德

夭折-等待策略的核心,首先是允许事务快速失败。当一个事务在提交前发现目标数据时间窗已过期、或者 vnode 正在合并、或者写队列已满,它不会无限期阻塞等待,而是立刻进入 abort 状态,结束这个事务,把已经占用的资源全部释放。

这里的关键是“快速”。一个事务从进入队列到被 abort,应该控制在极短的时间内,让连接、线程、内存缓冲都能迅速回到可用状态。

听起来很反直觉——我们平时做系统优化都希望事务尽量成功,怎么反而鼓励失败?其实在数据库内部,快速失败一个事务的成本,远比让它在资源紧张时空转要低得多。一个空转的事务占着连接和锁,会让其它事务排队;而快速失败的事务只占用一个极小的错误码输出,然后系统可以立刻处理下一批请求。

我见过一个比喻非常贴切:夭折策略像是餐厅排队。如果某位客人觉得菜单不合口味,他应该立刻离开,把座位让给下一位;而不是站在桌边一直纠结,导致后面所有等位的顾客都进不来。

3.3 “等待”为什么必须是指数退避加随机抖动

光夭折不等待,等于把问题抛给客户端,客户端不断重试,回到上一节的坑里。所以夭折之后必须有一个等待动作。等待不是简单地 sleep 固定 100 毫秒,而是要用指数退避加随机抖动。

比如第一次重试前等 50 毫秒,第二次等 100 毫秒,第三次等 200 毫秒,同时给每个等待时间加一个 ±20% 的随机量。为什么要有随机抖动?因为如果有 100 个线程同时收到 abort,它们都会在同一个时间点醒来重试,这 100 个重试又会同时到达服务端,再次形成一个小型的重试风暴。加了随机量之后,重试请求在时间轴上是打散的,服务端就有能力逐个消化。

等待窗口的终止条件也要明确。对 TDengine 来说,客户端重试不能是无限次的,超过最大重试次数后,应该上报失败,让业务层决策。一般建议最大重试 3 到 5 次,超过之后说明当前写入条件没有从根本上解决,继续重试只会拖慢其它任务。

我把朴素重试和夭折-等待的核心差异整理成了一个表格:

维度朴素重试夭折-等待
失败后行为立即重试进入退避等待再重试
服务端资源占用无效请求持续挤占队列快速释放,不占坑
高并发表现重试风暴,雪崩概率高请求被时间抖动打散
对有效请求的影响被无效请求挤压,延迟飙升有效请求获得稳定资源
完成率趋势越重试越差先降负载,再逐步恢复

3.4 一次夭折-等待的完整生命周期

把整个过程串起来,实际的路径是这样的:

  1. 客户端发起一批写入,目标表是 T1,时间戳范围落在 7 天前。
  2. vnode 收到请求后,检查 T1 对应时间窗的文件状态,发现已过期,直接 abort 这个事务,返回对应的错误码。
  3. 服务端在返回错误的同时,记录这个 abort 事件,更新相关统计指标,比如事务 abort 总数、abort 原因分类。
  4. 客户端连接器收到错误码后,识别出这是可重试的失败,进入指数退避等待。等待期间不占用服务端连接。
  5. 等待结束后,客户端重新发起写入。如果此时数据文件已经被彻底清理,服务端会再次 abort;如果这个时间窗口恢复了,事务就能正常提交。
  6. 如果重试次数达到上限,客户端把错误抛给应用层,由业务侧人工介入。

这个过程中最重要的设计决策在第 4 步:等待状态放在客户端还是服务端?TDengine 倾向于把重试逻辑放在客户端,服务端只负责快速判定和 abort,这样服务端的线程模型可以保持简单高效。对使用者来说,这意味着连接器重试参数的配置质量,直接决定了这套策略能不能落地。

4. 事务完成率是怎么被这两个机制撬动的

4.1 完成率怎么算:别被“总数”骗了

事务完成率,不能简单地用“成功数/总请求数”来衡量。举个例子:一次补数任务发了 10000 个请求,其中 4000 个是往过期时间窗写数据的,如果这 4000 个最终被业务层中止了,那完成率就是 6000/10000=60%。但如果你从“有效请求”的视角看,6000 个有效请求全部成功了,完成率是 100%。

当然,站在业务方的视角,60% 就是失败。所以 TDengine 要做的事,是尽可能让那些无效请求在业务发出之前就被拦截,或者在发出之后被快速终止,不浪费系统资源,这样才有余力去优化那部分有效请求的成功率。夭折-等待在这里的作用,是让无效请求和有效请求之间不互相挤兑。

从监控指标上,你应该拆开看两组数据:一组是“请求总数”和“成功数”,另一组是“有效请求数”和“成功数”。如果第一组完成率低,但第二组完成率很高,说明绝大多数失败来自无效请求,你该调整的是 KEEP 或者业务发送侧;如果两组完成率都低,说明存储或调度本身有问题,需要查 vnode 负载和 WAL 状态。

4.2 等待让“有效请求”的比例变高

没有夭折-等待时,无效请求会长期占用 vnode 写队列和 WAL 资源,导致有效请求在队列里等的时间变长,最终因为超时 abort。有了夭折-等待后,无效请求被快速清出队列,有效请求几乎不用排队;而处于等待状态的重试请求也不会占用连接资源。于是,系统同一时刻处理的,基本全是有效请求,单位时间内成功提交的事务数量自然就上去了。

严格来说,TDengine 内部并没有公开一个“完成率阈值公式”,但从排队论的角度讲,一个队列里如果无效请求占比达到 30%,有效请求的平均排队时间会增加一倍以上。夭折-等待等于把无效请求从队列里先抽走,虽然它还会回来,但是被时间抖动打散,回来的节奏不会造成拥堵。

这也能解释一个现象:为什么有时候你觉得写入压力明明很大,但打开 TDengine 的监控面板,写队列深度反而很小?大概率是夭折-等待策略在起作用,无效请求没有堆在队列里,而是被快速打回客户端等待区了。

4.3 数据过期和夭折-等待的联动效果

如果只讲夭折-等待,不讲数据有效期,是不完整的。数据有效期这个前置约束,决定了事务在什么情况下会被判定为无效。TDengine 在判断一个写入事务是否有意义时,会看它的时间戳是否落在 KEEP 和现有文件边界内。如果 KEEP 设置合理,大部分事务在进入 vnode 队列前就被判定为有效;只有边界场景的事务会触发 abort,再由夭折-等待兜底。

一个实践中的例子:我们把 KEEP 从 7 天调整成 30 天后,同样的补数任务,完成率从原来的 45% 直接跳到 98% 以上。这不是因为夭折-等待策略失效了,而是因为无效事务的大头被前置条件消灭了。所以我的经验是,夭折-等待是效率兜底,KEEP 才是战略性的前置判断。两者配合起来,才能在“数据量大、写入窗口集中、部分数据过期”的典型时序场景里,把事务完成率稳定在可用线之上。

5. 实战:把 KEEP 和夭折-等待用好的配置与监控手段

5.1 安装和基础配置:Windows 和 Linux 上的注意点

先回应一下最近不少人在问的 TDengine 怎么装。Windows 环境下去官网下载安装包,默认安装后启动 taosd 服务,然后用 taos 命令行连接一下,基本就通了。Linux 下更简单,官方有 apt/yum 源,一条命令搞定。3.x 版本之后,还可以用 Docker 一键起一个测试环境。

我需要额外提醒的是,安装完成后第一件事不是建表,而是规划好库参数。库参数设计不好,后面调整的成本非常高,尤其是 KEEP。很多人图省事直接CREATE DATABASE db;完事,结果数据跑了好几个月,突然发现磁盘占用异常,再想改 KEEP,旧数据也不会自动按新策略回溯整理,整个过程非常被动。

5.2 数据结构设计如何配合 KEEP:超级表、子表和时间范围

很多初学者会问,时序数据库的表结构怎么设计。TDengine 的推荐设计是:一张采集点对应一张子表,用超级表统一管理。比如你有一批传感器,每个传感器是子表,字段有电流、电压、温度,标签是位置和设备 ID。建库和建表是这样:

CREATE DATABASE power KEEP 3650 DAYS 30 BLOCKS 8; CREATE STABLE meters ( ts TIMESTAMP, current FLOAT, voltage INT, phase FLOAT ) TAGS ( location BINARY(24), groupId INT ); CREATE TABLE d1001 USING meters TAGS ('Beijing', 1);

注意 KEEP 设置成了 3650 天,也就是十年。为什么默认给十年?因为时序数据的价值往往要到做长周期趋势分析时才能体现出来。你如果不知道业务要回溯多久,建议先给一个足够大的值,后续再通过 ALTER DATABASE 调整。调小容易,调大就需要等数据慢慢补回来,所以初期别抠。

KEEP 的设置没有一个万能答案,我按业务类型给一个参考:

业务场景采样频率KEEP 建议
实时监控秒级或毫秒级7-30 天
设备运维分钟级90-365 天
金融交易毫秒级3-10 年
科研/碳排放审计分钟级或小时级不短于监管要求年限

5.3 客户端重试与连接池配置:MyBatis-Plus 能不能用

有同学问 TDengine 可不可以用 MyBatis-Plus 做插入。你用 MyBatis-Plus 通常是走了 JDBC 连接器,TDengine 提供了标准的 JDBC 驱动,所以理论上可以。但我建议不要在 ORM 框架里做复杂的批处理,尤其是时序数据高频写入的场景。时序数据的高效写入,最好是直接使用原生写入接口,或者用 taosAdapter 的 REST 接口批量提交。

如果你确实要在一个项目里用 MyBatis-Plus,也建议把 TDengine 的写入和业务表的写入拆开,别混在一个事务里,跨库事务在时序场景下不是一个好设计。

连接器层面有几个参数值得关注:超时时间、最大重试次数、连接池大小。以 JDBC 为例,请求超时不要设得太长,一般 5 到 10 秒就够了,如果 10 秒还没提交成功,多半是服务端已经堵了,继续等也没意义。连接池大小要和服务端的 vnode 数量匹配,避免连接过多造成线程上下文切换开销。重试配置建议开启,但最大重试次数别超过 5 次。

客户端重试逻辑的伪代码大概是下面这个样子,很多连接器底层已经内置了,不需要你手写:

int maxRetries = 4; int attempt = 0; long waitMs = 50; while (attempt < maxRetries) { try { insertBatch(records); break; } catch (RetryableException e) { attempt++; long sleepMs = waitMs * (1L << (attempt - 1)); sleepMs += ThreadLocalRandom.current().nextLong(0, sleepMs / 5); Thread.sleep(sleepMs); } }

这里的退避用的是指数增长加 20% 抖动,正是上一节讲的夭折-等待落地到客户端时应该有的样子。

5.4 监控事务完成率的几个观测点

怎么确认这两个机制在工作?最简单的是看客户端日志。如果日志里频繁出现某个固定错误码,且每次重试前都有指数退避的间隔,说明夭折-等待在起作用。如果看到连续重试且间隔固定、没有抖动,说明客户端配置需要调整。

服务端层面,TDengine 3.x 提供了监控采集组件 taosKeeper,它会定时把系统表和 vnode 的健康状态上报到 Prometheus,重点看这几类指标:

  • 写入请求总数
  • 写入失败总数
  • 写队列深度
  • WAL 同步耗时
  • 合并任务状态

如果写队列深度长时间处于高位,且失败数同时上升,那大概率是无效事务太多,需要回去检查 KEEP 设置和业务补数时间范围。反过来,如果队列深度很低但失败率很高,也有可能是客户端退避策略没有生效,大量请求打到服务端就被快速 abort,此时要优化客户端侧的逻辑。

最后再分享一个我的习惯:批量补数前,先用 SQL 查一下目标表的数据范围,确认要写入的时间区间没有被 KEEP 吃掉,从源头减少无效事务。比如:

SELECT FIRST(ts), LAST(ts) FROM power.d1001;

如果 FIRST(ts) 已经比你打算补录的时间点还晚,那就说明这批数据大概率会失败,没必要让资源白白耗在重试上。把这一步放在任务调度里做前置校验,能省掉大量无效的夭折-等待循环。

数据有效期和夭折-等待策略这两件事,单独看都不复杂,但放在一起,恰好构成了时序数据库在海量写入时保持稳定性的两条腿:KEEP 负责让无效数据活着的时候不浪费资源,夭折-等待负责让无效请求失败的时候不拖垮系统。配置好它们,事务完成率自然就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询