TDengine 上手:三段 Python 脚本先把建表口径验掉,再谈建库 「建表五分钟还债两年」——时序库出问题的地方几乎都不在写入速度而在建库那天没写清的口径。这篇给三段不依赖第三方库的脚本第一段校验标签字典并算出子表清单第二段验证窗口是不是真的按网格切第三段把写入批次与补传乱序分开处理。手边有 Python 3.8 就能跑全程不连数据库所以能在提建库申请之前把该吵的吵完。一、字段进列还是进标签让配置说话判据只有一条在单条曲线的历史里这个值会不会反复变。会变的是测量值进列几乎不变的是身份进标签。把字典写成配置的最大收益不是好看而是脚本能替你查出三类低级错误——标签用了浮点取值域会失控、字段名大小写混杂不同平台对标识符的处理不一致迁移时最麻烦、同一台设备的写法不统一。建库前自检标签字典 - 字段问题 子表清单仅用标准库。importre# (字段名, 类型, 是否作为标签)FIELDS[(device_id,BINARY(32),True),(plot_id,BINARY(24),True),(crop_code,SMALLINT,True),(temp,FLOAT,False),]NAME_MAX48# 子表名会长进日志和排查命令行太长迟早被截断devices[(DEV-0007,P0107,3),(DEV-0008,P0107,3),(DEV-0009,P0108,5),(dev-0009 ,P0108,5),# 尾部空格在库侧会变成另一台设备]deffield_issues(fields):out[]forname,typ,is_taginfields:ifis_tagandtyp.startswith((FLOAT,DOUBLE)):out.append(name: 标签不能是浮点)ifnotre.fullmatch(r[a-z][a-z0-9_]*,name):out.append(name: 只留小写字母、数字与下划线)returnoutdefsub_name(dev):归一化必须与接入服务完全一致否则自检结果和库里的真实子表数对不上。returnenv_re.sub(r[^0-9a-z],,dev.strip().lower())names[sub_name(d[0])fordindevices]print(字段问题,field_issues(FIELDS)or无)print(子表候选 {} 个超长 {} 个.format(len(names),len([nforninnamesiflen(n)NAME_MAX])))print(归一后重复 {} 个{}.format(len(names)-len(set(names)),sorted({nforninnamesifnames.count(n)1})))# expect: 子表候选 4 个超长 0 个三行输出就是建表前的一张验收单。第三行最容易被跳过「归一后重复」不为零意味着同一台设备将来对应两张子表两条曲线互不相接这种错误在几万行数据里肉眼看不出来只有归一化之后再比对才现形。落地时把这段挂在每日对账里跑命中就回接入层堵写法别在库里删子表——删表会把历史曲线一起带走。二、建库语句里四个参数别吃默认值超级表怎么切、子表怎么建落到 DDL 只有下面这一屏。参数每个都显式写出来CREATEDATABASEagri_tsPRECISIONms-- 设备上报到秒留毫秒是为了补传按原时刻归位KEEP730-- 原始层保留天数按对账周期倒推DURATION10-- 单个数据文件覆盖的时长决定清理粒度BUFFER256-- 写入缓冲REPLICA1;-- 单副本节点不足时多副本建库直接失败CREATESTABLE env_metrics(tsTIMESTAMP,-- 采集时刻子表主键补传时保留原值rx_timeTIMESTAMP,-- 入站时刻平台落库那一刻每条不同tempFLOAT,humidityFLOAT,luxINT)TAGS(device_idBINARY(32),plot_idBINARY(24),crop_codeSMALLINT);-- 建子表不写字段清单只给标签值INSERTINTOenv_d0007USINGenv_metrics TAGS(DEV-0007,P0107,3)VALUES(2026-03-01 00:00:12,2026-03-01 00:00:13,20.1,63.5,1800);四个参数的分工值得单独记精度决定补传能对到哪一位保留天数直接决定过期清理是扔掉整个数据文件、还是逐行扫描重写后者通常落在凌晨的运维窗口里文件覆盖时长把「删旧数据」的代价从表大小解耦副本数要看实际可用节点节点不够时建库会直接报错而不是降级。另外这些参数的单位在版本之间改过KEEP 历史上出现过按天的写法与按分钟的写法上生产前拿当前版本的手册对一遍别照抄两年前的博客。三、拉一年的曲线窗口起点不是 WHERE 里的时间图表接口最容易错的地方就在这里。桶的边界只看「零点网格加偏移」跟 WHERE 里的起止时刻没有关系要按本地凌晨换日就得把偏移自己算出来写成常量绝不能让客户端时区掺进来——两个服务的时区设置一旦不同同一条查询会切出两套桶两块看板各显示一套数字。第二条脚本把两种切法摆在一起算错开多少秒能直接打印出来窗口切分对比按查询起点开窗 vs 按 epoch 网格开窗仅用标准库。fromdatetimeimportdatetime,timezone W3600# 窗口宽度1 小时OFFSET8*3600# 偏移按东八区零点换日显式写死COUNT12FMT%Y-%m-%d %H:%M:%Sdefep(s):returnint(datetime.strptime(s,FMT).replace(tzinfotimezone.utc).timestamp())defshow(sec):returndatetime.fromtimestamp(sec,timezone.utc).strftime(FMT)defon_grid(sec):向下贴到 (epoch OFFSET) 的整窗网格负数偏移也成立。returnsec-(sec-OFFSET)%W startep(2026-03-01 00:37:20)by_query[starti*Wforiinrange(COUNT)]# 从查询起点开始数by_grid[on_grid(start)i*Wforiinrange(COUNT)]# 先在网格上定位shiftabs(by_query[0]-by_grid[0])crossed[iforiinrange(COUNT)ifby_query[i]!by_grid[i]]# 逐个窗口比对边界print(查询起点 {}网格起点 {}.format(show(start),show(by_grid[0])))print(两套窗口错开 {} 秒{} 个窗口边界不一致.format(shift,len(crossed)))print(网格首个窗口结束于,show(by_grid[0]W))# expect: 两套窗口错开 2240 秒12 个窗口边界不一致错开的这 2240 秒就是两套统计的差值来源不是精度问题只要按查询起点开窗同一个整点数据每次被查都可能落进不同的桶昨天和今天的「日均」自然对不上。有夏令时的地方更不要拿本地时钟对齐那天的钟表有一段跳过、也有一段重复换成日窗就会变成 23 小时或者 25 小时。四、写入侧分桶提交乱序走补传批量写、按子表分桶、乱序不丢这三件事在同一条链路里。第三段脚本按采集时刻判乱序回退的点不进主批次单独记下来走补传通道批次构造按子表分桶、按采集时刻分流补传点仅用标准库。fromdatetimeimportdatetime,timezone BATCH2FMT%Y-%m-%d %H:%M:%Srows[(DEV-0007,2026-03-01 00:00:12,20.1),(DEV-0008,2026-03-01 00:00:20,19.4),(DEV-0007,2026-03-01 00:01:12,20.3),(DEV-0007,2026-03-01 00:00:40,20.2),# 补传采集时刻早于水位(DEV-0008,2026-03-01 00:01:20,19.6),(DEV-0007,2026-03-01 00:02:12,20.5),(DEV-0008,2026-03-01 00:02:20,19.8),]defep(s):returndatetime.strptime(s,FMT).replace(tzinfotimezone.utc).timestamp()watermark,buckets,late{},{},[]fordev,ts,valinrows:secep(ts)ifsecwatermark.get(dev,float(-inf)):# 采集时刻回退 穿过了水位late.append((dev,ts))continuewatermark[dev]sec buckets.setdefault(dev,[]).append((ts,val))batches[buckets[d][i:iBATCH]fordinsorted(buckets)foriinrange(0,len(buckets[d]),BATCH)]print(子表 {} 张批次 {} 个乱序 {} 条.format(len(buckets),len(batches),len(late)))print(各批次行数,[len(b)forbinbatches])print(乱序明细,late)# expect: 子表 2 张批次 4 个乱序 1 条注意水位判断是逐子表的不是全局全局水位会被上报最频繁的那台设备带偏把正常到达的点误判成补传。反过来补传点也不要直接丢——断网两小时再恢复那批数据本来就该按原采集时刻落库丢掉等于让曲线永久缺一段。批次大小也别一味往上加单批几千行看着省往返一旦中途失败重传锁冲突和重复写一起出现反而不好收拾。五、图表接口只留一个模板参数不进 SQL 字符串看板上的每个粒度都对应一句查询但 SQL 本身不该由前端拼。做法是把模板固化在后端只留三个占位符时间下界、时间上界、标签值库名、表名、窗口宽度走白名单。这一段同样能离线自测第四条脚本把「参数是否落在白名单」和「占位符数量」都验一遍查询模板生成与参数白名单校验SQL 不拼接业务入参仅用标准库。TPL(SELECT _wstart AS win_start, tbname, AVG(temp) AS avg_temp, MAX(humidity) AS max_hum, COUNT(*) AS pts FROM {db}.{stable} WHERE ts ? AND ts ? AND {tag} ? GROUP BY tbname INTERVAL({interval}, {offset}) SLIDING({slide}) FILL(LINEAR))DB_OK{agri_ts}STABLE_OK{env_metrics,soil_metrics}TAG_OK{device_id,plot_id,crop_code}# 只允许标签列做过滤defbuild(db,stable,tag,interval,offset,slide):ifdbnotinDB_OKorstablenotinSTABLE_OKortagnotinTAG_OK:raiseValueError(标识符不在白名单)returnTPL.format(dbdb,stablestable,tagtag,intervalinterval,offsetoffset,slideslide)sqlbuild(agri_ts,env_metrics,plot_id,1h,8h,10m)print(占位符 {} 个绑定顺序时间下界 / 时间上界 / 标签值.format(sql.count(?)))print(窗口参数:,[kforkin(1h,8h,10m)ifkinsql])try:build(agri_ts,env_metrics,ts,1h,8h,10m)print(白名单失效)exceptValueErrorasexc:print(非法过滤列已拦下:,exc)# expect: 占位符 3 个模板里三件事必须同时写出来缺一件曲线就带瑕疵GROUP BY tbname让每台设备各算各的INTERVAL带偏移负责边界SLIDING让相邻窗口重叠否则曲线出现台阶FILL显式声明空窗口怎么补——留给前端补前后端就会补出两套结果。窗口边界不变形的代价是偏移量要跟着换日规则走按本地零点换日就写 8 小时偏移跨夏令时的地区别用本地时间算。六、三个高频故障的对照现象根因修法某条曲线中途断成两段标签写法不一致同一台设备落了两张子表接入层归一化 每日巡检见第一节断网恢复后出现一根尖峰采集时刻与入站时刻混在一列拆两列补传保留原时刻看板一刷新就慢写入跟着超时图表直接打原始层扫表把 IO 挤满服务端聚合常用粒度提前物化六、跑完这三段之后再动手三段脚本的输出凑齐了建库就只剩执行字段问题为空、归一后重复为零、窗口边界与网格一致、乱序点有独立通道。剩下两个动作别省——把子表命名规则写进接入层的入参校验把降采样任务的失败告警接上。任务静默失败比不做更糟看板照样出图只是那根线停在上周三。设备侧的协议适配与断网缓存属于网关的活交给皓辰的边缘网关处理平台侧只认列与标签换厂商不用重建模。