工业边缘双写一致性:从缓存到多 DB 的工程实战

工业边缘系统里,一份业务数据经常要同时落在多个存储中:SQLite / PostgreSQL 保存业务状态,Redis 缓存热点配置,时序库保存测点历史,搜索索引支撑设备检索,消息队列驱动告警和云端同步。

这些存储没有共同的事务边界。应用里连续写两次,只能得到"先写 A、后写 B",中间任何一步都可能失败。双写一致性要解决的问题,就是在这类环境下让数据状态可解释、可恢复,而不是假装存在一个万能事务。

一、先定义一致性目标

开始选方案前,先问业务真正需要什么一致性:

模型 含义 典型场景
强一致 任一时刻读到的都是最近一次成功提交 控制指令、账户扣减、库存扣减
读己之写 用户写入后,自己立即读到新值 设备配置修改后的管理页
单调读 一旦读到新版本,不会又读到旧版本 运维状态流
最终一致 短暂不一致可接受,但最终收敛 缓存、搜索索引、云端镜像

工业现场还需要额外考虑:

  • 断网与断电:边缘节点可能在任何两步之间宕机;
  • 本地优先:设备控制和采集不能依赖云端事务;
  • 网络分区:本地可写,云端暂时不可达;
  • 时钟漂移:不能只靠时间戳判断新旧;
  • 长期运行:进程升级、存储迁移和 schema 变更不能破坏旧数据。

因此,边缘侧常见目标是:本地主存储强一致,派生存储最终一致,关键路径可回放和可对账

二、双写到底在哪里失败

1. 写入失败

text 复制代码
写 DB 成功 → 写缓存失败 → 缓存长期返回旧值
写 DB 成功 → 发消息失败 → 下游永远看不到事件
写业务库成功 → 写时序库失败 → 报表缺数

连续调用不是事务。第二步失败时,第一步通常不会自动回滚。

2. 并发写乱序

text 复制代码
线程 1:写 DB(v2) → 删除缓存
线程 2:写 DB(v3) → 删除缓存
线程 1:回填缓存 v2

最终 DB 是 v3,缓存却是 v2。线程调度、网络重试、缓存回填都可能造成旧值晚到。

3. 重试导致重复

生产者发布消息后,还没等到确认就宕机;重启后重新发布。下游如果没有幂等,同一条设备事件会落两次。

4. 部分失败后的自动修复

定时任务读取源库修复缓存,但修复任务本身也可能失败;如果只记录"已同步"而没有版本,无法判断下一次是否还需要修复。

5. 拆库后的跨库事务

PostgreSQL 与 Elasticsearch、Redis、MQ 之间无法使用普通本地事务。XA / 2PC 理论上可以协调多资源,但在边缘设备上运维成本高、可用性差,通常只在少数强一致场景使用。

三、Cache-Aside:最常用的缓存模式

Cache-Aside 的基本原则是:DB 是 source of truth,缓存只是可丢弃的派生数据

python 复制代码
async def get_device(device_id: str):
    cache_key = f"device:{device_id}"
    cached = await cache.get(cache_key)
    if cached is not None:
        return cached

    device = await db.fetch_device(device_id)
    if device is None:
        await cache.set(cache_key, "NOT_FOUND", ttl=60)
        return None

    await cache.set(cache_key, encode(device), ttl=300)
    return device


async def update_device(device_id: str, patch: dict):
    version = await db.update_device_and_version(device_id, patch)

    try:
        await cache.delete(f"device:{device_id}")
    except CacheError:
        await invalidation_queue.enqueue(
            key=f"device:{device_id}",
            version=version,
            reason="cache_delete_failed",
        )

推荐使用"更新 DB 后删除缓存",而不是把新值直接塞回缓存:

  • 删除是幂等操作;
  • 下一次 miss 时从 DB 回填;
  • 避免应用计算出的缓存格式与 DB 派生逻辑不一致;
  • 缓存写失败可以进入失效重试队列。

Cache-Aside 的竞态

即使先写 DB 后删缓存,仍可能出现:

text 复制代码
读请求 1:cache miss,从 DB 读到 v2
写请求:DB 更新为 v3,并删除缓存
读请求 1:把 v2 写回缓存

缓解方式:

  1. 使用较短 TTL;
  2. 缓存值携带 version,回填时用 CAS 或 Lua 脚本拒绝旧版本;
  3. 对热点 key 使用单写者或按 key 串行化;
  4. 延迟后再删一次;
  5. 后台对账修复。

这些手段降低不一致窗口,但都不等价于严格事务。

四、Write-Through 与 Write-Behind

Write-Through

Write-Through 指应用只访问缓存组件,由缓存层同步写后端存储:

text 复制代码
应用 → cache layer → DB
              ↓
           cache

它的前提是缓存组件同时管理 DB 写入和缓存更新,而不是应用自己分别调用两个客户端。即使如此,也要明确:

  • DB 写成功但缓存元数据更新失败时如何恢复;
  • 多个写入者如何避免乱序;
  • 缓存层故障时是否自动降级直读 DB;
  • 是否存在事务日志和恢复流程。

下面这种代码并不是严格 Write-Through,只是应用侧双写:

python 复制代码
async def update_device_naive(device_id: str, patch: dict):
    await db.update_device(device_id, patch)
    await cache.set(f"device:{device_id}", patch, ttl=300)

它无法处理第二步失败,也无法保证并发版本顺序。生产上至少要改成"写 DB + 删除缓存 + 失效重试"。

Write-Behind

Write-Behind / Write-Back 是先写缓存,再异步批量写 DB。它适合高频测点写入,但必须持久化写队列,否则断电丢数据。

适用条件:

  • 数据允许少量延迟;
  • 写队列落盘;
  • 有 watermark 和溢出策略;
  • 有按 key 合并和排序机制;
  • 消费端幂等;

不适合关键控制指令、告警确认和计费类数据。

五、延迟双删:缓解手段,不是保证

延迟双删常见写法:

python 复制代码
async def update_device_with_delayed_delete(device_id: str, patch: dict):
    await cache.delete(f"device:{device_id}")
    version = await db.update_device_and_version(device_id, patch)

    asyncio.create_task(
        delayed_delete(
            key=f"device:{device_id}",
            delay_seconds=1,
            version=version,
        )
    )


async def delayed_delete(key: str, delay_seconds: float, version: int):
    await asyncio.sleep(delay_seconds)
    await cache.delete_if_version_older_than(key, version)

它的目标是清理第一轮并发读回填的旧值。但问题很明显:

  • 延迟时间没有通用公式,取决于慢查询和并发窗口;
  • 延迟任务可能丢失;
  • 过长延迟意味着长时间读旧值;
  • 过短延迟可能仍清不掉竞态;
  • 第二次删除也可能失败。

工程上可以把延迟双删作为辅助,但核心仍应是:

  • 缓存值带版本;
  • TTL 兜底;
  • 删除失败入队重试;
  • 后台对账修复;
  • 热点 key 串行化。

不要把"延迟双删"宣传成强一致方案。

六、Outbox:DB 与消息的可靠桥

Outbox 模式的核心是:业务数据和事件写入同一个本地数据库事务。

表结构示例

sql 复制代码
CREATE TABLE outbox (
    id BIGSERIAL PRIMARY KEY,
    aggregate_type TEXT NOT NULL,
    aggregate_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    headers JSONB NOT NULL DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    published_at TIMESTAMPTZ,
    status TEXT NOT NULL DEFAULT 'pending',
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX outbox_pending_idx
ON outbox (next_retry_at, id)
WHERE status = 'pending';

业务写入:

python 复制代码
async def create_work_order(order_data: dict):
    async with db.transaction():
        order = await db.insert_work_order(order_data)

        await db.execute(
            """
            INSERT INTO outbox (
                aggregate_type, aggregate_id, event_type, payload, headers
            )
            VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
            """,
            "work_order",
            str(order["id"]),
            "work_order.created",
            json.dumps(order),
            json.dumps({
                "event_id": str(uuid.uuid4()),
                "trace_id": current_trace_id(),
            }),
        )

    return order

这个事务保证两件事:

  1. work order 和事件要么同时可见;
  2. work order 未提交时,事件绝不会先暴露给 Relay。

Relay 投递

python 复制代码
async def run_outbox_relay():
    while not shutdown_event.is_set():
        events = await db.fetch_pending_outbox(limit=100)
        if not events:
            await shutdown_event.wait(1)
            continue

        for event in events:
            try:
                await message_bus.publish(
                    topic=event["event_type"],
                    key=event["aggregate_id"],
                    value=event["payload"],
                    headers=event["headers"],
                )
                await db.mark_outbox_sent(event["id"])
            except PublishError:
                await db.schedule_outbox_retry(
                    event_id=event["id"],
                    backoff_seconds=next_backoff(event["retry_count"]),
                )

更严谨的 Relay 可以使用:

  • SELECT ... FOR UPDATE SKIP LOCKED 支持多实例;
  • 发布确认后再标记 sent;
  • 按聚合 ID 保序;
  • 最大重试次数和死信状态;
  • archived 表或按时间归档;
  • lag、retry、dead letter 指标。

至少一次与幂等消费

Outbox 得到的是 at-least-once,不是 exactly-once。消费者必须有 Inbox:

sql 复制代码
CREATE TABLE inbox (
    event_id UUID PRIMARY KEY,
    consumer_group TEXT NOT NULL,
    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    processed_at TIMESTAMPTZ
);

处理流程:

text 复制代码
开启本地事务
  → INSERT inbox(event_id)
  → 若主键冲突,说明已处理,直接 ack
  → 执行业务写入
  → 提交事务

如果业务处理和事件确认不能同事务,需要保存处理状态,并在重启后重放。

七、CDC:从数据库日志派生变更

CDC(Change Data Capture)监听数据库 binlog / WAL / logical replication,把已提交变更发布出去。它适合 DB → 搜索索引、DB → 消息、DB → 云端镜像等派生链路。

概念流程:

text 复制代码
业务只写 DB
     ↓
DB commit log / WAL
     ↓
CDC connector
     ↓
Kafka / MQ
     ↓
索引、缓存、云端镜像消费者

一个 Kafka Connect 风格的配置示例:

yaml 复制代码
name: edge-postgres-outbox-connector
config:
  connector.class: io.debezium.connector.postgresql.PostgresConnector
  database.hostname: 127.0.0.1
  database.port: "5432"
  database.user: edge_relay
  database.dbname: edge
  topic.prefix: edge
  table.include.list: public.outbox
  tombstones.on.delete: "false"
  snapshot.mode: initial

CDC 的工程要求:

  • 记录 offset,并持久化消费进度;
  • 处理 schema evolution 和 DDL 兼容;
  • 区分 create / update / delete 和 tombstone;
  • 保留 WAL / binlog 足够长时间;
  • 部署故障后能重新 snapshot 或从 checkpoint 恢复;
  • 消费端仍要幂等;
  • 变更事件带聚合 ID、版本或 LSN,便于排序。

Outbox 与 CDC 的关系

Outbox 解决"事件与业务写入同事务";CDC 解决"可靠读取已提交事件"。二者经常组合:

text 复制代码
业务 + outbox 行
     同一 DB 事务
CDC 读取 outbox 表
     发布消息
消费者幂等写入派生存储

相比应用内 Relay,CDC 减少"扫表轮询"压力,并读取数据库日志,但部署和运维复杂度更高。资源受限的边缘设备上,先用简单 Relay 往往更可控。

八、多 DB 场景的落地选择

1. DB + 缓存

优先 Cache-Aside:

  • DB 是权威数据;
  • 更新后删除缓存;
  • 删除失败进入重试队列;
  • 使用 TTL 和版本兜底;
  • 后台对账修复。

2. DB + 搜索索引

优先 Outbox / CDC:

  • 业务写 DB;
  • 事件携带聚合 ID 和版本;
  • 消费者更新 Elasticsearch / OpenSearch;
  • 冲突时按版本或 DB 更新时间拒绝旧文档;
  • 定期抽样比对;
  • 索引可全量重建。

3. DB + 消息

必须用 Outbox,避免"写 DB 后直接 publish"的中间失败。

4. 业务 DB + 时序 DB

测点高频数据不要为每条记录启动跨库事务。推荐:

  • 分批缓冲;
  • 本地 WAL / 队列持久化;
  • 按设备和时间窗口保序;
  • 写失败重试;
  • 断网期间本地缓存;
  • 恢复后按 chunk 补传;
  • 云端按 (device_id, timestamp, seq) 去重。

业务状态与关键事件仍走 Outbox。

5. 本地 DB + 云端 DB

边缘断网时本地继续运行,云端同步应是异步链路:

text 复制代码
本地业务事务 + outbox
     ↓
同步服务读取 outbox
     ↓
云端 API / 消息入口
     ↓
云端按 event_id 幂等入库

冲突策略要显式定义:

  • 设备采集数据以边缘时间序列和序列号为准;
  • 远程配置以配置版本和审批状态为准;
    -人工操作以事件顺序和操作日志为准;
  • 不能只比较两台机器的本地时钟。

6. 双主或多边缘节点

如果两个节点都能写同一业务对象,需要:

  • 按 key 分片,让同一 key 只有单一写入者;
  • 租约和 fencing token 防止旧主继续写;
  • 版本向量或逻辑时钟;
  • 冲突解决规则;
  • 审计日志。

不要假设 Merkle、CRDT 或"最后写入 wins"能自动满足工业业务语义。

九、监控与对账

双写链路必须可观测。建议指标:

指标 用途
outbox_pending_count 判断事件积压
outbox_publish_lag_seconds 评估消息延迟
outbox_retry_count 发现投递异常
outbox_dead_letter_count 发现不可自动恢复事件
cache_invalidation_failure_count 发现缓存失效失败
index_lag_seconds 评估搜索索引落后
timeseries_write_lag_seconds 评估时序入库延迟
consistency_check_diff_count 发现派生存储差异
duplicate_event_count 评估幂等有效性
repair_success / repair_failure 跟踪自动修复质量

对账不要简单比较对象字符串:

python 复制代码
async def check_device_consistency(sample_size: int = 100):
    devices = await db.fetch_random_devices(sample_size)

    for device in devices:
        cached = await cache.get(f"device:{device['id']}")
        if cached is None:
            continue

        if decode(cached)["version"] != device["version"]:
            consistency_diff_counter.inc(labels={"store": "cache"})
            await repair_device_cache(device["id"])

更好的对账方式:

  • 比较聚合 ID、版本号、更新序列或内容哈希;
  • 区分结构差异和序列化差异;
  • 记录差异原因;
  • 限制修复速率;
  • 修复前后写审计日志;
  • 无法自动修复的数据进入人工队列。

十、测试清单

上线前至少覆盖:

  1. 写 DB 成功、删缓存失败;
  2. 写 DB 成功、发消息失败;
  3. 消息发布成功但 ack 丢失;
  4. 消费者重复收到同一 event ID;
  5. 两个写请求并发更新同一 key;
  6. 旧版本读请求晚于新版本回填;
  7. Relay 在标记 sent 前崩溃;
  8. 消费者在业务写入库前崩溃;
  9. 本地断网、云端不可达、恢复后补传;
  10. 存储磁盘满和 quota 不足;
  11. schema 升级后事件仍可解析;
  12. 时钟回拨场景下的排序;
  13. 索引被删除后可全量重建;
  14. Outbox 死信可人工重放。

推荐把故障注入做成自动化测试,而不是靠现场偶然发现。

十一、常见坑与处理

现场表现 处理
把连续调用当事务 第二步失败后长期不一致 本地事务 + Outbox / 失效队列
缓存写新值而不是删除 并发旧值覆盖新值 Cache-Aside 删除缓存,带版本回填
延迟双删当强一致 偶发旧值仍存在 版本、TTL、重试和后台对账
Outbox 无重试上限 坏事件反复占用队列 退避、死信和人工处理
消费端不幂等 重复事件重复入库 Inbox 或唯一键约束
事件无版本 旧事件后到覆盖新状态 聚合版本 / LSN / 序列号
CDC offset 丢失 重启后漏事件或重扫 持久化 checkpoint 和恢复演练
双主随意写 冲突无法解释 单写者、租约、fencing 和冲突规则
对账直接比较 JSON 字符串 误报大量差异 规范化编码后比较版本或哈希
只监控写成功率 不知道下游落后 监控 lag 和一致性差异

TL;DR

双写一致性的正确起点不是选一个技巧,而是明确 source of truth 和一致性等级。缓存这类可丢弃数据适合 Cache-Aside、删除缓存、TTL 与版本兜底;DB 与消息必须优先 Outbox;搜索索引和云端镜像适合 Outbox / CDC 加幂等消费;业务库与时序库要用本地持久化队列、批量写入和断点补传。

延迟双删只能缩小不一致窗口,不能提供强一致保证。真正可靠的工程组合是:本地事务、至少一次投递、事件 ID、聚合版本、幂等消费、lag 监控、对账修复和故障注入测试。

相关推荐
边缘计算社区13 分钟前
从 Byte 到 Token,重新认识网宿科技
人工智能·科技·边缘计算
志栋智能23 分钟前
运维不再“猜谜”:自动化定界终结无效排查
运维·数据库·自动化
职场的momo26 分钟前
16384个槽怎么搬?解析Redis集群迁移与脑裂防御
数据库·redis·缓存
隔窗听雨眠29 分钟前
异构数据库迁移至南大通用GBase 8a完全指南:从调研到双轨切换的实战路径
数据库
CNSSIRD数据库31 分钟前
中国企业海关信用数据库
大数据·数据库·数据分析·论文笔记
cfm_29141 小时前
Redis大Key与热Key分析
redis·缓存
算法大模型备案干货咪1 小时前
《大模型备案工程实操:语料标注规则、关键词拦截列表与测试题集的设计》
数据库·人工智能
Elastic 中国社区官方博客1 小时前
安装 Elasticsearch
大数据·数据库·elasticsearch·搜索引擎·全文检索
小白说大模型1 小时前
基于 Qwen3.8-Max 构建 AI 合同精审系统:文档解析、多轮条款分析 Pipeline 工程实战
数据库·人工智能·spring·机器学习·自然语言处理