行业背景:这个问题现在为什么被反复问到
农业数据的形态其实很死,选型的讨论却常常停在「哪个库写入快」。 一块地或一个大棚上挂着若干测点——温湿度、光照、土壤墒情、二氧化碳,按分钟级周期上报:只追加、几乎不更新,单条记录离开上下文几乎没有价值,查询以时间范围和聚合为主。形态这么规整,选型本该是简单题。
难的地方不在数据本身,而在「谁的数据」。 设备、地块、棚区、作物、批次描述的是这条记录属于谁,不是被测量的量。它们在同一测点里每行都相同,却决定了这条记录和谁放在一起看。当成普通列存,身份信息跟着每个采样点重复一遍;编进表名,表名就开始承担 schema 的职责。
这两年真正变的是规模。 早先几十个测点、十几台设备,一台设备一张宽表还能应付;现在一个县域项目动辄上千个测点,宽表加测点就是加列,加列要变更表结构,夜间窗口未必够用。关系型加索引和分区能撑一阵,但到十亿行量级,取一年曲线的执行计划开始走偏,扫一次表就能把写入的 IO 挤掉。
成本在建表那一刻就定死了。 决定三年后好不好用的不是数据库品牌,而是建表时定下的三件事:超级表按什么切、哪些字段做标签、历史数据按什么粒度留存。所以本文只讲这三件事——它们定了,换哪个库都得面对。
痛点:现场到底卡在哪几步
第一卡:一张表装下所有设备,加一个测点就要动全表。 起步时温湿度加光照三列够用,接土壤墒情要加列,接二氧化碳又加列,每次都得在夜间窗口改表结构,改完还担心旧代码里的插入语句少了字段。于是有人反过来做——一台设备一张表。表是拆开了,表名却开始承担 schema 的职责:东区三号棚温湿度 这种名字进了代码,下次改棚号就要改代码、改表名、改历史数据。
第二卡:设备的身份写进了数据列。 设备编号、地块名、作物批次,在同一个测点的每一行里都一模一样。写进去看着直观,代价有两个:它们跟着每个采样点重复存储,压缩比被几乎不变的长字符串吃掉;查「这台设备最近怎么样」得先 group by 设备编号。量小无感,到了十亿行,本该走索引的查询变成扫表。
第三卡:原始数据直接喂图表。 小范围拉原始点还好,可看板上的「近一年」一打开,一次请求扫的点数比整个库的写入量还大,扫的那几秒打满磁盘 IO,同期上报开始排队、超时、重传,重传又放大写入量,滚成小雪崩。图表越丰富,后端越危险。
第四卡:时间语义没定死,补传那天数据全歪。 时间至少有两个:设备采集那一刻,平台收到那一刻。混用一个字段,设备断网两小时再补传,两小时的数据会挤在恢复的那一分钟里,曲线上多出一根不存在的尖峰。精度与时区同理——毫秒和微秒混写,按小时窗口统计就漏掉半秒里的行;一边按本地时间切窗口、一边按 UTC 存,跨时区或夏令时那天曲线就缺一段。
四个卡点没有一个能靠换数据库解决。 它们全部来自建表之前没定下的口径:一类测点几张表、身份信息放哪、原始数据留多久、时间用哪个字段。口径不定,迁移只是把同一个错误换个引擎重写,还带着历史数据一起错。
方案:可落地的结构与取舍
结构三句话能说完,先说这三句。 一个业务域一个数据库;同一类测点收在一个超级表里,列是指标、标签是维度;每一个测点对应一张子表,子表与超级表共用同一套列定义,靠标签值唯一确定「这条曲线是谁的」。建子表不写字段清单,只给标签值——这就是它与「一台设备一张表」在运维上的真正差别:加一个测点,从「夜里改表结构」变成「插一行数据」。
列和标签的区别不是写法,是生命周期。 列里的值随采样不断变,每个点写一遍;标签里的值随子表存一份,写一次之后基本不动。判断标准也直白:这个字段会不会在同一个测点的历史里反复变化?会,进列;不会,进标签。由此推出一条最容易被违反的规则——标签值必须稳定。用不会改的业务内部 ID,不要用「东区三号棚」这种会改名的词:棚区合并、编号重排那天,改一个标签值等于让一批子表搬家,历史曲线当场断成两段。标签基数也要克制,几十种作物是维度,每一株苗是灾难。
三种切法的取舍,摆到一块比更清楚。
| 做法 | 加一个测点 | 查一台设备 | 存储与压缩 |
|---|---|---|---|
| 一刀切宽表 | 加列、变更表结构,得等停机窗口 | 直接查,最快 | 稀疏列多,压缩比被拖垮 |
| 一设备一表、无超级表 | 新建表并改代码 | 按表名查,但表名开始承担 schema 职责 | 单表小、表数量爆炸,元数据开销上去 |
| 超级表 + 子表 | 写一行数据,其余不动 | 按标签过滤,不必知道表名 | 同类数据同构,压缩有基础 |
子表命名只解决可读性,不解决唯一性。 建议 <测点类型前缀>_<稳定ID>:前缀表达是什么数据、ID 表达是谁的,长度控制在几十字符内——名字会进日志和排查命令行,太长迟早被截断。但重复子表不是靠命名规范挡住的,是靠「标签值唯一」挡住的,别指望编码规则兜数据质量。
分层是这套结构能长期活着的前提。 原始层存高精度短保留,聚合层存 1 小时与 1 天粒度、长保留。聚合是固定窗口现算还是提前落成物化子表,按查询频次定:每天被拉几千次的看板曲线落下来,偶尔看一眼的维度现算更省存储。保留策略与分块时长要在建库时显式指定,别吃默认值——原始层留多久由对账和合规要求决定,而分块粒度决定删旧数据时整块丢弃还是要重写。
技术实现:字段、接口与数据口径
建库这一步就把口径写死。精度、保留时长、分块时长、副本数全部在 CREATE DATABASE 里显式给出来,不要吃默认值:它们决定了删旧数据时是整块文件丢弃还是要扫描重写,以及补传最多能回溯多远。
-- 非可运行代码,仅作设计示意(参数取值按项目对账与合规要求自定)
CREATE DATABASE agri_metrics
PRECISION 'ms' -- 时间精度:设备上报到秒,留毫秒是为了补传能按原时刻对齐
KEEP 730 -- 原始层保留期,按天计(旧版本单位是分钟,升级前先对一遍文档)
DURATION 10 -- 每个数据文件覆盖的时长,决定删旧数据的粒度
BUFFER 256 -- 写入缓冲,批量写到这个量级才有意义
REPLICA 1; -- 单副本;要多副本得先有那么多可用节点,否则建库直接失败
超级表的名字表达「一类语义」,子表的名字表达「一条曲线」。列是指标,随采样不断变;标签是维度,一个子表里恒定。建子表只给标签值、不写字段清单——「加一个测点」于是从夜里改表结构变成插一行数据。
-- 非可运行代码,仅作设计示意:一类测点一个超级表
CREATE STABLE env_metrics (
ts TIMESTAMP, -- 采集时刻:子表内主键,窗口切分与乱序判断都看它
rx_time TIMESTAMP, -- 入站时刻:平台落库那一刻,只用于链路与时延统计
temp FLOAT, -- 温度
humidity FLOAT, -- 相对湿度
lux INT -- 光照
) TAGS (
device_id BINARY(32), -- 稳定设备 ID,不要用会改名的业务编号
plot_id BINARY(24), -- 地块/棚区
crop_code SMALLINT -- 作物编码
);
-- 写第一行数据的同时把子表建出来:只给标签值,不给列清单
INSERT INTO env_metrics_d0007 USING env_metrics
TAGS ('DEV-0007', 'P0107', 3)
VALUES ('2026-03-01 00:00:12', '2026-03-01 00:00:13', 20.1, 63.5, 1800);
字段口径列成表,比写在注释里管用,建表评审时逐行确认的就是这张表:
| 字段 | 类型 | 语义 | 谁写入 |
|---|---|---|---|
ts | TIMESTAMP | 采集时刻(设备时钟),子表主键;补传时保留原始采集时刻 | 设备上报;网关补传不改原值 |
rx_time | TIMESTAMP | 入站时刻(平台落库那一刻),每条都不同 | 接入服务统一打,设备不负责 |
temp / humidity / lux | FLOAT / FLOAT / INT | 指标值,每个采样点写一次 | 设备 |
device_id / plot_id / crop_code | 标签 | 维度;一个子表内恒定,改一次等于换身份 | 建子表时写一次 |
这两个时间字段回答三个不同的问题,混用一个就全歪。 判乱序用采集时刻:新到的 ts 早于该子表已写入的最大时间,就是补传来的乱序点,要走补传通道而不是当异常丢弃。判链路用入站时刻:按 rx_time 统计每秒入站条数,能直接看出断线重连时的尖峰。算端到端时延看两者之差的 P95 而不是平均值——平均值会被秒级上报的小时延拉平,看不出慢的那批设备。第二节那个「多一根尖峰」的卡点,根因就是这两个时间被合成了一个字段。
查询侧只留一个模板,图表接口照它填参数——按标签分组、固定窗口、滑动前移、空值填充四件事必须同时出现,少一件曲线就会有台阶或者断口。
-- 非可运行代码,仅作设计示意:图表接口用这一条,前端不碰原始层
SELECT _wstart AS win_start, -- 窗口起点(伪列),前端拿它当 x 轴,不要用返回行号猜
tbname,
AVG(temp) AS avg_temp,
MAX(humidity) AS max_hum,
COUNT(*) AS pts
FROM agri_metrics.env_metrics
WHERE ts >= '2026-01-01 00:00:00' AND ts < '2026-02-01 00:00:00'
AND plot_id = 'P0107'
PARTITION BY tbname -- 按标签分组:每台设备各算各的,窗口互不混
INTERVAL(1h, 0s) -- 1 小时翻转窗,偏移 0:从 epoch 零点切,不按查询起点切
SLIDING(10m) -- 滑窗:窗口每 10 分钟前移一格,相邻窗口重叠,曲线不带台阶
FILL(LINEAR); -- 空窗口线性插值;插值这件事显式写在 SQL 里,别留给前端
窗口边界由「epoch 零点 + 偏移量」决定,跟 WHERE 里的起点无关,这就是最容易踩的时区坑:想按本地凌晨换日,就把宽度和偏移量显式写出来、按 UTC 换算,别依赖客户端时区——两端时区配置不一致时,同一条 INTERVAL(1d) 会切出不同边界,看板对不上账。跨夏令时的地区尤其别用本地时间对齐:那天本地时间有一段不存在或重复,日窗会少或多一小时。
库里能算的交给 SQL,但有两个场景拿不到库:图表缓存的兜底校验,以及离线对账与补数。这段纯标准库的降采样在任何环境都能跑,口径与上面的窗口切法逐条对齐,尤其是空窗口必须显式计数——静默跳过等于把断网从报表里抹掉。
"""按固定窗口降采样并补齐空窗口:仅标准库,不连数据库。"""
from datetime import datetime, timezone
WINDOW_S = 300 # 5 分钟窗口
FMT = "%Y-%m-%d %H:%M:%S"
# (采集时刻, 值):00:05–00:15 之间断网,应出现连续两个空窗口
raw = [
("2026-03-01 00:00:12", 20.1),
("2026-03-01 00:01:40", 20.4),
("2026-03-01 00:03:05", 20.9),
("2026-03-01 00:04:50", 21.3),
("2026-03-01 00:15:30", 21.7),
("2026-03-01 00:21:10", 22.2),
("2026-03-01 00:23:40", 22.6),
]
def epoch(ts):
return int(datetime.strptime(ts, FMT).replace(tzinfo=timezone.utc).timestamp())
def window_start(sec):
"""向下取整到窗口边界:与 SQL 里 INTERVAL 从 epoch 零点切分同口径。"""
return sec - sec % WINDOW_S
points = sorted((epoch(ts), value) for ts, value in raw)
buckets = {}
for sec, value in points:
buckets.setdefault(window_start(sec), []).append(value)
first, last = min(buckets), max(buckets)
windows = [first + i * WINDOW_S for i in range((last - first) // WINDOW_S + 1)]
means = {w: (sum(buckets[w]) / len(buckets[w]) if w in buckets else None) for w in windows}
# 空窗口先计数,再用前后最近的有效窗口线性插值(两端缺邻居就沿用单侧值)
missing = [w for w in windows if means[w] is None]
filled = dict(means)
for w in missing:
i = windows.index(w)
left = next(((j, means[windows[j]]) for j in range(i - 1, -1, -1)
if means[windows[j]] is not None), None)
right = next(((j, means[windows[j]]) for j in range(i + 1, len(windows))
if means[windows[j]] is not None), None)
if left and right:
(j1, v1), (j2, v2) = left, right
filled[w] = v1 + (v2 - v1) * (i - j1) / (j2 - j1)
else:
filled[w] = (left or right)[1]
for w in windows:
mark = " <- 空窗口,已插值" if w in missing else ""
print(datetime.fromtimestamp(w, timezone.utc).strftime(FMT), round(filled[w], 2), mark)
print("窗口 {} 个,缺失 {} 个,原始点 {} 条".format(len(windows), len(missing), len(points)))
# expect: 窗口 5 个,缺失 2 个,原始点 7 条
写入侧三条,按被忽略的频率排序。批量写:一次带上几十上百行,一条一条发等于把网络往返当免费资源。按子表分桶:同一子表的数据先攒在一起再提交,别在一个批次里来回切表。乱序按补传通道走:采集时刻早于水位的点不要直接丢,也不要假设到达顺序等于采集顺序——补传那天必然乱序,按乱序落库的窗口统计会被撕成碎片。
查询侧只有一条原则:图表接口在服务端聚合,前端永远不直连数据库。看板需要的几个粒度各留一个查询模板,参数只放时间范围和标签值,SQL 不做字符串拼接。
项目实践:一次真实落地怎么走
落地比选型更没有想象空间:五步,顺序不能颠倒,每一步都有能当场验的判据。
一、口径评审:先吵清「什么算一台测点」,再动手建表。 交付物是两张清单——测点清单(有哪些量、周期、量纲)和标签字典(哪几个字段进标签、取值域、谁维护)。最容易扯不清的是网关与探头:一个网关挂多个探头时子表必须对齐探头,否则同一台网关下的量挤进一张子表,曲线分不出是哪一株。
二、建库与超级表:一个业务域一个库,同类测点一个超级表,标签一次定死。 这一天的所有决定——精度、保留期、分块时长、标签类型——以后每改一次都要付迁移的账。判据很硬:把「加一个测点」写成一条 INSERT 并跑通。
三、双写比对:新旧两套并行写满一个对账周期,按小时比条数、均值差、极值差。 两边必须用同一个时间字段(采集时刻),否则时延一入账就全在报警。差异超阈值不切流,先查是采集侧还是入库侧。这一步不是为了证明新库更快,是证明数据没丢、没重、没歪。
四、图表切流:先低频看板,再实时曲线。 低频看板一天被看几次,出问题丢人有限;实时曲线是值班盯着看的,切之前确认旧查询路径还在、开关能一秒切回,切完把两个入口的曲线并排对一次。
五、收尾三件事:降采样任务、保留策略、子表数量巡检。 降采样必须带失败告警——任务静默失败比不做更坏,看板照样有数,只是那是三天前的数。保留期按对账周期倒推。巡检按标签值 group by 数子表,数量对不上就是有脏标签。
三个坑都是真踩过的。 其一,标签值大小写与首尾空格不一致,同一台设备落成两张子表,曲线当场断成两段——接入层统一归一化,并加「同一设备 ID 对应多张子表」的告警。其二,补传历史数据没按采集时刻排序,乱序把窗口统计撕碎。其三,原始层保留期设得太短,对账还没结束数据已过期——保留期取「对账周期 + 一段改动缓冲」,别按存储成本压。
在皓辰的项目里,这套顺序是按周推进的:口径评审、双写对账、切流巡检各一周。代码量从来不是瓶颈,口径一致才是。
价值总结:判断标准与复用边界
这套结构切得对不对,不用问库,问四个问题就能自测。 一、加一个测点要几行 SQL?答「一条 INSERT,只给标签值」就是切对了;还要动表结构、改代码里的字段清单,说明仍是宽表思路。二、加一台设备要不要改表?要改,就是身份信息还躺在 schema 里。三、拉一年曲线扫多少数据?答案里应当出现「聚合层」;只能回答「扫原始点」,数据量再翻一倍就会演成第二节那个扫表打满 IO 的连锁反应。四、删历史数据要不要停写?要停,说明保留期与分块粒度当初没在设计里算过。四个问题里有两个答不上来,别急着换库,先把建表口径改对。
复用边界要一并说清,否则这套结构会被套到不适用地方。 前提只有三条:测点集合相对固定、写入以追加为主、查询以时间范围与聚合为主。三条都满足,超级表加标签能一直用到十亿行这个量级。反过来这几类不要硬套:需要跨多表事务的业务库;频繁更新一行的数据——时序库擅长追加,不擅长改;按任意维度即席分析的探索式查询;以及一年数据不到千万行的小系统,那时一张普通表加时间索引更省事。
最后一句,也是前面六节反复指向的那句:先定口径,再选库。 口径对了,换库只是一次迁移;口径错了,换库就是把同一个错误在新引擎上重写一遍,还得带着历史数据一起错。一类测点一个超级表、标签放稳定身份、原始层与聚合层分开留存——这三条不依赖特定产品。
