工业边缘系统里,一份业务数据经常要同时落在多个存储中: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 写回缓存
缓解方式:
- 使用较短 TTL;
- 缓存值携带
version,回填时用 CAS 或 Lua 脚本拒绝旧版本; - 对热点 key 使用单写者或按 key 串行化;
- 延迟后再删一次;
- 后台对账修复。
这些手段降低不一致窗口,但都不等价于严格事务。
四、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
这个事务保证两件事:
- work order 和事件要么同时可见;
- 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、版本号、更新序列或内容哈希;
- 区分结构差异和序列化差异;
- 记录差异原因;
- 限制修复速率;
- 修复前后写审计日志;
- 无法自动修复的数据进入人工队列。
十、测试清单
上线前至少覆盖:
- 写 DB 成功、删缓存失败;
- 写 DB 成功、发消息失败;
- 消息发布成功但 ack 丢失;
- 消费者重复收到同一 event ID;
- 两个写请求并发更新同一 key;
- 旧版本读请求晚于新版本回填;
- Relay 在标记 sent 前崩溃;
- 消费者在业务写入库前崩溃;
- 本地断网、云端不可达、恢复后补传;
- 存储磁盘满和 quota 不足;
- schema 升级后事件仍可解析;
- 时钟回拨场景下的排序;
- 索引被删除后可全量重建;
- 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 监控、对账修复和故障注入测试。