时间戳为什么要单独讲

PipeMonitor 的时间戳横跨三个运行环境:STM32F103 设备侧、Node.js 服务端、Flutter 客户端。三者的时钟来源、精度、坐标系都不一样——设备侧只有开机后的单调递增计数器,服务端有 NTP 同步过的系统墙钟,客户端拿到的是服务端转出的 ISO 字符串。如果不把每一段的语义边界讲清楚,很容易在”查不到重启前的数据""曲线时间差 8 小时""ack 帧的 ts 和 tele 的 ts 对不上”这类问题上反复踩坑。

这篇把 ts 字段从设备发出、服务落地、到客户端展示的整条链路拆开,记录每一处转换的理由。内容相对薄,所以顺带把”为什么不让设备自己做 NTP 同步”这个设计决策也放进来讨论。

设备端的 ts:uptime 而非 Unix 时间戳

STM32F103VC 上没有电池供电的 RTC,也没有 SNTP 客户端。固件里唯一可靠的时间源是 rt_tick_get() 换算出的开机秒数。上行帧的 ts 字段直接取自采样时刻的 sample_timestamp

/* tele 帧的 ts 来自采样时刻的 sample_timestamp */
n = rt_snprintf(out, out_size,
                "{\"t\":\"tele\",\"ts\":%ld,\"seq\":%lu,\"dev\":\"%s\","
                "\"flow\":%s,\"total\":%s,\"v\":%s,\"pres\":%s,"
                "\"temp\":[%s,%s,%s,%s,%s,%s,%s,%s,%s],"
                "\"heart_count\":%lu,\"valid\":%lu}\n",
                (long)m->sample_timestamp,
                (unsigned long)seq,
                UPLINK_DEVICE_ID,
                flow_s, total_s, vel_s, pres_mpa_s,
                t_s[0], t_s[1], t_s[2], t_s[3], t_s[4], t_s[5], t_s[6],
                t_s[7], t_s[8],
                (unsigned long)m->heart_count,
                (unsigned long)valid_bits);

sample_timestamp 是固件在采样线程里用 rt_tick_get() / RT_TICK_PER_SECOND 算出的开机秒数。它的关键特性是:

  • 单调递增,不会回退,适合做单次会话内的相对时间排序。
  • 重启归零,每次 MCU 复位后从 0 重新累加。
  • 不是 Unix 时间戳,和墙钟时间没有任何对应关系。

告警帧的 evt->timestamp 同样来自这个开机秒数。所以同一个 ts=120,在两次重启之间指的可能是完全不同的物理时刻。这一点直接决定了服务端不能用 ts 做跨重启的时间范围查询。

服务端的双时间戳:received_at 与 payload_ts

服务端在 db.jsinsertMeasurement 里把两个时间分别落到不同列:

async function insertMeasurement(pool, result) {
  const payload = result.payload;
  await pool.execute(
    `INSERT INTO measurements
       (device_id, topic, payload_ts, seq, flow, total_value, velocity, pressure,
        temperature_json, valid_mask, payload, received_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     ON DUPLICATE KEY UPDATE
       topic = VALUES(topic),
       flow = VALUES(flow),
       ...
       received_at = VALUES(received_at)`,
    [
      result.device,
      result.topic,
      integerOrNull(payload.ts),   // 设备 uptime,原样落库
      integerOrNull(payload.seq),
      ...
      toMysqlDateTime(result.receivedAt),  // 服务端墙钟时间
    ],
  );
}
  • payload_tsBIGINT,直接存 payload.ts 的整数值,保留设备原始 uptime,可空。
  • received_atDATETIME(3),服务端收到这一帧的时刻,毫秒精度,UTC,NOT NULL

received_at 的值来自 API 在 MQTT 消息回调里生成的 result.receivedAttoMysqlDateTime 把它切成 MySQL 能接受的 YYYY-MM-DD HH:mm:ss.sss 格式:

function toMysqlDateTime(value) {
  const date = value ? new Date(value) : new Date();
  return date.toISOString().slice(0, 23).replace("T", " ");
}

toISOString() 输出 UTC,slice(0, 23) 截到毫秒位,replace("T", " ") 把 ISO 分隔符换成 MySQL 的空格。配合连接池的 timezone: "Z",整条写入链路对齐到 UTC:

const pool = mysql.createPool({
  host: config.host,
  port: config.port,
  database: config.database,
  user: config.user,
  password: config.password,
  waitForConnections: true,
  connectionLimit: config.connectionLimit,
  timezone: "Z",   // 强制按 UTC 存取,避免容器时区不一致
});

保留 payload_ts 的目的不是查询,而是设备侧行为分析:当需要复盘某次重启周期内设备在 uptime 第几秒触发了告警、采样间隔是否漂移、补传帧的原始时序如何,payload_ts 提供了设备视角的相对时间。它和 seq 一起还能拼出三元唯一索引 (device_id, seq, payload_ts) 做补传去重——这块在 MySQL 存储层 里详细讲过,这里不重复。

为什么查询用 received_at 而非 payload_ts

历史查询接口的 from/to 参数代表”用户想看哪段时间的数据”。用户的语义是墙钟时间(“昨天下午 3 点到 4 点”),不是设备 uptime。buildQuery 把时间过滤绑到 received_at

function buildQuery(query) {
  const clauses = [];
  const params = [];

  if (query.device) {
    clauses.push("device_id = ?");
    params.push(query.device);
  }

  // 使用服务器接收时间 received_at 过滤,而不是设备上报的 payload_ts。
  // STM32 设备上报的 ts 是开机后的秒数(uptime),不是 Unix 时间戳,
  // 设备重启后 ts 会重置,导致无法查询到重启前的历史数据。
  if (query.from !== null) {
    clauses.push("received_at >= FROM_UNIXTIME(?)");
    params.push(query.from);
  }

  if (query.to !== null) {
    clauses.push("received_at <= FROM_UNIXTIME(?)");
    params.push(query.to);
  }
  // ...
}

注释把原因写得很直接:payload_ts 会随重启归零,用 payload_ts >= 120 过滤会把所有重启后 uptime 超过 120 秒的数据全捞出来,和用户想要的”某墙钟时刻之后”完全对不上。received_at 是服务端打的时间戳,不受设备重启影响,且和用户本地时钟处于同一坐标系(都是墙钟),过滤语义正确。

这里有个隐含的精度损失:received_at 是”服务端收到帧的时刻”,不是”设备采样的时刻”。两者之间隔了 MQTT 上行链路延迟(DR154 4G 模块 + broker),典型在百毫秒到几秒量级。对 PipeMonitor 这种分钟级采样的流量监测场景,这个延迟可以忽略;如果未来切到秒级高频采样,再考虑在设备端引入 RTC 或 SNTP 打上真实采样时间。

FROM_UNIXTIME 绑定转换

客户端传来的 from/to 是 Unix 秒(parseTimestamp 把字符串统一转成整秒),而 received_atDATETIME(3) 列。两者不能直接比较,需要 FROM_UNIXTIME(?) 把 Unix 秒转成 MySQL 的 DATETIME

if (query.from !== null) {
  clauses.push("received_at >= FROM_UNIXTIME(?)");
  params.push(query.from);
}

parseTimestamp 同时接受纯数字字符串和 ISO 字符串:

function parseTimestamp(value) {
  if (typeof value !== "string" || !value.trim()) return null;

  const numeric = Number(value);
  if (Number.isFinite(numeric)) {
    return Math.trunc(numeric);   // 数字按 Unix 秒处理
  }

  const parsed = Date.parse(value);
  return Number.isFinite(parsed) ? Math.trunc(parsed / 1000) : null;
}

Date.parse 返回毫秒,除以 1000 转秒。FROM_UNIXTIME 在 MySQL 内部按当前 session 的 time_zone 解释 Unix 秒,由于连接池设了 timezone: "Z",转换按 UTC 进行,和 received_at 的存储时区一致——这是 timezone: "Z" 必须设的关键原因,否则容器若跑在东八区,FROM_UNIXTIME 会把 Unix 秒按 +08:00 转成 DATETIME,和 UTC 存的 received_at 差 8 小时,查询结果全错。

查询结果回传时,mapMeasurementRowreceived_at 转回 ISO 字符串:

function mapMeasurementRow(row) {
  const payload = parseJson(row.payload);
  return {
    id: Number(row.id),
    device: row.device_id,
    topic: row.topic,
    ts: row.payload_ts,          // 设备 uptime,原样回传
    seq: row.seq,
    ...
    receivedAt: toIsoString(row.received_at),  // UTC ISO 字符串
  };
}

toIsoStringDate.toISOString(),输出带 Z 后缀的 UTC ISO 字符串。这样服务端出口的时间是明确的 UTC,客户端拿到后自行转本地时区。

Flutter 端的 toLocal() 转换

Flutter 客户端拿到 receivedAt 后,timestamp.dartparseServerReceivedAt 负责解析并转本地时区:

/// 服务端 `receivedAt` 解析:服务端在 [state.js] 里用 `new Date().toISOString()`
/// 生成 UTC ISO 字符串;这里把它解析回本地时区。
///
/// 设备侧 `ts` 字段当前是开机以来的 uptime 秒数(不是 Unix 时间戳),
/// 重启会归零。所有需要"墙钟时间"的地方都应优先取 `receivedAt`
/// 仅在没有外层信封时降级到设备 `ts`
DateTime? parseServerReceivedAt(Object? raw) {
  if (raw is String && raw.isNotEmpty) {
    final parsed = DateTime.tryParse(raw);
    if (parsed != null) return parsed.toLocal();
  }
  return null;
}

DateTime.tryParse 解析带 Z 后缀的 ISO 字符串时,返回的是 UTC 类型的 DateTimeisUtc == true)。直接 toString() 会显示成 2026-06-10 07:30:00.000Z,对用户不友好。toLocal() 把它转成当前设备时区的 DateTime,之后再 DateFormat 格式化就是本地时间。

文档注释里那句”所有需要墙钟时间的地方都应优先取 receivedAt,仅在没有外层信封时降级到设备 ts”是给后续维护者的明确约束:设备 ts 不是墙钟时间,不能直接拿来当时间轴用。历史曲线页 history_page.dart 的 X 轴正是用 receivedAt 转出的 DateTime 作为 FlSpot.x(毫秒时间戳),而不是 payload_ts——这一点在 fl_chart 历史曲线 里有详细实现。

为什么不让设备做 NTP 同步

讲到这里自然会问:既然设备 ts 是 uptime 这么麻烦,为什么不直接给 STM32 加个 SNTP 客户端,让它上报 Unix 时间戳?这是个合理的设计选项,但在当前项目约束下被否掉了,原因有几层。

硬件约束。STM32F103VC 主板没有外挂电池 backed RTC,断电后系统时钟归零。即便软件层面跑 SNTP,每次冷启动后到 SNTP 同步成功之前,time() 返回的值都是不可信的。PipeMonitor 的设备通过 DR154 4G 模组上 MQTT,DR154 拨号注册网络本身需要 20-40 秒,SNTP 请求还要再走一遍 DNS 解析和 UDP 往返,冷启动后至少一分钟内设备拿不到真实时间。这段时间内的上行帧 ts 仍然是 uptime 语义。

网络路径约束。DR154 作为 4G 模组,数据面走运营商 APN,未必开放公网 UDP 53/123。SNTP 需要 UDP 出站到 NTP 服务器,如果 APN 做了 NAT 或端口限制,SNTP 可能直接失败。即便改用 NTP over TCP 或 HTTP 时间接口,又引入新的依赖和失败模式。

精度需求错位。PipeMonitor 是流量监测,采样间隔在分钟级,曲线展示最小分度 30 秒。服务端 received_at 相对真实采样时刻的百毫秒级延迟完全可接受。引入 SNTP 带来的复杂度(冷启动同步窗口、同步失败降级、RTC 漂移补偿)换不来任何业务价值。

服务端兜底更简单。服务端有 NTP 同步过的系统时钟,收到帧即打时间戳,单点维护、全局一致。设备侧只需保留单调 uptime 做会话内排序,跨重启的对齐交给服务端。这种”设备简单、服务端复杂”的分工在嵌入式物联网里是成熟做法,比每台设备各自维护墙钟可靠得多。

代价是 received_at 只能反映”服务端收到时刻”,无法精确还原设备采样瞬间。如果未来业务需要亚秒级的采样时刻精度(比如振动分析),再考虑加 RTC 硬件 + SNTP,并在帧格式里新增一个 sampled_at 字段。

跨端时间语义对齐的工程取舍

把三端的转换链路画出来:

设备 uptime 秒 ──ts──▶ 服务端 payload_ts (BIGINT, 原样保留)

服务端 new Date() ──receivedAt──▶ received_at (DATETIME(3), UTC)

                  FROM_UNIXTIME(?) ◀─ from/to (Unix 秒, 客户端)

                  toIsoString ──▶ receivedAt (ISO UTC 字符串)

                  Flutter DateTime.tryParse + toLocal ──▶ 本地时区展示

几个关键取舍:

  • 设备侧不维护墙钟,只输出单调 uptime。简单、可靠、零冷启动成本,代价是跨重启时间不可比。
  • 服务端双时间戳并存received_at 承担所有查询和展示,payload_ts 只用于设备侧行为分析和去重索引。两者职责分离,不混用。
  • UTC 作为跨端传输基准:服务端存 UTC、出口 ISO 带 Z,客户端自行 toLocal()。避免在传输层做时区转换导致歧义。
  • timezone: "Z" 必须显式设:mysql2 默认会用本地时区解释 DATETIME,容器时区不一致会让 received_atFROM_UNIXTIME(?) 错位。

小结

  • 设备 ts 是开机 uptime 秒,单调递增但重启归零,不是 Unix 时间戳;告警和遥测帧共用同一来源。
  • 服务端 payload_ts 原样保留设备 uptime,用于设备侧行为分析和三元去重索引,不参与墙钟时间范围查询。
  • received_at 是服务端 UTC 墙钟时间DATETIME(3) 毫秒精度,由 new Date().toISOString() 生成,是所有时间查询和展示的基准。
  • 查询用 received_at 而非 payload_ts:设备 uptime 重启归零,跨重启查询会得到错误结果。
  • FROM_UNIXTIME(?) 配合 timezone: "Z":连接池强制 UTC,保证 Unix 秒到 DATETIME 的转换和存储时区一致。
  • Flutter 端 toLocal() 转本地时区:服务端出口是 UTC ISO 字符串,客户端解析后转本地展示,时区转换只在展示层做。
  • 不做设备 NTP 同步:硬件无 RTC、4G 路径未必支持 UDP、业务精度只需分钟级,服务端兜底打时间戳更简单可靠。

后续阅读