[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"article-gongyewulianwangyaoceshuanglianluyuandianweijiangerukujishushijian":3},{"id":4,"title":5,"slug":6,"summary":7,"content":8,"contentHtml":9,"wordCount":10,"readingTime":11,"categoryId":12,"tags":13,"coverImage":13,"thumbnail":13,"status":14,"isTop":15,"isRecommended":15,"allowComments":15,"password":13,"viewCount":16,"likeCount":15,"commentCount":15,"seoTitle":5,"seoKeywords":17,"seoDescription":18,"source":13,"sourceUrl":13,"publishTime":19},"2102319880609001474","工业物联网遥测双链路与按点位间隔入库技术实践","gongyewulianwangyaoceshuanglianluyuandianweijiangerukujishushijian","# 工业物联网遥测双链路与按点位间隔入库技术实践 > 文档类型：技术与架构实践分享 > 编写日期：2026-09-22 > 实践状态：SSE 实时推送与 InfluxDB 按点位间隔保存已完成现场功能验证 > 适用场景：设备高频遥测、实时大屏、时序数据持久化、Kafka 消费解耦 ## 1. 摘要 工业物联网平台经常同时面对两类目标： 1. 实时页面希望尽快看到设备最新值，不能被数据库写入速度影响； 2. 时序数据库需要按照不同点位的业务价值控制保存频率，降低写入量和存储成本，同时不能静默丢失应保存的数据。 本实践最终采用“同一原始遥测 Topic、两个独立 Kafka 消费组”的双链路架构：实时链路只负责最新状态投影与 SSE 推送，持久化链路独立完成按点位间隔判断、批量写入...","# 工业物联网遥测双链路与按点位间隔入库技术实践\n\n> 文档类型：技术与架构实践分享  \n> 编写日期：2026-09-22  \n> 实践状态：SSE 实时推送与 InfluxDB 按点位间隔保存已完成现场功能验证  \n> 适用场景：设备高频遥测、实时大屏、时序数据持久化、Kafka 消费解耦\n\n## 1. 摘要\n\n工业物联网平台经常同时面对两类目标：\n\n1. 实时页面希望尽快看到设备最新值，不能被数据库写入速度影响；\n2. 时序数据库需要按照不同点位的业务价值控制保存频率，降低写入量和存储成本，同时不能静默丢失应保存的数据。\n\n本实践最终采用“同一原始遥测 Topic、两个独立 Kafka 消费组”的双链路架构：实时链路只负责最新状态投影与 SSE 推送，持久化链路独立完成按点位间隔判断、批量写入 InfluxDB、水位提交和异常重试。两个链路共享无状态的标准化规则和点位映射缓存，但不共享消费位点、消费线程或下游成败状态。\n\n现场功能验证表明：\n\n- SSE 推送不再受点位入库间隔控制；\n- InfluxDB 保存间隔能够按型号点位或设备覆盖配置生效；\n- InfluxDB 链路的采样判断和写入不再占用实时消费组线程；\n- 设备点位映射通过本地缓存与 Redis 缓存减少了 PostgreSQL 重复查询。\n\n本次验证属于功能与链路正确性验证。文档不虚构生产吞吐量、P95\u002FP99 延迟或容量上限，正式容量结论仍应以压测和生产监控为准。\n\n## 2. 脱敏说明\n\n本文所有名称和数据均为通用化示例：\n\n- 项目、公司、客户和产品名称使用“某工业能源平台”代替；\n- Kafka Topic、消费组使用 `\u003Craw-topic>`、`\u003Crealtime-group>` 等占位符；\n- 租户、设备和点位使用 `tenant-demo`、`device-demo`、`temperature` 等示例值；\n- 数据库、Redis、InfluxDB、Nacos 地址和认证信息不展示；\n- 示例中的时间、数值、分区、Offset 均为构造数据；\n- 不包含生产日志原文、真实设备编码、Token、密码和客户业务数据。\n\n## 3. 业务背景\n\n假设设备网关每秒上报一次遥测，一条设备消息包含温度、压力、瞬时流量、累计流量、电能等多个点位。\n\n业务侧通常有不同要求：\n\n| 目标     | 典型要求                        |\n| ------ | --------------------------- |\n| 实时大屏   | 尽可能快速展示最新值，允许在一个推送窗口内合并旧值   |\n| 历史趋势   | 根据点位重要性每 5 秒、30 秒或 60 秒保存一次 |\n| 累计量与抄表 | 保留可靠时间戳和原始累计值，便于期间差值计算      |\n| 故障恢复   | 数据库短时不可用时不能让实时页面同步停顿        |\n| 性能     | 批量处理，避免每条消息、每个点位都查询关系数据库    |\n\n这里最容易混淆的是“设备采集频率”和“平台入库间隔”：\n\n- 设备采集频率决定上游多久产生一条数据；\n- 平台入库间隔只决定合法数据多久写入一次 InfluxDB；\n- SSE 使用实时数据，不读取入库间隔做降频。\n\n## 4. 方案演进与问题复盘\n\n### 4.1 串行单链路的问题\n\n早期链路可以抽象为：\n\n```text\n原始遥测\n  → 消息校验与点位映射\n  → 入库间隔判断\n  → 写 InfluxDB\n  → 更新实时投影\n  → SSE 推送\n```\n\n该结构的核心问题不是调用顺序本身，而是实时和持久化共用一个 Kafka 消费组、同一批线程和同一个 Offset 推进条件。\n\n当 InfluxDB 变慢、写入重试、Redis 水位操作耗时或点位映射频繁回源数据库时，当前批次无法及时完成，Kafka Offset 不能推进，后续实时消息也只能排队。外部表现通常是：\n\n- SSE 推送延迟；\n- InfluxDB 入库延迟；\n- Kafka Consumer Lag 持续增长；\n- 消费组频繁重平衡后延迟进一步放大；\n- 通过增加线程只能暂时缓解，不能消除故障传播。\n\n### 4.2 标准化 Topic 中转方案的取舍\n\n一种常见方案是在原始 Topic 后增加标准化 Topic，再由实时组和持久化组分别消费。它可以建立清晰的内部消息契约，但也增加一次 Kafka 生产、存储和消费过程。若标准化生产者自身吞吐不足、需要等待 Broker 确认，或者标准化阶段频繁查询数据库，它会成为两个下游共同的前置瓶颈。\n\n因此，本次实践没有把中转 Topic 作为当前主链路，而是保留为可回滚、可演进的兼容模式。\n\n### 4.3 最终采用的直接双消费组方案\n\n```mermaid\nflowchart LR\n    GW[设备网关或IoT平台] --> RAW[原始遥测 Topic]\n    RAW -->|实时消费组| RT[校验与标准化]\n    RAW -->|持久化消费组| PS[校验与标准化]\n\n    RT --> COALESCE[批次内按点位保留最新值]\n    COALESCE --> PROJ[Redis最新值投影与Pub\u002FSub]\n    PROJ --> SSE[SSE客户端]\n\n    PS --> SAMPLE[按点位入库间隔判断]\n    SAMPLE --> BATCH[批量分片]\n    BATCH --> INFLUX[InfluxDB]\n    INFLUX --> WATERMARK[成功后提交采样水位]\n\n    MAP[(两级点位映射缓存)] --> RT\n    MAP --> PS\n    META[(PostgreSQL设备与型号元数据)] -.缓存未命中.-> MAP\n```\n\n关键隔离点：\n\n- 两个消费组拥有独立 Offset；\n- 两个消费组使用独立 Listener 和消费线程；\n- 实时链路不调用采样服务和 InfluxDB；\n- 持久化链路不调用 SSE Hub；\n- InfluxDB 失败只阻塞持久化组；\n- 实时组首次启用从最新消息开始，避免历史积压冲击当前页面；\n- 持久化组复用原有消费组位点，避免切换时跳过尚未入库的消息。\n\n## 5. 消息标准化\n\n两条链路都会把原始设备消息转换为统一的内部对象。标准化步骤包括：\n\n1. 校验消息契约版本、设备时间和遥测键数量；\n2. 解析租户与设备标识；\n3. 校验设备归属和外部平台绑定；\n4. 加载设备生效的点位映射；\n5. 按点位定义转换数值、布尔或字符串；\n6. 过滤未知点位或非法值；\n7. 为合法点位携带生效的入库间隔快照。\n\n脱敏后的标准化消息示例：\n\n```json\n{\n  \"schemaVersion\": \"1.0\",\n  \"eventId\": \"\u003Ctopic>:2:10001\",\n  \"tenantId\": \"tenant-demo\",\n  \"deviceId\": \"device-demo\",\n  \"deviceCode\": \"METER-DEMO-01\",\n  \"sourceTs\": 1790056800000,\n  \"ingestTs\": 1790056800125,\n  \"points\": [\n    {\n      \"pointCode\": \"active_power\",\n      \"valueType\": \"double\",\n      \"value\": 428.6,\n      \"unit\": \"kW\",\n      \"samplingIntervalSec\": 5\n    }\n  ]\n}\n```\n\n`eventId` 可以由原 Topic、分区和 Offset 组成，便于定位和幂等处理。示例不使用真实 Topic、设备编号和 Offset。\n\n## 6. 点位配置合并规则\n\n每个设备点位的有效入库间隔按以下优先级确定：\n\n```text\n设备点位覆盖值 samplingIntervalOverride\n  > 型号点位默认值 samplingIntervalSec\n  > null：每条合法数据都允许入库\n```\n\n设备采集频率字段不参与该判断。设备仍可每秒上报，SSE 仍可秒级更新，而 InfluxDB 根据点位配置每 5 秒或 60 秒保存一次。\n\n设备覆盖表用于保存少量设备例外，例如：\n\n| 字段                              | 含义          |\n| ------------------------------- | ----------- |\n| `device_id`                     | 需要单独配置的设备   |\n| `point_template_id`             | 被覆盖的型号点位    |\n| `enabled_override`              | 是否启用该设备点位   |\n| `sampling_interval_override`    | 设备级入库间隔，单位秒 |\n| `unit_override`                 | 设备级工程单位     |\n| `effective_start\u002Feffective_end` | 生效时间范围      |\n\n该表属于遥测标准化的运行时依赖。部署前必须确保表结构、索引和应用 `search_path` 正确；不能把“表不存在”作为空配置静默忽略，否则两个消费组会在冷缓存加载时同时失败。\n\n非法间隔不应导致数据被丢弃。实践中对超出允许范围的历史配置记录告警，并降级为 `null`，即每条合法数据均可入库。\n\n## 7. 两级点位映射缓存\n\n### 7.1 为什么需要两级缓存\n\n双消费组会分别执行标准化。如果每条消息都查询设备表、型号点位表和设备覆盖表，数据库访问量会随上报频率线性增长，并成为 Kafka 消费瓶颈。\n\n最终缓存结构为：\n\n```text\n请求设备点位映射\n  → L1：进程内 Caffeine\n  → L2：Redis\n  → PostgreSQL 回源\n```\n\n同时对“设备映射”和“型号点位集合”分别缓存：\n\n- 同型号设备共享型号点位缓存，减少重复加载；\n- 设备缓存保存型号点位与设备覆盖合并后的最终映射；\n- 同一批消息还会使用批次内 Map，避免同批次重复解析同一设备。\n\n### 7.2 缓存一致性\n\n配置变更后执行：\n\n1. 删除当前实例的 L1 缓存；\n2. 删除 Redis L2 缓存；\n3. 通过 Redis Pub\u002FSub 发布失效键；\n4. 其他实例收到通知后删除对应 L1 缓存；\n5. TTL 作为最终兜底，不作为主要一致性机制。\n\nRedis TTL 增加随机抖动，避免大量设备同时过期形成缓存雪崩。Redis 异常时开启短时间本机旁路，直接单次回源 PostgreSQL 并写入 L1，避免每条消息反复等待同一个 Redis 故障。\n\n需要注意：Redis 是缓存，不是设备配置的最终事实来源。PostgreSQL 仍是设备、型号和覆盖配置的权威数据源。\n\n## 8. 实时 SSE 链路\n\n实时链路只做以下工作：\n\n```text\n消费原始批次\n  → 标准化\n  → 按 tenant + device + point 合并为批次最新值\n  → 更新实时投影\n  → Redis Pub\u002FSub\n  → SSE Hub\n```\n\n批次合并示例：\n\n| 点位       | 批次内时间        | 值    | 处理结果     |\n| -------- | ------------ | ----:| -------- |\n| pressure | 10:00:00.000 | 0.61 | 被同批次新值替代 |\n| pressure | 10:00:00.500 | 0.62 | 被同批次新值替代 |\n| pressure | 10:00:00.900 | 0.63 | 发布       |\n\n这意味着 SSE 是“最新状态流”，不是完整历史审计流。它追求页面及时性和有界内存，不承诺浏览器逐条接收设备上报的每一个瞬时值。\n\n实时链路明确不执行：\n\n- InfluxDB 写入；\n- 入库间隔判断；\n- 采样水位读写；\n- 历史数据补传。\n\n因此，把某点位入库间隔从 1 秒调整为 60 秒，不会把该点位 SSE 推送也降低为每分钟一次。\n\n## 9. InfluxDB 持久化链路\n\n### 9.1 首个到期样本算法\n\n每个点位维护最后一次成功入库的源时间：\n\n```text\nkey = tenantId + deviceId + pointCode\nvalue = lastPersistedSourceTs\n```\n\n判断规则：\n\n```text\n没有成功水位                         → 选择当前点\n入库间隔为 null                     → 选择当前点\nsourceTs \u003C lastPersistedSourceTs    → 判定为乱序\nsourceTs >= last + interval         → 选择当前点\n其他情况                            → 跳过本次历史入库\n```\n\n伪代码：\n\n```java\nif (last != null && sourceTs \u003C last) {\n    sendToPersistenceDlq(event);\n} else if (last == null || interval == null\n        || sourceTs >= last + interval.toMillis()) {\n    selected.add(event);\n    provisionalWatermark.put(key, sourceTs);\n} else {\n    skipped++;\n}\n```\n\n### 9.2 时间示例\n\n假设设备每秒上报，点位入库间隔为 5 秒：\n\n| 源时间      | SSE | InfluxDB | 原因      |\n| -------- | --- | -------- | ------- |\n| 12:00:00 | 更新  | 写入       | 首个样本    |\n| 12:00:01 | 更新  | 跳过       | 未到5秒    |\n| 12:00:02 | 更新  | 跳过       | 未到5秒    |\n| 12:00:03 | 更新  | 跳过       | 未到5秒    |\n| 12:00:04 | 更新  | 跳过       | 未到5秒    |\n| 12:00:05 | 更新  | 写入       | 到达下一个周期 |\n| 12:00:06 | 更新  | 跳过       | 未到下一周期  |\n| 12:00:10 | 更新  | 写入       | 到达下一周期  |\n\n该算法以首次成功入库时间为滚动起点，不强制对齐自然分钟或自然整点。\n\n### 9.3 批次临时水位\n\n一个 Kafka 批次内可能包含同一点位的多条消息。判断过程中先更新“批次临时水位”，避免在真实水位尚未提交时选中多个未到期样本。\n\n真实水位只能在 InfluxDB 写入成功后提交：\n\n```text\n采样判断\n  → 生成候选点\n  → 分片批量写 InfluxDB\n  → 全部写入成功\n  → 更新本地水位\n  → Redis Pipeline 同步水位\n  → Listener 正常返回并提交 Kafka Offset\n```\n\n如果 InfluxDB 写入失败：\n\n- 抛出异常，让当前 Kafka 批次重试；\n- 不推进采样水位；\n- 不提交该批次消费位点；\n- 已成功写入的前置分片可能在重试时再次写入，应依靠相同 Tag、Field 和源时间实现幂等覆盖。\n\n### 9.4 本地水位与 Redis 水位\n\n- 本地并发 Map 是运行实例内的快速水位；\n- Redis 用于实例重启或分区迁移后的水位恢复；\n- Redis 使用批量读取和 Pipeline 写入，避免逐点网络往返；\n- Redis 写入失败时，本地水位继续生效；\n- Redis 状态丢失可能产生少量重复写，但不能让系统跳过本应保存的数据。\n\n这是“宁可少量重复，不可静默少写”的可靠性取舍。\n\n## 10. Kafka Offset、重试与消费组\n\n### 10.1 两个消费组的职责\n\n| 消费组  | 数据源        | 首次启动策略    | Offset 推进条件 |\n| ---- | ---------- | --------- | ----------- |\n| 实时组  | 原始遥测 Topic | 从最新位置开始   | 当前批次实时投影成功  |\n| 持久化组 | 原始遥测 Topic | 复用既有入库组位点 | 当前批次持久化流程成功 |\n\n实时组从最新位置开始，是因为实时页面关心“现在”，不应在首次启用时回放大量历史消息。组一旦产生提交位点，重启后继续从已提交位置恢复。\n\n持久化组沿用旧链路的消费组 ID，可以继承原 Offset，降低切换时从头回放或跳过积压的风险。切换消费组 ID 属于数据语义变更，不能只当普通配置修改。\n\n### 10.2 重试策略\n\n直接双链路模式对处理异常执行持续重试，不将临时故障伪装成成功：\n\n- 失败时不提交 Offset；\n- 日志记录 Topic、分区、Offset、尝试次数、根异常类型和安全错误摘要；\n- 对第一次、2的幂次和固定周期尝试打印错误，减少无限重试时的日志风暴；\n- 乱序点进入持久化 DLQ，不推进正常水位；\n- DLQ 事件应携带稳定事件标识，消费侧需要防止重复回放。\n\n持续重试保证不静默丢失，但也意味着永久性配置错误会阻塞对应分区。例如点位覆盖表缺失、字段不兼容或必需 Topic 不存在。因此必须同时建设启动检查、数据库迁移和运维告警，不能只依赖重试。\n\n## 11. 性能设计\n\n### 11.1 批量优先\n\n- Kafka 使用批量 Listener；\n- 标准化按设备消息处理，不把每个点位拆成独立 Kafka 消息；\n- 同批次相同设备复用映射；\n- SSE 在批次内按点位合并最新值；\n- Redis 水位使用 `multiGet` 和 Pipeline；\n- InfluxDB 按可配置最大点数分片写入。\n\n### 11.2 双标准化的成本\n\n两个消费组直接消费原始 Topic，意味着标准化逻辑执行两次。这是用计算量换取故障隔离和低延迟的明确取舍。\n\n降低额外成本的方法：\n\n1. 标准化保持无状态、无阻塞；\n2. 设备映射使用 L1\u002FL2 两级缓存；\n3. 型号点位按型号共享缓存；\n4. 数据库只在冷缓存或配置变更后回源；\n5. 避免在标准化热路径进行逐点远程调用；\n6. 监控两个消费组各自的 CPU、处理耗时和 Lag。\n\n当流量规模继续扩大，且双标准化成本超过可接受范围时，可以重新评估标准化 Topic，但必须保证标准化生产层本身可水平扩展、有独立 Lag 监控，并通过压测证明不会重新成为共同瓶颈。\n\n### 11.3 并发与分区\n\n- Listener 并发数不应超过 Topic 分区数；\n- 同一设备使用稳定 Kafka Key，尽量保持设备内顺序；\n- 不通过无限增加线程掩盖数据库、Redis 或 InfluxDB 瓶颈；\n- 分区数应根据峰值消息数、平均消息大小、单条点位数和目标处理时间压测确定。\n\n## 12. 脱敏配置示例\n\n以下配置仅展示结构，Topic、消费组和地址必须根据环境管理，认证信息不得写入文档或代码仓库。\n\n```yaml\nspring:\n  kafka:\n    consumer:\n      # 持久化链路复用原消费组位点。\n      group-id: ${PERSISTENCE_RAW_GROUP:\u003Cpersistence-raw-group>}\n      max-poll-records: ${KAFKA_MAX_POLL_RECORDS:200}\n    properties:\n      allow.auto.create.topics: false\n\nplatform:\n  iot:\n    telemetry:\n      enabled: true\n      consumer-enabled: true\n      raw-topic: ${RAW_TELEMETRY_TOPIC:\u003Craw-topic>}\n\n      # split：实时与持久化使用独立消费组直接消费原始Topic。\n      processing-mode: split\n      realtime-consumer-group: ${REALTIME_GROUP:\u003Crealtime-group>}\n      realtime-concurrency: 3\n\n      point-cache:\n        local-enabled: true\n        local-maximum-size: 50000\n        local-model-maximum-size: 10000\n        local-expire-after-access: 30m\n        redis-ttl: 2h\n        redis-ttl-jitter: 10m\n        redis-failure-cooldown: 30s\n        invalidation-channel: \u003Ccache-invalidation-channel>\n\n      sampling:\n        enabled: true\n        min-interval: 1s\n        max-interval: 1d\n        watermark-ttl: 7d\n\n      persistence:\n        max-points-per-write: 1000\n        replay-enabled: false\n```\n\n实际项目可能使用不同配置前缀，迁移到其他系统时应保持配置语义，不应照抄示例命名。\n\nInfluxDB URL、组织、Bucket、Token，Kafka SASL\u002FSSL 凭据，Redis 密码和配置中心凭据必须通过受控环境变量或密钥管理系统注入。\n\n## 13. 启动与运行检查\n\n服务启动时建议输出一条安全摘要：\n\n```text\nmode=split\nrawTopic=\u003Cmasked>\nrealtimeRawGroup=\u003Cmasked>\npersistenceRawGroup=\u003Cmasked>\nsamplingEnabled=true\nlocalCacheEnabled=true\n```\n\n不要输出 Token、密码、完整连接串或原始遥测载荷。\n\n健康检查至少验证：\n\n- 原始遥测 Topic 存在且分区数大于0；\n- 持久化 DLQ 存在，分区数满足回放策略；\n- 当前 Kafka 账号拥有 Describe 和消费权限；\n- InfluxDB 必要配置非空；\n- PostgreSQL 必需表存在，包括设备、型号点位和设备点位覆盖表；\n- Redis 不可用时服务能够进入明确降级状态，而不是无限同步等待。\n\n## 14. 关键日志与指标\n\n### 14.1 实时批次日志\n\n脱敏示例：\n\n```text\nRealtime batch processed records=120 receivedPoints=840\ncoalescedPoints=390 published=390 costMs=18\n```\n\n字段含义：\n\n- `receivedPoints`：标准化后的合法点位数；\n- `coalescedPoints`：批次内按设备和点位合并后的数量；\n- `published`：实际发布到实时投影的数量；\n- `costMs`：批次总处理时间。\n\n### 14.2 持久化批次日志\n\n脱敏示例：\n\n```text\nPersistence batch processed records=120 candidates=840\nselected=140 written=140 skipped=698 outOfOrder=2\nsamplingCostMs=3 influxWriteCostMs=14 watermarkCommitCostMs=2 costMs=23\n```\n\n重点观察：\n\n- `selected` 应与当前点位间隔配置相符；\n- `written` 应等于成功写入的选中点数；\n- `skipped` 增加说明入库间隔正在生效，不代表数据异常；\n- `outOfOrder` 持续增加需要检查设备时钟或历史补传；\n- `influxWriteCostMs` 高于总处理预算时，应优先检查 InfluxDB 和网络；\n- `watermarkCommitCostMs` 异常时检查 Redis，但不能让其掩盖 InfluxDB 结果。\n\n### 14.3 推荐指标\n\n- 两个消费组的 Lag 和最老消息年龄；\n- 实时批次处理时间、接收点数、合并点数和发布点数；\n- 采样选中数、跳过数和乱序数；\n- InfluxDB 写入点数、批次大小、耗时和失败数；\n- L1、Redis 点位映射缓存命中率；\n- PostgreSQL 设备映射和型号点位回源次数；\n- Redis 失败次数和旁路持续时间；\n- SSE 连接数、发送失败数和端到端数据新鲜度。\n\n指标标签不得直接使用设备ID或点位编码，避免监控系统出现高基数。设备级问题通过日志、Trace 或受控诊断接口定位。\n\n## 15. 验证实例\n\n### 15.1 SSE 不受入库间隔影响\n\n测试条件：\n\n- 设备每秒上报；\n- 点位入库间隔设置为30秒；\n- 同时观察实时页面和 InfluxDB。\n\n预期：\n\n- 实时页面持续看到新值；\n- InfluxDB 相邻保存点约为30秒；\n- 实时消费组 Lag 不跟随持久化写入耗时增长；\n- 持久化日志中 `skipped` 明显大于0。\n\n### 15.2 设备级覆盖优先\n\n测试条件：\n\n- 型号点位默认间隔为60秒；\n- 某测试设备覆盖为10秒；\n- 另一台同型号设备不设置覆盖。\n\n预期：\n\n- 被覆盖设备约每10秒保存一次；\n- 未覆盖设备约每60秒保存一次；\n- 两台设备的 SSE 均不受上述间隔影响；\n- 修改覆盖后，缓存失效并在后续新消息中使用新值。\n\n### 15.3 InfluxDB 故障隔离\n\n测试条件：\n\n- 临时模拟 InfluxDB 写入失败；\n- 保持 Kafka、Redis 和 SSE 客户端正常。\n\n预期：\n\n- 持久化组重试且 Lag 增长；\n- 失败批次不提交持久化 Offset；\n- 采样水位不推进；\n- 实时组继续消费；\n- SSE 页面继续更新；\n- InfluxDB 恢复后持久化组逐步追平积压。\n\n### 15.4 冷缓存与数据库完整性\n\n测试条件：\n\n- 清除指定测试设备的 L1\u002FL2 映射缓存；\n- 保持设备、型号点位、设备点位覆盖表完整。\n\n预期：\n\n- 首次消息回源 PostgreSQL；\n- 后续消息命中 L1 或 Redis；\n- 不再重复打印大量相同点位查询；\n- 若必需表缺失，日志明确报告根异常，消费位点不被错误提交。\n\n## 16. 验收清单\n\n### 16.1 功能\n\n- [ ] 服务启动日志显示处理模式为 `split`。\n- [ ] 采样开关为启用状态。\n- [ ] SSE 在点位入库间隔大于上报周期时仍持续更新。\n- [ ] `samplingIntervalSec = null` 的点位每条合法消息均可入库。\n- [ ] 型号点位间隔能够生效。\n- [ ] 设备点位覆盖间隔优先于型号默认值。\n- [ ] 点位覆盖启用状态和工程单位能够正确合并。\n- [ ] 乱序消息不会推进正常采样水位。\n- [ ] 配置修改后设备与型号缓存能够主动失效。\n\n### 16.2 可靠性\n\n- [ ] InfluxDB 失败不会阻塞实时消费组。\n- [ ] InfluxDB 失败时持久化 Offset 不推进。\n- [ ] InfluxDB 失败时采样水位不推进。\n- [ ] Redis 水位失败最多导致重复写，不导致应写数据被跳过。\n- [ ] Redis 映射缓存失败时能够短路降级并回源 PostgreSQL。\n- [ ] 永久性数据库结构错误能够通过启动检查或明确日志暴露。\n- [ ] 重启和 Kafka Rebalance 后两个消费组均从各自 Offset 恢复。\n\n### 16.3 性能与观测\n\n- [ ] 两个消费组分别监控 Lag。\n- [ ] 记录实时、采样、InfluxDB写入和水位提交分段耗时。\n- [ ] 设备映射与型号点位数据库回源次数显著低于消息数。\n- [ ] InfluxDB 单次写入点数不超过配置上限。\n- [ ] Listener 并发数不超过 Topic 分区数。\n- [ ] 完成目标规模的持续压测和故障注入测试。\n- [ ] 使用真实但脱敏的流量确定 P95\u002FP99 和告警阈值。\n\n### 16.4 安全\n\n- [ ] 文档、日志和监控不包含 Token、密码或完整连接串。\n- [ ] 对外分享不包含真实租户、设备、客户、Topic 和消费组名称。\n- [ ] 无效消息日志限制原始载荷长度并执行脱敏。\n- [ ] DLQ 和重放操作具备权限控制与审计。\n\n## 17. 已知边界与后续演进\n\n1. 双消费组会重复执行标准化，需要持续观察 CPU 和缓存命中率。\n2. SSE 是最新状态流，不是逐条可靠消息流；需要完整历史时应查询 InfluxDB。\n3. 固定间隔采用首个到期样本，不计算窗口平均值、最小值或最大值。\n4. Redis 水位丢失可能造成少量重复写，这是为了避免少写数据。\n5. 永久性元数据错误会阻塞对应 Kafka 分区，需要数据库迁移门禁和告警处理。\n6. 当前功能验证不能替代容量压测；分区数、并发数、批次大小必须按真实峰值校准。\n7. 对累计量、状态量、告警和高价值点位，可继续增加“状态变化强制保存”“累计量复位保存”等策略。\n8. 若未来重新引入标准化 Topic，应把其作为独立可扩展的数据产品，而不是把单一标准化消费者变成新的串行瓶颈。\n\n## 18. 实践总结\n\n本次实践最重要的结论不是简单地“增加一个采样判断”，而是先把实时性和历史持久化的可靠性边界拆开：\n\n- Kafka 独立消费组提供 Offset 和故障隔离；\n- SSE 链路只维护最新状态，不承担历史保存职责；\n- InfluxDB 链路独立承担入库间隔、水位、批量写和重试；\n- 两级缓存把关系数据库从遥测热路径中移出；\n- 水位在写入成功后提交，保证失败时可以安全重试；\n- 配置错误必须显式暴露，不能通过静默降级掩盖数据库迁移缺失。\n\n对于工业物联网系统，实时和历史不是同一个消费语义。只有把二者拆成独立可观测、可失败、可恢复的链路，才能同时获得及时的页面体验和可控的时序存储成本。\n","\u003Ch1 data-line=\"0\" id=\"工业物联网遥测双链路与按点位间隔入库技术实践\">工业物联网遥测双链路与按点位间隔入库技术实践\u003C\u002Fh1>\n\u003Cblockquote data-line=\"2\">\n\u003Cp data-line=\"2\">文档类型：技术与架构实践分享\u003Cbr>\n编写日期：2026-09-22\u003Cbr>\n实践状态：SSE 实时推送与 InfluxDB 按点位间隔保存已完成现场功能验证\u003Cbr>\n适用场景：设备高频遥测、实时大屏、时序数据持久化、Kafka 消费解耦\u003C\u002Fp>\n\u003C\u002Fblockquote>\n\u003Ch2 data-line=\"7\" id=\"1. 摘要\">1. 摘要\u003C\u002Fh2>\n\u003Cp data-line=\"9\">工业物联网平台经常同时面对两类目标：\u003C\u002Fp>\n\u003Col data-line=\"11\">\n\u003Cli data-line=\"11\">实时页面希望尽快看到设备最新值，不能被数据库写入速度影响；\u003C\u002Fli>\n\u003Cli data-line=\"12\">时序数据库需要按照不同点位的业务价值控制保存频率，降低写入量和存储成本，同时不能静默丢失应保存的数据。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"14\">本实践最终采用“同一原始遥测 Topic、两个独立 Kafka 消费组”的双链路架构：实时链路只负责最新状态投影与 SSE 推送，持久化链路独立完成按点位间隔判断、批量写入 InfluxDB、水位提交和异常重试。两个链路共享无状态的标准化规则和点位映射缓存，但不共享消费位点、消费线程或下游成败状态。\u003C\u002Fp>\n\u003Cp data-line=\"16\">现场功能验证表明：\u003C\u002Fp>\n\u003Cul data-line=\"18\">\n\u003Cli data-line=\"18\">SSE 推送不再受点位入库间隔控制；\u003C\u002Fli>\n\u003Cli data-line=\"19\">InfluxDB 保存间隔能够按型号点位或设备覆盖配置生效；\u003C\u002Fli>\n\u003Cli data-line=\"20\">InfluxDB 链路的采样判断和写入不再占用实时消费组线程；\u003C\u002Fli>\n\u003Cli data-line=\"21\">设备点位映射通过本地缓存与 Redis 缓存减少了 PostgreSQL 重复查询。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"23\">本次验证属于功能与链路正确性验证。文档不虚构生产吞吐量、P95\u002FP99 延迟或容量上限，正式容量结论仍应以压测和生产监控为准。\u003C\u002Fp>\n\u003Ch2 data-line=\"25\" id=\"2. 脱敏说明\">2. 脱敏说明\u003C\u002Fh2>\n\u003Cp data-line=\"27\">本文所有名称和数据均为通用化示例：\u003C\u002Fp>\n\u003Cul data-line=\"29\">\n\u003Cli data-line=\"29\">项目、公司、客户和产品名称使用“某工业能源平台”代替；\u003C\u002Fli>\n\u003Cli data-line=\"30\">Kafka Topic、消费组使用 \u003Ccode>&lt;raw-topic&gt;\u003C\u002Fcode>、\u003Ccode>&lt;realtime-group&gt;\u003C\u002Fcode> 等占位符；\u003C\u002Fli>\n\u003Cli data-line=\"31\">租户、设备和点位使用 \u003Ccode>tenant-demo\u003C\u002Fcode>、\u003Ccode>device-demo\u003C\u002Fcode>、\u003Ccode>temperature\u003C\u002Fcode> 等示例值；\u003C\u002Fli>\n\u003Cli data-line=\"32\">数据库、Redis、InfluxDB、Nacos 地址和认证信息不展示；\u003C\u002Fli>\n\u003Cli data-line=\"33\">示例中的时间、数值、分区、Offset 均为构造数据；\u003C\u002Fli>\n\u003Cli data-line=\"34\">不包含生产日志原文、真实设备编码、Token、密码和客户业务数据。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"36\" id=\"3. 业务背景\">3. 业务背景\u003C\u002Fh2>\n\u003Cp data-line=\"38\">假设设备网关每秒上报一次遥测，一条设备消息包含温度、压力、瞬时流量、累计流量、电能等多个点位。\u003C\u002Fp>\n\u003Cp data-line=\"40\">业务侧通常有不同要求：\u003C\u002Fp>\n\u003Ctable data-line=\"42\">\n\u003Cthead data-line=\"42\">\n\u003Ctr data-line=\"42\">\n\u003Cth>目标\u003C\u002Fth>\n\u003Cth>典型要求\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"44\">\n\u003Ctr data-line=\"44\">\n\u003Ctd>实时大屏\u003C\u002Ftd>\n\u003Ctd>尽可能快速展示最新值，允许在一个推送窗口内合并旧值\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"45\">\n\u003Ctd>历史趋势\u003C\u002Ftd>\n\u003Ctd>根据点位重要性每 5 秒、30 秒或 60 秒保存一次\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"46\">\n\u003Ctd>累计量与抄表\u003C\u002Ftd>\n\u003Ctd>保留可靠时间戳和原始累计值，便于期间差值计算\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"47\">\n\u003Ctd>故障恢复\u003C\u002Ftd>\n\u003Ctd>数据库短时不可用时不能让实时页面同步停顿\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"48\">\n\u003Ctd>性能\u003C\u002Ftd>\n\u003Ctd>批量处理，避免每条消息、每个点位都查询关系数据库\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"50\">这里最容易混淆的是“设备采集频率”和“平台入库间隔”：\u003C\u002Fp>\n\u003Cul data-line=\"52\">\n\u003Cli data-line=\"52\">设备采集频率决定上游多久产生一条数据；\u003C\u002Fli>\n\u003Cli data-line=\"53\">平台入库间隔只决定合法数据多久写入一次 InfluxDB；\u003C\u002Fli>\n\u003Cli data-line=\"54\">SSE 使用实时数据，不读取入库间隔做降频。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"56\" id=\"4. 方案演进与问题复盘\">4. 方案演进与问题复盘\u003C\u002Fh2>\n\u003Ch3 data-line=\"58\" id=\"4.1 串行单链路的问题\">4.1 串行单链路的问题\u003C\u002Fh3>\n\u003Cp data-line=\"60\">早期链路可以抽象为：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"62\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">原始遥测\n  → 消息校验与点位映射\n  → 入库间隔判断\n  → 写 InfluxDB\n  → 更新实时投影\n  → SSE 推送\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"71\">该结构的核心问题不是调用顺序本身，而是实时和持久化共用一个 Kafka 消费组、同一批线程和同一个 Offset 推进条件。\u003C\u002Fp>\n\u003Cp data-line=\"73\">当 InfluxDB 变慢、写入重试、Redis 水位操作耗时或点位映射频繁回源数据库时，当前批次无法及时完成，Kafka Offset 不能推进，后续实时消息也只能排队。外部表现通常是：\u003C\u002Fp>\n\u003Cul data-line=\"75\">\n\u003Cli data-line=\"75\">SSE 推送延迟；\u003C\u002Fli>\n\u003Cli data-line=\"76\">InfluxDB 入库延迟；\u003C\u002Fli>\n\u003Cli data-line=\"77\">Kafka Consumer Lag 持续增长；\u003C\u002Fli>\n\u003Cli data-line=\"78\">消费组频繁重平衡后延迟进一步放大；\u003C\u002Fli>\n\u003Cli data-line=\"79\">通过增加线程只能暂时缓解，不能消除故障传播。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"81\" id=\"4.2 标准化 Topic 中转方案的取舍\">4.2 标准化 Topic 中转方案的取舍\u003C\u002Fh3>\n\u003Cp data-line=\"83\">一种常见方案是在原始 Topic 后增加标准化 Topic，再由实时组和持久化组分别消费。它可以建立清晰的内部消息契约，但也增加一次 Kafka 生产、存储和消费过程。若标准化生产者自身吞吐不足、需要等待 Broker 确认，或者标准化阶段频繁查询数据库，它会成为两个下游共同的前置瓶颈。\u003C\u002Fp>\n\u003Cp data-line=\"85\">因此，本次实践没有把中转 Topic 作为当前主链路，而是保留为可回滚、可演进的兼容模式。\u003C\u002Fp>\n\u003Ch3 data-line=\"87\" id=\"4.3 最终采用的直接双消费组方案\">4.3 最终采用的直接双消费组方案\u003C\u002Fh3>\n\u003Cdiv  data-line=\"89\" class=\"md-editor-mermaid\" data-mermaid-theme=\"light\" data-closed=\"true\">flowchart LR\n    GW[设备网关或IoT平台] --&gt; RAW[原始遥测 Topic]\n    RAW --&gt;|实时消费组| RT[校验与标准化]\n    RAW --&gt;|持久化消费组| PS[校验与标准化]\n\n    RT --&gt; COALESCE[批次内按点位保留最新值]\n    COALESCE --&gt; PROJ[Redis最新值投影与Pub\u002FSub]\n    PROJ --&gt; SSE[SSE客户端]\n\n    PS --&gt; SAMPLE[按点位入库间隔判断]\n    SAMPLE --&gt; BATCH[批量分片]\n    BATCH --&gt; INFLUX[InfluxDB]\n    INFLUX --&gt; WATERMARK[成功后提交采样水位]\n\n    MAP[(两级点位映射缓存)] --&gt; RT\n    MAP --&gt; PS\n    META[(PostgreSQL设备与型号元数据)] -.缓存未命中.-&gt; MAP\u003C\u002Fdiv>\u003Cp data-line=\"109\">关键隔离点：\u003C\u002Fp>\n\u003Cul data-line=\"111\">\n\u003Cli data-line=\"111\">两个消费组拥有独立 Offset；\u003C\u002Fli>\n\u003Cli data-line=\"112\">两个消费组使用独立 Listener 和消费线程；\u003C\u002Fli>\n\u003Cli data-line=\"113\">实时链路不调用采样服务和 InfluxDB；\u003C\u002Fli>\n\u003Cli data-line=\"114\">持久化链路不调用 SSE Hub；\u003C\u002Fli>\n\u003Cli data-line=\"115\">InfluxDB 失败只阻塞持久化组；\u003C\u002Fli>\n\u003Cli data-line=\"116\">实时组首次启用从最新消息开始，避免历史积压冲击当前页面；\u003C\u002Fli>\n\u003Cli data-line=\"117\">持久化组复用原有消费组位点，避免切换时跳过尚未入库的消息。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"119\" id=\"5. 消息标准化\">5. 消息标准化\u003C\u002Fh2>\n\u003Cp data-line=\"121\">两条链路都会把原始设备消息转换为统一的内部对象。标准化步骤包括：\u003C\u002Fp>\n\u003Col data-line=\"123\">\n\u003Cli data-line=\"123\">校验消息契约版本、设备时间和遥测键数量；\u003C\u002Fli>\n\u003Cli data-line=\"124\">解析租户与设备标识；\u003C\u002Fli>\n\u003Cli data-line=\"125\">校验设备归属和外部平台绑定；\u003C\u002Fli>\n\u003Cli data-line=\"126\">加载设备生效的点位映射；\u003C\u002Fli>\n\u003Cli data-line=\"127\">按点位定义转换数值、布尔或字符串；\u003C\u002Fli>\n\u003Cli data-line=\"128\">过滤未知点位或非法值；\u003C\u002Fli>\n\u003Cli data-line=\"129\">为合法点位携带生效的入库间隔快照。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"131\">脱敏后的标准化消息示例：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"133\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">json\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-json\" language=json>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;schemaVersion&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;1.0&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;eventId&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;&lt;topic&gt;:2:10001&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;tenantId&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;tenant-demo&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;deviceId&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;device-demo&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;deviceCode&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;METER-DEMO-01&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;sourceTs&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">1790056800000\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;ingestTs&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">1790056800125\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">&quot;points&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">&quot;pointCode&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;active_power&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">&quot;valueType&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;double&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">&quot;value&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">428.6\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">&quot;unit&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&quot;kW&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">&quot;samplingIntervalSec&quot;\u003C\u002Fspan>\u003Cspan class=\"hljs-punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">5\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"hljs-punctuation\">}\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"154\">\u003Ccode>eventId\u003C\u002Fcode> 可以由原 Topic、分区和 Offset 组成，便于定位和幂等处理。示例不使用真实 Topic、设备编号和 Offset。\u003C\u002Fp>\n\u003Ch2 data-line=\"156\" id=\"6. 点位配置合并规则\">6. 点位配置合并规则\u003C\u002Fh2>\n\u003Cp data-line=\"158\">每个设备点位的有效入库间隔按以下优先级确定：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"160\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">设备点位覆盖值 samplingIntervalOverride\n  &gt; 型号点位默认值 samplingIntervalSec\n  &gt; null：每条合法数据都允许入库\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"166\">设备采集频率字段不参与该判断。设备仍可每秒上报，SSE 仍可秒级更新，而 InfluxDB 根据点位配置每 5 秒或 60 秒保存一次。\u003C\u002Fp>\n\u003Cp data-line=\"168\">设备覆盖表用于保存少量设备例外，例如：\u003C\u002Fp>\n\u003Ctable data-line=\"170\">\n\u003Cthead data-line=\"170\">\n\u003Ctr data-line=\"170\">\n\u003Cth>字段\u003C\u002Fth>\n\u003Cth>含义\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"172\">\n\u003Ctr data-line=\"172\">\n\u003Ctd>\u003Ccode>device_id\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>需要单独配置的设备\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"173\">\n\u003Ctd>\u003Ccode>point_template_id\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>被覆盖的型号点位\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"174\">\n\u003Ctd>\u003Ccode>enabled_override\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>是否启用该设备点位\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"175\">\n\u003Ctd>\u003Ccode>sampling_interval_override\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>设备级入库间隔，单位秒\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"176\">\n\u003Ctd>\u003Ccode>unit_override\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>设备级工程单位\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"177\">\n\u003Ctd>\u003Ccode>effective_start\u002Feffective_end\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>生效时间范围\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"179\">该表属于遥测标准化的运行时依赖。部署前必须确保表结构、索引和应用 \u003Ccode>search_path\u003C\u002Fcode> 正确；不能把“表不存在”作为空配置静默忽略，否则两个消费组会在冷缓存加载时同时失败。\u003C\u002Fp>\n\u003Cp data-line=\"181\">非法间隔不应导致数据被丢弃。实践中对超出允许范围的历史配置记录告警，并降级为 \u003Ccode>null\u003C\u002Fcode>，即每条合法数据均可入库。\u003C\u002Fp>\n\u003Ch2 data-line=\"183\" id=\"7. 两级点位映射缓存\">7. 两级点位映射缓存\u003C\u002Fh2>\n\u003Ch3 data-line=\"185\" id=\"7.1 为什么需要两级缓存\">7.1 为什么需要两级缓存\u003C\u002Fh3>\n\u003Cp data-line=\"187\">双消费组会分别执行标准化。如果每条消息都查询设备表、型号点位表和设备覆盖表，数据库访问量会随上报频率线性增长，并成为 Kafka 消费瓶颈。\u003C\u002Fp>\n\u003Cp data-line=\"189\">最终缓存结构为：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"191\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">请求设备点位映射\n  → L1：进程内 Caffeine\n  → L2：Redis\n  → PostgreSQL 回源\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"198\">同时对“设备映射”和“型号点位集合”分别缓存：\u003C\u002Fp>\n\u003Cul data-line=\"200\">\n\u003Cli data-line=\"200\">同型号设备共享型号点位缓存，减少重复加载；\u003C\u002Fli>\n\u003Cli data-line=\"201\">设备缓存保存型号点位与设备覆盖合并后的最终映射；\u003C\u002Fli>\n\u003Cli data-line=\"202\">同一批消息还会使用批次内 Map，避免同批次重复解析同一设备。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"204\" id=\"7.2 缓存一致性\">7.2 缓存一致性\u003C\u002Fh3>\n\u003Cp data-line=\"206\">配置变更后执行：\u003C\u002Fp>\n\u003Col data-line=\"208\">\n\u003Cli data-line=\"208\">删除当前实例的 L1 缓存；\u003C\u002Fli>\n\u003Cli data-line=\"209\">删除 Redis L2 缓存；\u003C\u002Fli>\n\u003Cli data-line=\"210\">通过 Redis Pub\u002FSub 发布失效键；\u003C\u002Fli>\n\u003Cli data-line=\"211\">其他实例收到通知后删除对应 L1 缓存；\u003C\u002Fli>\n\u003Cli data-line=\"212\">TTL 作为最终兜底，不作为主要一致性机制。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"214\">Redis TTL 增加随机抖动，避免大量设备同时过期形成缓存雪崩。Redis 异常时开启短时间本机旁路，直接单次回源 PostgreSQL 并写入 L1，避免每条消息反复等待同一个 Redis 故障。\u003C\u002Fp>\n\u003Cp data-line=\"216\">需要注意：Redis 是缓存，不是设备配置的最终事实来源。PostgreSQL 仍是设备、型号和覆盖配置的权威数据源。\u003C\u002Fp>\n\u003Ch2 data-line=\"218\" id=\"8. 实时 SSE 链路\">8. 实时 SSE 链路\u003C\u002Fh2>\n\u003Cp data-line=\"220\">实时链路只做以下工作：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"222\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">消费原始批次\n  → 标准化\n  → 按 tenant + device + point 合并为批次最新值\n  → 更新实时投影\n  → Redis Pub\u002FSub\n  → SSE Hub\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"231\">批次合并示例：\u003C\u002Fp>\n\u003Ctable data-line=\"233\">\n\u003Cthead data-line=\"233\">\n\u003Ctr data-line=\"233\">\n\u003Cth>点位\u003C\u002Fth>\n\u003Cth>批次内时间\u003C\u002Fth>\n\u003Cth style=\"text-align:right\">值\u003C\u002Fth>\n\u003Cth>处理结果\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"235\">\n\u003Ctr data-line=\"235\">\n\u003Ctd>pressure\u003C\u002Ftd>\n\u003Ctd>10:00:00.000\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.61\u003C\u002Ftd>\n\u003Ctd>被同批次新值替代\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"236\">\n\u003Ctd>pressure\u003C\u002Ftd>\n\u003Ctd>10:00:00.500\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.62\u003C\u002Ftd>\n\u003Ctd>被同批次新值替代\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"237\">\n\u003Ctd>pressure\u003C\u002Ftd>\n\u003Ctd>10:00:00.900\u003C\u002Ftd>\n\u003Ctd style=\"text-align:right\">0.63\u003C\u002Ftd>\n\u003Ctd>发布\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"239\">这意味着 SSE 是“最新状态流”，不是完整历史审计流。它追求页面及时性和有界内存，不承诺浏览器逐条接收设备上报的每一个瞬时值。\u003C\u002Fp>\n\u003Cp data-line=\"241\">实时链路明确不执行：\u003C\u002Fp>\n\u003Cul data-line=\"243\">\n\u003Cli data-line=\"243\">InfluxDB 写入；\u003C\u002Fli>\n\u003Cli data-line=\"244\">入库间隔判断；\u003C\u002Fli>\n\u003Cli data-line=\"245\">采样水位读写；\u003C\u002Fli>\n\u003Cli data-line=\"246\">历史数据补传。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"248\">因此，把某点位入库间隔从 1 秒调整为 60 秒，不会把该点位 SSE 推送也降低为每分钟一次。\u003C\u002Fp>\n\u003Ch2 data-line=\"250\" id=\"9. InfluxDB 持久化链路\">9. InfluxDB 持久化链路\u003C\u002Fh2>\n\u003Ch3 data-line=\"252\" id=\"9.1 首个到期样本算法\">9.1 首个到期样本算法\u003C\u002Fh3>\n\u003Cp data-line=\"254\">每个点位维护最后一次成功入库的源时间：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"256\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">key = tenantId + deviceId + pointCode\nvalue = lastPersistedSourceTs\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"261\">判断规则：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"263\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">没有成功水位                         → 选择当前点\n入库间隔为 null                     → 选择当前点\nsourceTs &lt; lastPersistedSourceTs    → 判定为乱序\nsourceTs &gt;= last + interval         → 选择当前点\n其他情况                            → 跳过本次历史入库\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"271\">伪代码：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"273\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">java\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-java\" language=java>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (last != \u003Cspan class=\"hljs-literal\">null\u003C\u002Fspan> &amp;&amp; sourceTs &lt; last) {\n    sendToPersistenceDlq(event);\n} \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> \u003Cspan class=\"hljs-keyword\">if\u003C\u002Fspan> (last == \u003Cspan class=\"hljs-literal\">null\u003C\u002Fspan> || interval == \u003Cspan class=\"hljs-literal\">null\u003C\u002Fspan>\n        || sourceTs &gt;= last + interval.toMillis()) {\n    selected.add(event);\n    provisionalWatermark.put(key, sourceTs);\n} \u003Cspan class=\"hljs-keyword\">else\u003C\u002Fspan> {\n    skipped++;\n}\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Ch3 data-line=\"285\" id=\"9.2 时间示例\">9.2 时间示例\u003C\u002Fh3>\n\u003Cp data-line=\"287\">假设设备每秒上报，点位入库间隔为 5 秒：\u003C\u002Fp>\n\u003Ctable data-line=\"289\">\n\u003Cthead data-line=\"289\">\n\u003Ctr data-line=\"289\">\n\u003Cth>源时间\u003C\u002Fth>\n\u003Cth>SSE\u003C\u002Fth>\n\u003Cth>InfluxDB\u003C\u002Fth>\n\u003Cth>原因\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"291\">\n\u003Ctr data-line=\"291\">\n\u003Ctd>12:00:00\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>写入\u003C\u002Ftd>\n\u003Ctd>首个样本\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"292\">\n\u003Ctd>12:00:01\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>跳过\u003C\u002Ftd>\n\u003Ctd>未到5秒\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"293\">\n\u003Ctd>12:00:02\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>跳过\u003C\u002Ftd>\n\u003Ctd>未到5秒\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"294\">\n\u003Ctd>12:00:03\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>跳过\u003C\u002Ftd>\n\u003Ctd>未到5秒\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"295\">\n\u003Ctd>12:00:04\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>跳过\u003C\u002Ftd>\n\u003Ctd>未到5秒\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"296\">\n\u003Ctd>12:00:05\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>写入\u003C\u002Ftd>\n\u003Ctd>到达下一个周期\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"297\">\n\u003Ctd>12:00:06\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>跳过\u003C\u002Ftd>\n\u003Ctd>未到下一周期\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"298\">\n\u003Ctd>12:00:10\u003C\u002Ftd>\n\u003Ctd>更新\u003C\u002Ftd>\n\u003Ctd>写入\u003C\u002Ftd>\n\u003Ctd>到达下一周期\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"300\">该算法以首次成功入库时间为滚动起点，不强制对齐自然分钟或自然整点。\u003C\u002Fp>\n\u003Ch3 data-line=\"302\" id=\"9.3 批次临时水位\">9.3 批次临时水位\u003C\u002Fh3>\n\u003Cp data-line=\"304\">一个 Kafka 批次内可能包含同一点位的多条消息。判断过程中先更新“批次临时水位”，避免在真实水位尚未提交时选中多个未到期样本。\u003C\u002Fp>\n\u003Cp data-line=\"306\">真实水位只能在 InfluxDB 写入成功后提交：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"308\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">采样判断\n  → 生成候选点\n  → 分片批量写 InfluxDB\n  → 全部写入成功\n  → 更新本地水位\n  → Redis Pipeline 同步水位\n  → Listener 正常返回并提交 Kafka Offset\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"318\">如果 InfluxDB 写入失败：\u003C\u002Fp>\n\u003Cul data-line=\"320\">\n\u003Cli data-line=\"320\">抛出异常，让当前 Kafka 批次重试；\u003C\u002Fli>\n\u003Cli data-line=\"321\">不推进采样水位；\u003C\u002Fli>\n\u003Cli data-line=\"322\">不提交该批次消费位点；\u003C\u002Fli>\n\u003Cli data-line=\"323\">已成功写入的前置分片可能在重试时再次写入，应依靠相同 Tag、Field 和源时间实现幂等覆盖。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"325\" id=\"9.4 本地水位与 Redis 水位\">9.4 本地水位与 Redis 水位\u003C\u002Fh3>\n\u003Cul data-line=\"327\">\n\u003Cli data-line=\"327\">本地并发 Map 是运行实例内的快速水位；\u003C\u002Fli>\n\u003Cli data-line=\"328\">Redis 用于实例重启或分区迁移后的水位恢复；\u003C\u002Fli>\n\u003Cli data-line=\"329\">Redis 使用批量读取和 Pipeline 写入，避免逐点网络往返；\u003C\u002Fli>\n\u003Cli data-line=\"330\">Redis 写入失败时，本地水位继续生效；\u003C\u002Fli>\n\u003Cli data-line=\"331\">Redis 状态丢失可能产生少量重复写，但不能让系统跳过本应保存的数据。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"333\">这是“宁可少量重复，不可静默少写”的可靠性取舍。\u003C\u002Fp>\n\u003Ch2 data-line=\"335\" id=\"10. Kafka Offset、重试与消费组\">10. Kafka Offset、重试与消费组\u003C\u002Fh2>\n\u003Ch3 data-line=\"337\" id=\"10.1 两个消费组的职责\">10.1 两个消费组的职责\u003C\u002Fh3>\n\u003Ctable data-line=\"339\">\n\u003Cthead data-line=\"339\">\n\u003Ctr data-line=\"339\">\n\u003Cth>消费组\u003C\u002Fth>\n\u003Cth>数据源\u003C\u002Fth>\n\u003Cth>首次启动策略\u003C\u002Fth>\n\u003Cth>Offset 推进条件\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody data-line=\"341\">\n\u003Ctr data-line=\"341\">\n\u003Ctd>实时组\u003C\u002Ftd>\n\u003Ctd>原始遥测 Topic\u003C\u002Ftd>\n\u003Ctd>从最新位置开始\u003C\u002Ftd>\n\u003Ctd>当前批次实时投影成功\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr data-line=\"342\">\n\u003Ctd>持久化组\u003C\u002Ftd>\n\u003Ctd>原始遥测 Topic\u003C\u002Ftd>\n\u003Ctd>复用既有入库组位点\u003C\u002Ftd>\n\u003Ctd>当前批次持久化流程成功\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp data-line=\"344\">实时组从最新位置开始，是因为实时页面关心“现在”，不应在首次启用时回放大量历史消息。组一旦产生提交位点，重启后继续从已提交位置恢复。\u003C\u002Fp>\n\u003Cp data-line=\"346\">持久化组沿用旧链路的消费组 ID，可以继承原 Offset，降低切换时从头回放或跳过积压的风险。切换消费组 ID 属于数据语义变更，不能只当普通配置修改。\u003C\u002Fp>\n\u003Ch3 data-line=\"348\" id=\"10.2 重试策略\">10.2 重试策略\u003C\u002Fh3>\n\u003Cp data-line=\"350\">直接双链路模式对处理异常执行持续重试，不将临时故障伪装成成功：\u003C\u002Fp>\n\u003Cul data-line=\"352\">\n\u003Cli data-line=\"352\">失败时不提交 Offset；\u003C\u002Fli>\n\u003Cli data-line=\"353\">日志记录 Topic、分区、Offset、尝试次数、根异常类型和安全错误摘要；\u003C\u002Fli>\n\u003Cli data-line=\"354\">对第一次、2的幂次和固定周期尝试打印错误，减少无限重试时的日志风暴；\u003C\u002Fli>\n\u003Cli data-line=\"355\">乱序点进入持久化 DLQ，不推进正常水位；\u003C\u002Fli>\n\u003Cli data-line=\"356\">DLQ 事件应携带稳定事件标识，消费侧需要防止重复回放。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"358\">持续重试保证不静默丢失，但也意味着永久性配置错误会阻塞对应分区。例如点位覆盖表缺失、字段不兼容或必需 Topic 不存在。因此必须同时建设启动检查、数据库迁移和运维告警，不能只依赖重试。\u003C\u002Fp>\n\u003Ch2 data-line=\"360\" id=\"11. 性能设计\">11. 性能设计\u003C\u002Fh2>\n\u003Ch3 data-line=\"362\" id=\"11.1 批量优先\">11.1 批量优先\u003C\u002Fh3>\n\u003Cul data-line=\"364\">\n\u003Cli data-line=\"364\">Kafka 使用批量 Listener；\u003C\u002Fli>\n\u003Cli data-line=\"365\">标准化按设备消息处理，不把每个点位拆成独立 Kafka 消息；\u003C\u002Fli>\n\u003Cli data-line=\"366\">同批次相同设备复用映射；\u003C\u002Fli>\n\u003Cli data-line=\"367\">SSE 在批次内按点位合并最新值；\u003C\u002Fli>\n\u003Cli data-line=\"368\">Redis 水位使用 \u003Ccode>multiGet\u003C\u002Fcode> 和 Pipeline；\u003C\u002Fli>\n\u003Cli data-line=\"369\">InfluxDB 按可配置最大点数分片写入。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"371\" id=\"11.2 双标准化的成本\">11.2 双标准化的成本\u003C\u002Fh3>\n\u003Cp data-line=\"373\">两个消费组直接消费原始 Topic，意味着标准化逻辑执行两次。这是用计算量换取故障隔离和低延迟的明确取舍。\u003C\u002Fp>\n\u003Cp data-line=\"375\">降低额外成本的方法：\u003C\u002Fp>\n\u003Col data-line=\"377\">\n\u003Cli data-line=\"377\">标准化保持无状态、无阻塞；\u003C\u002Fli>\n\u003Cli data-line=\"378\">设备映射使用 L1\u002FL2 两级缓存；\u003C\u002Fli>\n\u003Cli data-line=\"379\">型号点位按型号共享缓存；\u003C\u002Fli>\n\u003Cli data-line=\"380\">数据库只在冷缓存或配置变更后回源；\u003C\u002Fli>\n\u003Cli data-line=\"381\">避免在标准化热路径进行逐点远程调用；\u003C\u002Fli>\n\u003Cli data-line=\"382\">监控两个消费组各自的 CPU、处理耗时和 Lag。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp data-line=\"384\">当流量规模继续扩大，且双标准化成本超过可接受范围时，可以重新评估标准化 Topic，但必须保证标准化生产层本身可水平扩展、有独立 Lag 监控，并通过压测证明不会重新成为共同瓶颈。\u003C\u002Fp>\n\u003Ch3 data-line=\"386\" id=\"11.3 并发与分区\">11.3 并发与分区\u003C\u002Fh3>\n\u003Cul data-line=\"388\">\n\u003Cli data-line=\"388\">Listener 并发数不应超过 Topic 分区数；\u003C\u002Fli>\n\u003Cli data-line=\"389\">同一设备使用稳定 Kafka Key，尽量保持设备内顺序；\u003C\u002Fli>\n\u003Cli data-line=\"390\">不通过无限增加线程掩盖数据库、Redis 或 InfluxDB 瓶颈；\u003C\u002Fli>\n\u003Cli data-line=\"391\">分区数应根据峰值消息数、平均消息大小、单条点位数和目标处理时间压测确定。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"393\" id=\"12. 脱敏配置示例\">12. 脱敏配置示例\u003C\u002Fh2>\n\u003Cp data-line=\"395\">以下配置仅展示结构，Topic、消费组和地址必须根据环境管理，认证信息不得写入文档或代码仓库。\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"397\" class=\"md-editor-code\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">yaml\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-yaml\" language=yaml>\u003Cspan class=\"md-editor-code-block\">\u003Cspan class=\"hljs-attr\">spring:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">kafka:\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">consumer:\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-comment\"># 持久化链路复用原消费组位点。\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">group-id:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${PERSISTENCE_RAW_GROUP:&lt;persistence-raw-group&gt;}\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">max-poll-records:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${KAFKA_MAX_POLL_RECORDS:200}\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">properties:\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">allow.auto.create.topics:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">false\u003C\u002Fspan>\n\n\u003Cspan class=\"hljs-attr\">platform:\u003C\u002Fspan>\n  \u003Cspan class=\"hljs-attr\">iot:\u003C\u002Fspan>\n    \u003Cspan class=\"hljs-attr\">telemetry:\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">enabled:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">consumer-enabled:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">raw-topic:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${RAW_TELEMETRY_TOPIC:&lt;raw-topic&gt;}\u003C\u002Fspan>\n\n      \u003Cspan class=\"hljs-comment\"># split：实时与持久化使用独立消费组直接消费原始Topic。\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">processing-mode:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">split\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">realtime-consumer-group:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">${REALTIME_GROUP:&lt;realtime-group&gt;}\u003C\u002Fspan>\n      \u003Cspan class=\"hljs-attr\">realtime-concurrency:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">3\u003C\u002Fspan>\n\n      \u003Cspan class=\"hljs-attr\">point-cache:\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">local-enabled:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">local-maximum-size:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">50000\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">local-model-maximum-size:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">10000\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">local-expire-after-access:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">30m\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">redis-ttl:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">2h\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">redis-ttl-jitter:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">10m\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">redis-failure-cooldown:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">30s\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">invalidation-channel:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">&lt;cache-invalidation-channel&gt;\u003C\u002Fspan>\n\n      \u003Cspan class=\"hljs-attr\">sampling:\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">enabled:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">true\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">min-interval:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">1s\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">max-interval:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">1d\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">watermark-ttl:\u003C\u002Fspan> \u003Cspan class=\"hljs-string\">7d\u003C\u002Fspan>\n\n      \u003Cspan class=\"hljs-attr\">persistence:\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">max-points-per-write:\u003C\u002Fspan> \u003Cspan class=\"hljs-number\">1000\u003C\u002Fspan>\n        \u003Cspan class=\"hljs-attr\">replay-enabled:\u003C\u002Fspan> \u003Cspan class=\"hljs-literal\">false\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"440\">实际项目可能使用不同配置前缀，迁移到其他系统时应保持配置语义，不应照抄示例命名。\u003C\u002Fp>\n\u003Cp data-line=\"442\">InfluxDB URL、组织、Bucket、Token，Kafka SASL\u002FSSL 凭据，Redis 密码和配置中心凭据必须通过受控环境变量或密钥管理系统注入。\u003C\u002Fp>\n\u003Ch2 data-line=\"444\" id=\"13. 启动与运行检查\">13. 启动与运行检查\u003C\u002Fh2>\n\u003Cp data-line=\"446\">服务启动时建议输出一条安全摘要：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"448\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">mode=split\nrawTopic=&lt;masked&gt;\nrealtimeRawGroup=&lt;masked&gt;\npersistenceRawGroup=&lt;masked&gt;\nsamplingEnabled=true\nlocalCacheEnabled=true\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"457\">不要输出 Token、密码、完整连接串或原始遥测载荷。\u003C\u002Fp>\n\u003Cp data-line=\"459\">健康检查至少验证：\u003C\u002Fp>\n\u003Cul data-line=\"461\">\n\u003Cli data-line=\"461\">原始遥测 Topic 存在且分区数大于0；\u003C\u002Fli>\n\u003Cli data-line=\"462\">持久化 DLQ 存在，分区数满足回放策略；\u003C\u002Fli>\n\u003Cli data-line=\"463\">当前 Kafka 账号拥有 Describe 和消费权限；\u003C\u002Fli>\n\u003Cli data-line=\"464\">InfluxDB 必要配置非空；\u003C\u002Fli>\n\u003Cli data-line=\"465\">PostgreSQL 必需表存在，包括设备、型号点位和设备点位覆盖表；\u003C\u002Fli>\n\u003Cli data-line=\"466\">Redis 不可用时服务能够进入明确降级状态，而不是无限同步等待。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"468\" id=\"14. 关键日志与指标\">14. 关键日志与指标\u003C\u002Fh2>\n\u003Ch3 data-line=\"470\" id=\"14.1 实时批次日志\">14.1 实时批次日志\u003C\u002Fh3>\n\u003Cp data-line=\"472\">脱敏示例：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"474\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">Realtime batch processed records=120 receivedPoints=840\ncoalescedPoints=390 published=390 costMs=18\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"479\">字段含义：\u003C\u002Fp>\n\u003Cul data-line=\"481\">\n\u003Cli data-line=\"481\">\u003Ccode>receivedPoints\u003C\u002Fcode>：标准化后的合法点位数；\u003C\u002Fli>\n\u003Cli data-line=\"482\">\u003Ccode>coalescedPoints\u003C\u002Fcode>：批次内按设备和点位合并后的数量；\u003C\u002Fli>\n\u003Cli data-line=\"483\">\u003Ccode>published\u003C\u002Fcode>：实际发布到实时投影的数量；\u003C\u002Fli>\n\u003Cli data-line=\"484\">\u003Ccode>costMs\u003C\u002Fcode>：批次总处理时间。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"486\" id=\"14.2 持久化批次日志\">14.2 持久化批次日志\u003C\u002Fh3>\n\u003Cp data-line=\"488\">脱敏示例：\u003C\u002Fp>\n\n        \u003Cdetails  data-line=\"490\" class=\"md-editor-code\" open=\"\">\n          \u003Csummary class=\"md-editor-code-head\">\n            \u003Cdiv class=\"md-editor-code-flag\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"md-editor-code-action\">\n              \u003Cspan class=\"md-editor-code-lang\">text\u003C\u002Fspan>\n              \u003Cspan class=\"md-editor-copy-button\" data-tips=\"复制代码\">复制代码\u003C\u002Fspan>\n              \n              \u003Cspan class=\"md-editor-collapse-tips\">\u003Csvg xmlns=\"http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-circle-chevron-left md-editor-icon\">\u003Ccircle cx=\"12\" cy=\"12\" r=\"10\"\u002F>\u003Cpath d=\"m14 16-4-4 4-4\"\u002F>\u003C\u002Fsvg>\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n          \u003C\u002Fsummary>\n          \u003Cpre>\u003Ccode class=\"language-text\" language=text>\u003Cspan class=\"md-editor-code-block\">Persistence batch processed records=120 candidates=840\nselected=140 written=140 skipped=698 outOfOrder=2\nsamplingCostMs=3 influxWriteCostMs=14 watermarkCommitCostMs=2 costMs=23\u003C\u002Fspan>\u003Cspan rn-wrapper aria-hidden=\"true\">\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003Cspan>\u003C\u002Fspan>\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\n\n        \u003C\u002Fdetails>\n      \u003Cp data-line=\"496\">重点观察：\u003C\u002Fp>\n\u003Cul data-line=\"498\">\n\u003Cli data-line=\"498\">\u003Ccode>selected\u003C\u002Fcode> 应与当前点位间隔配置相符；\u003C\u002Fli>\n\u003Cli data-line=\"499\">\u003Ccode>written\u003C\u002Fcode> 应等于成功写入的选中点数；\u003C\u002Fli>\n\u003Cli data-line=\"500\">\u003Ccode>skipped\u003C\u002Fcode> 增加说明入库间隔正在生效，不代表数据异常；\u003C\u002Fli>\n\u003Cli data-line=\"501\">\u003Ccode>outOfOrder\u003C\u002Fcode> 持续增加需要检查设备时钟或历史补传；\u003C\u002Fli>\n\u003Cli data-line=\"502\">\u003Ccode>influxWriteCostMs\u003C\u002Fcode> 高于总处理预算时，应优先检查 InfluxDB 和网络；\u003C\u002Fli>\n\u003Cli data-line=\"503\">\u003Ccode>watermarkCommitCostMs\u003C\u002Fcode> 异常时检查 Redis，但不能让其掩盖 InfluxDB 结果。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"505\" id=\"14.3 推荐指标\">14.3 推荐指标\u003C\u002Fh3>\n\u003Cul data-line=\"507\">\n\u003Cli data-line=\"507\">两个消费组的 Lag 和最老消息年龄；\u003C\u002Fli>\n\u003Cli data-line=\"508\">实时批次处理时间、接收点数、合并点数和发布点数；\u003C\u002Fli>\n\u003Cli data-line=\"509\">采样选中数、跳过数和乱序数；\u003C\u002Fli>\n\u003Cli data-line=\"510\">InfluxDB 写入点数、批次大小、耗时和失败数；\u003C\u002Fli>\n\u003Cli data-line=\"511\">L1、Redis 点位映射缓存命中率；\u003C\u002Fli>\n\u003Cli data-line=\"512\">PostgreSQL 设备映射和型号点位回源次数；\u003C\u002Fli>\n\u003Cli data-line=\"513\">Redis 失败次数和旁路持续时间；\u003C\u002Fli>\n\u003Cli data-line=\"514\">SSE 连接数、发送失败数和端到端数据新鲜度。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"516\">指标标签不得直接使用设备ID或点位编码，避免监控系统出现高基数。设备级问题通过日志、Trace 或受控诊断接口定位。\u003C\u002Fp>\n\u003Ch2 data-line=\"518\" id=\"15. 验证实例\">15. 验证实例\u003C\u002Fh2>\n\u003Ch3 data-line=\"520\" id=\"15.1 SSE 不受入库间隔影响\">15.1 SSE 不受入库间隔影响\u003C\u002Fh3>\n\u003Cp data-line=\"522\">测试条件：\u003C\u002Fp>\n\u003Cul data-line=\"524\">\n\u003Cli data-line=\"524\">设备每秒上报；\u003C\u002Fli>\n\u003Cli data-line=\"525\">点位入库间隔设置为30秒；\u003C\u002Fli>\n\u003Cli data-line=\"526\">同时观察实时页面和 InfluxDB。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"528\">预期：\u003C\u002Fp>\n\u003Cul data-line=\"530\">\n\u003Cli data-line=\"530\">实时页面持续看到新值；\u003C\u002Fli>\n\u003Cli data-line=\"531\">InfluxDB 相邻保存点约为30秒；\u003C\u002Fli>\n\u003Cli data-line=\"532\">实时消费组 Lag 不跟随持久化写入耗时增长；\u003C\u002Fli>\n\u003Cli data-line=\"533\">持久化日志中 \u003Ccode>skipped\u003C\u002Fcode> 明显大于0。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"535\" id=\"15.2 设备级覆盖优先\">15.2 设备级覆盖优先\u003C\u002Fh3>\n\u003Cp data-line=\"537\">测试条件：\u003C\u002Fp>\n\u003Cul data-line=\"539\">\n\u003Cli data-line=\"539\">型号点位默认间隔为60秒；\u003C\u002Fli>\n\u003Cli data-line=\"540\">某测试设备覆盖为10秒；\u003C\u002Fli>\n\u003Cli data-line=\"541\">另一台同型号设备不设置覆盖。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"543\">预期：\u003C\u002Fp>\n\u003Cul data-line=\"545\">\n\u003Cli data-line=\"545\">被覆盖设备约每10秒保存一次；\u003C\u002Fli>\n\u003Cli data-line=\"546\">未覆盖设备约每60秒保存一次；\u003C\u002Fli>\n\u003Cli data-line=\"547\">两台设备的 SSE 均不受上述间隔影响；\u003C\u002Fli>\n\u003Cli data-line=\"548\">修改覆盖后，缓存失效并在后续新消息中使用新值。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"550\" id=\"15.3 InfluxDB 故障隔离\">15.3 InfluxDB 故障隔离\u003C\u002Fh3>\n\u003Cp data-line=\"552\">测试条件：\u003C\u002Fp>\n\u003Cul data-line=\"554\">\n\u003Cli data-line=\"554\">临时模拟 InfluxDB 写入失败；\u003C\u002Fli>\n\u003Cli data-line=\"555\">保持 Kafka、Redis 和 SSE 客户端正常。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"557\">预期：\u003C\u002Fp>\n\u003Cul data-line=\"559\">\n\u003Cli data-line=\"559\">持久化组重试且 Lag 增长；\u003C\u002Fli>\n\u003Cli data-line=\"560\">失败批次不提交持久化 Offset；\u003C\u002Fli>\n\u003Cli data-line=\"561\">采样水位不推进；\u003C\u002Fli>\n\u003Cli data-line=\"562\">实时组继续消费；\u003C\u002Fli>\n\u003Cli data-line=\"563\">SSE 页面继续更新；\u003C\u002Fli>\n\u003Cli data-line=\"564\">InfluxDB 恢复后持久化组逐步追平积压。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"566\" id=\"15.4 冷缓存与数据库完整性\">15.4 冷缓存与数据库完整性\u003C\u002Fh3>\n\u003Cp data-line=\"568\">测试条件：\u003C\u002Fp>\n\u003Cul data-line=\"570\">\n\u003Cli data-line=\"570\">清除指定测试设备的 L1\u002FL2 映射缓存；\u003C\u002Fli>\n\u003Cli data-line=\"571\">保持设备、型号点位、设备点位覆盖表完整。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"573\">预期：\u003C\u002Fp>\n\u003Cul data-line=\"575\">\n\u003Cli data-line=\"575\">首次消息回源 PostgreSQL；\u003C\u002Fli>\n\u003Cli data-line=\"576\">后续消息命中 L1 或 Redis；\u003C\u002Fli>\n\u003Cli data-line=\"577\">不再重复打印大量相同点位查询；\u003C\u002Fli>\n\u003Cli data-line=\"578\">若必需表缺失，日志明确报告根异常，消费位点不被错误提交。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"580\" id=\"16. 验收清单\">16. 验收清单\u003C\u002Fh2>\n\u003Ch3 data-line=\"582\" id=\"16.1 功能\">16.1 功能\u003C\u002Fh3>\n\u003Cul class=\"contains-task-list\" data-line=\"584\">\n\u003Cli class=\"task-list-item \" data-line=\"584\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 服务启动日志显示处理模式为 \u003Ccode>split\u003C\u002Fcode>。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"585\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 采样开关为启用状态。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"586\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> SSE 在点位入库间隔大于上报周期时仍持续更新。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"587\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> \u003Ccode>samplingIntervalSec = null\u003C\u002Fcode> 的点位每条合法消息均可入库。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"588\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 型号点位间隔能够生效。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"589\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 设备点位覆盖间隔优先于型号默认值。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"590\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 点位覆盖启用状态和工程单位能够正确合并。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"591\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 乱序消息不会推进正常采样水位。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"592\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 配置修改后设备与型号缓存能够主动失效。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"594\" id=\"16.2 可靠性\">16.2 可靠性\u003C\u002Fh3>\n\u003Cul class=\"contains-task-list\" data-line=\"596\">\n\u003Cli class=\"task-list-item \" data-line=\"596\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> InfluxDB 失败不会阻塞实时消费组。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"597\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> InfluxDB 失败时持久化 Offset 不推进。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"598\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> InfluxDB 失败时采样水位不推进。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"599\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> Redis 水位失败最多导致重复写，不导致应写数据被跳过。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"600\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> Redis 映射缓存失败时能够短路降级并回源 PostgreSQL。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"601\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 永久性数据库结构错误能够通过启动检查或明确日志暴露。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"602\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 重启和 Kafka Rebalance 后两个消费组均从各自 Offset 恢复。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"604\" id=\"16.3 性能与观测\">16.3 性能与观测\u003C\u002Fh3>\n\u003Cul class=\"contains-task-list\" data-line=\"606\">\n\u003Cli class=\"task-list-item \" data-line=\"606\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 两个消费组分别监控 Lag。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"607\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 记录实时、采样、InfluxDB写入和水位提交分段耗时。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"608\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 设备映射与型号点位数据库回源次数显著低于消息数。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"609\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> InfluxDB 单次写入点数不超过配置上限。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"610\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> Listener 并发数不超过 Topic 分区数。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"611\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 完成目标规模的持续压测和故障注入测试。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"612\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 使用真实但脱敏的流量确定 P95\u002FP99 和告警阈值。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3 data-line=\"614\" id=\"16.4 安全\">16.4 安全\u003C\u002Fh3>\n\u003Cul class=\"contains-task-list\" data-line=\"616\">\n\u003Cli class=\"task-list-item \" data-line=\"616\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 文档、日志和监控不包含 Token、密码或完整连接串。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"617\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 对外分享不包含真实租户、设备、客户、Topic 和消费组名称。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"618\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> 无效消息日志限制原始载荷长度并执行脱敏。\u003C\u002Fli>\n\u003Cli class=\"task-list-item \" data-line=\"619\">\u003Cinput class=\"task-list-item-checkbox\" disabled=\"\" type=\"checkbox\"> DLQ 和重放操作具备权限控制与审计。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2 data-line=\"621\" id=\"17. 已知边界与后续演进\">17. 已知边界与后续演进\u003C\u002Fh2>\n\u003Col data-line=\"623\">\n\u003Cli data-line=\"623\">双消费组会重复执行标准化，需要持续观察 CPU 和缓存命中率。\u003C\u002Fli>\n\u003Cli data-line=\"624\">SSE 是最新状态流，不是逐条可靠消息流；需要完整历史时应查询 InfluxDB。\u003C\u002Fli>\n\u003Cli data-line=\"625\">固定间隔采用首个到期样本，不计算窗口平均值、最小值或最大值。\u003C\u002Fli>\n\u003Cli data-line=\"626\">Redis 水位丢失可能造成少量重复写，这是为了避免少写数据。\u003C\u002Fli>\n\u003Cli data-line=\"627\">永久性元数据错误会阻塞对应 Kafka 分区，需要数据库迁移门禁和告警处理。\u003C\u002Fli>\n\u003Cli data-line=\"628\">当前功能验证不能替代容量压测；分区数、并发数、批次大小必须按真实峰值校准。\u003C\u002Fli>\n\u003Cli data-line=\"629\">对累计量、状态量、告警和高价值点位，可继续增加“状态变化强制保存”“累计量复位保存”等策略。\u003C\u002Fli>\n\u003Cli data-line=\"630\">若未来重新引入标准化 Topic，应把其作为独立可扩展的数据产品，而不是把单一标准化消费者变成新的串行瓶颈。\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Ch2 data-line=\"632\" id=\"18. 实践总结\">18. 实践总结\u003C\u002Fh2>\n\u003Cp data-line=\"634\">本次实践最重要的结论不是简单地“增加一个采样判断”，而是先把实时性和历史持久化的可靠性边界拆开：\u003C\u002Fp>\n\u003Cul data-line=\"636\">\n\u003Cli data-line=\"636\">Kafka 独立消费组提供 Offset 和故障隔离；\u003C\u002Fli>\n\u003Cli data-line=\"637\">SSE 链路只维护最新状态，不承担历史保存职责；\u003C\u002Fli>\n\u003Cli data-line=\"638\">InfluxDB 链路独立承担入库间隔、水位、批量写和重试；\u003C\u002Fli>\n\u003Cli data-line=\"639\">两级缓存把关系数据库从遥测热路径中移出；\u003C\u002Fli>\n\u003Cli data-line=\"640\">水位在写入成功后提交，保证失败时可以安全重试；\u003C\u002Fli>\n\u003Cli data-line=\"641\">配置错误必须显式暴露，不能通过静默降级掩盖数据库迁移缺失。\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp data-line=\"643\">对于工业物联网系统，实时和历史不是同一个消费语义。只有把二者拆成独立可观测、可失败、可恢复的链路，才能同时获得及时的页面体验和可控的时序存储成本。\u003C\u002Fp>\n",12877,42,"0","",2,0,18,"设备,消费,InfluxDB,实时,标准化","工业物联网遥测双链路与按点位间隔入库技术实践\n\n文档类型：技术与架构实践分享\n编写日期：2026-09-22\n实践状态：SSE 实时推送与 InfluxDB 按点位间隔保存已完成现场功能验证\n适用场景：设备高频遥测、实时大屏、时序数据持久化、Kafka 消费解耦\n\n1.","2026-09-22 00:00:00"]