说明:专栏分布式篇的门店断网演练里有一条隐藏缺口:断网期间门店本地发生的事件,恢复后如何补到总部?本文设计边缘站点的缓存与续传架构:本地环形缓冲、断网状态机、恢复后增量上报与去重,让边缘站点在"孤岛期"既能自治又能补账。代码可直接改造进边缘网关。不放站外链接。
一、孤岛期的两个问题
门店断网(宽带故障、光缆施工、欠费停机)时,专栏分布式篇的架构下会发生什么:
- 总部失明:OOB 通道(专栏带外篇)能报"门店失联",但失联期间门店里发生了什么------本地告警、设备离线、异常恢复------总部一无所知;
- 恢复后丢账 :网络恢复,总部只看到"门店回来了",中间几小时的事件永远消失。
孤岛期的目标因此是两句话:孤岛期间本地自治照常(声光播报不依赖总部),恢复后增量补账(事件不丢、不重、有序)。这就是边缘计算的"最终一致":边缘与总部短暂分叉,最终汇合。
二、边缘架构:本地自治 + 环形缓冲
边缘网关(门店侧的小主机或路由器插件)承担两个角色:
- 自治面:本地告警照常触发声光终端(本地链路,专栏分布式篇的"本地响")------断网不影响门店自己的告警能力;
- 缓冲面 :所有本应上报总部的事件写入本地环形缓冲,网络恢复后按序补传。
python
# edge_buffer.py --- 边缘事件缓冲:本地写、恢复后按序续传、去重
import datetime as dt
import json
import pathlib
import sqlite3
import time
import requests
DB = pathlib.Path("edge_buffer.db")
HQ = "https://hq-bus.internal/ingest"
BATCH = 50
RETRY_BACKOFF = [5, 15, 60, 300] # 恢复后的重试退避(秒)
def buffer(event: dict, now: float = None) -> None:
"""任何本应上报的事件先落本地库------断网时这是唯一动作"""
now = now or time.time()
conn = sqlite3.connect(DB)
conn.execute(
"INSERT INTO events(ts, device, payload, sent) VALUES(?,?,?,0)",
(now, event.get("device", "?"),
json.dumps(event, ensure_ascii=False)))
conn.commit()
def flush() -> int:
"""网络恢复后按时间序分批上报:成功才标记 sent,失败退避重试"""
conn = sqlite3.connect(DB)
rows = conn.execute(
"SELECT rowid, ts, payload FROM events WHERE sent=0 "
"ORDER BY ts LIMIT ?", (BATCH,)).fetchall()
if not rows:
return 0
payload = [{"ts": ts, "event": json.loads(pl)} for _, ts, pl in rows]
try:
r = requests.post(HQ, json={"events": payload}, timeout=10)
r.raise_for_status()
except requests.RequestException:
delay = RETRY_BACKOFF[min(len(RETRY_BACKOFF) - 1, _fail_streak())]
time.sleep(delay)
return 0
ids = [rid for rid, _, _ in rows]
conn.executemany("UPDATE events SET sent=1 WHERE rowid=?", [(i,) for i in ids])
conn.commit()
return len(ids)
def _fail_streak() -> int:
return 0 # 实现略:连续失败计数存内存
四个关键设计:先落库再谈上报 (事件的第一站是本地 SQLite,不是网络);按时间序补传 (总部收到的是有序流,专栏审计篇的对账才有意义);成功才标记 (半批失败整批重试,服务端按事件 ID 幂等去重------专栏总线篇的 dedup_key);退避重试(恢复瞬间不冲击总线)。
三、去重与对账:补传不等于重放
续传最大的风险是重复:断线重连边界上的事件、重试批次与定时批次的交叠。三重防线:
- 事件 ID 幂等:每条事件带边缘侧生成的唯一 ID(设备前缀 + 序号),总部按 ID 去重;
- 水位确认:总部返回"已收到的最大序列号",边缘只补发之后的事件;
- 补账标记 :补传事件带
backfill=true标志------总部看板把它们与实时告警区分展示(补账事件不触发声光重播,只进记录),避免"恢复后把几小时前的告警再喊一遍"。
补账与总部的对账合并成一张表:断网时长 × 缓冲条数 × 补传条数,三个数字对得上,孤岛期才算真正"无损失"。
四、状态机:边缘网关的网络状态管理
python
# edge_state.py --- 边缘网关的网络状态机
from enum import Enum
class NetState(Enum):
ONLINE = "online"
DEGRADED = "degraded" # 时通时断:只缓冲高优先级事件
OFFLINE = "offline" # 全缓冲,本地自治
def next_state(state: NetState, ok_count: int, fail_count: int,
offline_minutes: float) -> NetState:
"""双阈值滞回:避免在弱网边界来回抖动"""
if state == NetState.ONLINE and fail_count >= 3:
return NetState.OFFLINE
if state == NetState.OFFLINE and ok_count >= 5:
return NetState.ONLINE
if state == NetState.ONLINE and offline_minutes >= 1:
return NetState.DEGRADED
return state
三态各有限流策略:ONLINE 全量实时上报;DEGRADED (弱网)只缓冲 critical/warning,info 类直接本地丢弃(弱网带宽优先保关键事件);OFFLINE 全缓冲。状态机与专栏 MQTT 篇的遗嘱配合------边缘网关自己也向总部发遗嘱,"网关死了"与"网关在缓存"总部可区分。
五、常见坑
- 本地库无限增长 → 长期断网把 SD 卡写满,环形缓冲要有上限(如 10 万条)与覆盖策略(覆盖最旧,并在恢复后标注"有丢弃");
- 恢复瞬间冲击总线 → 500 家门店同时恢复,总线的 Redis 瞬间被打爆;补传分批 + 全局令牌桶(专栏总线篇);
- 重复告警重播 → 补账事件没带 backfill 标志,恢复后喇叭把旧告警再喊一遍;
- 边缘时钟漂移 → 断网期间边缘 RTC 漂移,事件时间戳失真;恢复后按总部时间校准并标注偏移量;
- 缓冲的 critical 永不升级 → 孤岛期的 critical 只有本地声光,总部不知情------OOB 通道(专栏带外篇)负责把"门店离线 + 离线期间有 critical"先送出去;
- 只缓冲不告警 → 以为"反正会补传",本地声光也停了------本地自治是第一原则;
- 续传成功但总部解析失败 → schema 版本不匹配,补传批次被静默丢弃------响应里要有接收确认计数;
- 边缘网关单点 → 网关挂了缓冲全丢,网关进程守护 + 关键事件双写(SD + 内存快照)。
六、验收清单
- 断网演练:拔线 2 小时,本地声光照常,恢复后 15 分钟内补传完成;
- 补传零重复:总部按事件 ID 去重后计数 = 边缘缓冲计数;
- 补账事件带 backfill 标志,不触发声光重播;
- 三态状态机验证:弱网模拟下 critical 全缓冲、info 正常丢弃;
- 断网时长 / 缓冲数 / 补传数三数字对账表生成;
- 边缘时钟恢复后校准并标注偏移,长期断网环形覆盖有告警。
七、小结
边缘缓存与续传回答的是分布式告警的终极问题:**总部看不见的时候,边缘还算不算体系的一部分?**答案是:算------本地自治保证它仍有告警能力,环形缓冲保证它的事件不丢,补传机制保证账最终能对上。三者合起来,孤岛期从"体系盲区"变成"延迟可见的一等公民"。
它与专栏既有各篇严丝合缝:本地自治是分布式篇的"本地响",缓冲是总线篇削峰的边缘版,去重是 dedup_key 的跨站应用,对账是日志篇的方法论。架构没有新增哲学,只是把"最终一致"四个字落到了门店的网关里------光缆再被挖断一次,你的账本这次是全的。