工业边缘双写一致性:从缓存到多 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 监控、对账修复和故障注入测试。

相关推荐
前端 贾公子17 分钟前
LangGraph == 图的状态(State)管理 (下)
运维·服务器·数据库
zlwool27 分钟前
机械行业ERP与进销存的合规选型边界
数据库·erp
梦想的颜色32 分钟前
【AI速览】RAG 向量数据库选型:Milvus、Chroma、Qdrant、PGVector 全维度横向对比
数据库·milvus·chroma·rag·pgvector·向量库·qdrant
小溪学编程1 小时前
向量数据库 Milvus 基础实践:从入门到上手
数据库·milvus
数据最前线1 小时前
雪上加霜,Oracle向蓝色巨人最后的阵地发起挑战
数据库·oracle
于眠牧北1 小时前
Charles配置指定域名抓取和雷电模拟器中抓取APP接口配置
数据库·redis·缓存
福娃筱欢2 小时前
57014 canceling statement due to user request(金仓 KingbaseES)
数据库·kingbase·金仓数据库
ClouGence2 小时前
SAP HANA 数据同步遇到长事务,如何兼顾实时性与完整性?
数据库·后端
数据库小学妹2 小时前
数据库管理工具有哪些?2026主流工具分类与选型对照
数据库·数据库管理工具·数据库工具·信创数据库·数据库客户端
bingHHB2 小时前
销售订单同步实战:MES生产领料/补料→金蝶材料出库
数据库·集成测试