工业网关要处理的不只是"把数据写出去",还包括断电后能不能恢复、进程崩溃后会不会丢状态、存储写满时会不会覆盖旧数据、现场掉电重连后能不能从断点继续补传。
很多项目的持久化问题并不是因为没有调用 write(),而是因为把"写入接口返回成功"误认为"数据已经可靠落盘"。本文按工程落地顺序梳理:持久化语义、同步与异步策略、WAL 记录格式、Group Commit、Checkpoint、原子写入、文件系统选择、掉电测试和监控指标。
一、先把持久化目标定义清楚
讨论持久化前,先明确系统要防御哪类故障。
| 故障边界 | 需要的机制 | 典型效果 |
|---|---|---|
| 应用异常退出 | 状态可序列化,或使用进程外存储 | 内存队列会丢,文件/数据库可恢复 |
| 操作系统崩溃 | fsync / fdatasync / 数据库事务 |
已提交状态不依赖 page cache |
| 网关突然掉电 | 存储介质真实持久化,必要时禁用易失写缓存 | 掉电前已确认的数据可恢复 |
| 存储损坏或写满 | 校验、告警、保留策略、备份 | 能发现故障,而不是静默覆盖 |
| 断网后恢复 | 本地 WAL / 队列水位 / 补传状态 | 数据不丢、不乱序、不无限重复 |
因此,工业网关要先定义每个数据流的 RPO:
- 遥测数据能接受丢最近多少秒;
- 控制命令、配置变更、计量结算数据是否要求返回前必须落盘;
- 断网缓存最多保留多久、占多少空间;
- 存储写满时淘汰低价值数据,还是停止接收高价值数据;
- 补传时如何保证幂等,避免同一条记录重复入库。
这些答案会直接决定是否每条记录 fsync,还是允许 WAL 批量提交。
二、同步写入、异步写入与 WAL 的取舍
1. 同步写入
每次写入后执行 fsync,fsync 返回后才向业务确认。
优点是确认语义强:调用成功后,进程崩溃、系统崩溃和掉电场景下数据都应可恢复。代价是延迟高、IOPS 低,对 eMMC / SD 卡的写入寿命也更不友好。
适合控制命令、授权变更、计量和审计等低频但高价值数据。
2. 异步写入
数据先进入内存队列,后台线程定期批量写文件。
优点是性能好、写入平滑。问题是进程崩溃或掉电时,未落盘的数据会丢;如果队列无上限,故障时还会造成内存膨胀。
异步写入必须配置:
- 队列最大长度或最大字节数;
- 最大等待时间;
- 磁盘写满策略;
- 背压策略;
- 重启后的恢复水位。
3. Write-Ahead Log
WAL(Write-Ahead Log,预写日志)的核心原则是:先把变更写入可恢复的日志,确认日志持久化后,再修改主状态或向业务确认成功。
它不是简单追加文件。一个可用的 WAL 至少要处理:
- 记录长度与边界;
- CRC 或其他完整性校验;
- 单调序列号;
- 半条记录写入的情况;
- 版本与 schema 兼容;
- 并发写入串行化;
- Checkpoint 后的日志截断或轮转。
4. Group Commit
如果每条记录都单独 fsync,延迟会被大量小事务放大。Group Commit 把一段时间内的多条记录合成一次写盘和一次 fsync,所有参与者等这批日志持久化后一起收到确认。
它不会降低"已确认数据必须可恢复"的语义,只是把强制落盘的成本分摊到多条记录上。
| 策略 | 确认时是否已落盘 | 掉电 RPO | 吞吐 | 适用 |
|---|---|---|---|---|
| 单条同步写 | 是 | 已确认数据不丢 | 低 | 命令、配置、审计 |
| 异步批量写 | 否 | 可能丢一批 | 高 | 非关键采样 |
| WAL + 单条 fsync | 是 | 已确认数据不丢 | 中 | 低频关键事件 |
| WAL + Group Commit | 是 | 已确认数据不丢 | 较高 | 高频事件、断网缓存 |
这里的"不丢"有一个前提:存储设备自身在掉电后能真实保存已完成写入。如果磁盘或 eMMC 模组存在易失写缓存,而系统没有正确处理,软件层的 fsync 也无法提供期望的掉电持久性。
三、先修正几个 fsync 误区
1. write() 成功不等于落盘
write() 通常只把数据交给内核 page cache。函数返回成功,说明用户态缓冲区已经交出去,并不保证设备已经写入。
2. Python flush() 不等于 fsync
Python 文件对象的 flush() 主要把 Python / C 库用户态缓冲刷到内核。要让已写入数据和必要元数据持久化,仍需:
python
f.flush()
os.fsync(f.fileno())
3. close() 不保证 fsync
关闭文件会释放描述符,不等于显式执行了 fsync。依赖关闭时偶然发生的回写,不能作为可靠性设计。
4. rename 前后都要考虑持久化
原子替换文件的常见流程是:
- 写临时文件;
flush并fsync临时文件;rename临时文件为目标文件;fsync所在目录。
第 4 步经常被漏掉。目录项变更本身也属于元数据,掉电后可能出现"数据文件已持久化,但 rename 未持久化"的情况。
5. 检查存储写缓存语义
fsync 要求存储栈完成到介质的持久化,但如果设备存在未受控的易失写缓存,语义可能被破坏。工程上要确认:
- SSD / eMMC 是否有掉电保护电容;
- 设备写缓存是否安全启用;
- NVMe / SATA 的缓存语义;
- 嵌入式板卡电源保持时间是否足够完成最后一批写入;
- 文件系统、驱动和介质是否有厂商认证组合。
对工业网关,电源设计和存储选型也是持久化设计的一部分。
四、一个更完整的 WAL 记录格式
只写 type + length + payload 不够。掉电时可能只写入半条记录,日志中段也可能因存储异常出现位翻转。下面的教学版实现加入魔数、版本、序列号和 CRC:
python
import os
import struct
import zlib
from dataclasses import dataclass
from fcntl import flock, LOCK_EX, LOCK_SH, LOCK_UN
MAGIC = b"EW01"
HEADER = struct.Struct("!4sHHQI")
MAX_PAYLOAD = 8 * 1024 * 1024
@dataclass(frozen=True)
class WalRecord:
version: int
record_type: int
sequence: int
payload: bytes
class SimpleWal:
def __init__(self, path: str):
self.path = path
self.next_sequence = 1
self.healthy = True
self.fd = os.open(
path,
os.O_RDWR | os.O_CREAT | os.O_APPEND,
0o600,
)
self._recover_next_sequence()
def _recover_next_sequence(self):
last_sequence = 0
for record in self.replay():
last_sequence = record.sequence
self.next_sequence = last_sequence + 1
@staticmethod
def _write_all(fd: int, data: bytes):
view = memoryview(data)
while view:
written = os.write(fd, view)
if written <= 0:
raise OSError("short write to WAL")
view = view[written:]
@staticmethod
def _read_exact(fd: int, size: int) -> bytes:
chunks = []
while size:
chunk = os.read(fd, size)
if not chunk:
break
chunks.append(chunk)
size -= len(chunk)
return b"".join(chunks)
def append_many(self, items: list[tuple[int, bytes]]) -> int:
if not items:
return self.next_sequence - 1
if not self.healthy:
raise RuntimeError("WAL is in failed state")
frames = []
sequence = self.next_sequence
for record_type, payload in items:
if not payload:
raise ValueError("empty payload")
if len(payload) > MAX_PAYLOAD:
raise ValueError("payload too large")
crc = zlib.crc32(payload) & 0xFFFFFFFF
frames.append(HEADER.pack(
MAGIC,
1,
record_type,
sequence,
len(payload),
))
frames.append(payload)
frames.append(struct.pack("!I", crc))
sequence += 1
flock(self.fd, LOCK_EX)
try:
self._write_all(self.fd, b"".join(frames))
os.fsync(self.fd)
except BaseException:
self.healthy = False
raise
else:
self.next_sequence = sequence
return sequence - 1
finally:
flock(self.fd, LOCK_UN)
def replay(self):
os.lseek(self.fd, 0, os.SEEK_SET)
flock(self.fd, LOCK_SH)
expected_sequence = None
try:
while True:
header = self._read_exact(self.fd, HEADER.size)
if not header:
break
if len(header) < HEADER.size:
self._report_torn_tail(len(header), 0)
break
magic, version, record_type, sequence, length = HEADER.unpack(header)
if magic != MAGIC:
raise ValueError("WAL magic mismatch: possible mid-file corruption")
if version != 1:
raise ValueError(f"unsupported WAL version: {version}")
if length > MAX_PAYLOAD:
raise ValueError(f"invalid WAL payload length: {length}")
if expected_sequence is not None and sequence != expected_sequence:
raise ValueError(f"WAL sequence gap: got {sequence}, expect {expected_sequence}")
payload = self._read_exact(self.fd, length)
crc_bytes = self._read_exact(self.fd, 4)
if len(payload) != length or len(crc_bytes) != 4:
self._report_torn_tail(len(header), len(payload))
break
(crc,) = struct.unpack("!I", crc_bytes)
if (zlib.crc32(payload) & 0xFFFFFFFF) != crc:
raise ValueError(f"WAL CRC mismatch at sequence {sequence}")
record = WalRecord(version, record_type, sequence, payload)
yield record
expected_sequence = sequence + 1
finally:
flock(self.fd, LOCK_UN)
@staticmethod
def _report_torn_tail(header_size: int, payload_size: int):
# 生产实现应记录 offset、header_size、payload_size,并进入只读告警状态。
print(f"torn WAL tail: header={header_size}, payload={payload_size}")
def close(self):
if self.fd >= 0:
os.close(self.fd)
self.fd = -1
这个实现仍然是教学版。生产系统还要考虑日志轮转、只读错误处理、恢复时截断半条尾部、schema 演进、跨进程写入协议和磁盘满行为。
WAL 恢复时必须区分三类问题
| 问题 | 现象 | 处理 |
|---|---|---|
| 半条尾部记录 | 最后一帧 header 或 payload 不完整 | 停在最后一条完整记录,记录告警 |
| CRC 错误 | 长度合法但校验失败 | 不允许静默跳过,应进入诊断模式 |
| 序列号跳变 | 中间丢帧或多写入者交错 | 停止恢复,检查并发与存储 |
特别是中段 CRC 错误,不能简单"从下一条 magic 继续扫描"。这可能掩盖日志被覆盖、偏移错位或存储损坏。
五、Group Commit:合并 fsync,不降低确认语义
下面是一个 asyncio 版 Group Commit 框架。请求方把 payload 放入有界队列,后台提交者攒批后调用 append_many(),一次 fsync 成功后再唤醒所有请求。
python
from __future__ import annotations
import asyncio
class GroupCommitPipeline:
def __init__(
self,
wal,
*,
max_batch_records: int = 256,
max_batch_bytes: int = 1024 * 1024,
max_wait: float = 0.01,
max_queue: int = 10000,
):
self.wal = wal
self.max_batch_records = max_batch_records
self.max_batch_bytes = max_batch_bytes
self.max_wait = max_wait
self.queue: asyncio.Queue[tuple[int, bytes, asyncio.Future]] = asyncio.Queue(
maxsize=max_queue
)
self.worker: asyncio.Task | None = None
self.closing = False
def start(self):
if self.worker is None:
self.worker = asyncio.create_task(self._run(), name="wal-group-commit")
async def stop(self):
if self.worker is None:
return
self.closing = True
await self.queue.join()
self.worker.cancel()
try:
await self.worker
except asyncio.CancelledError:
pass
self.worker = None
async def commit(self, record_type: int, payload: bytes) -> int:
if self.closing:
raise RuntimeError("group commit pipeline is closing")
loop = asyncio.get_running_loop()
future = loop.create_future()
await self.queue.put((record_type, payload, future))
return await future
async def _take_batch(self):
first = await self.queue.get()
batch = [first]
batch_bytes = len(first[1])
while len(batch) < self.max_batch_records:
remaining = self.max_batch_bytes - batch_bytes
if remaining <= 0:
break
try:
item = await asyncio.wait_for(self.queue.get(), timeout=self.max_wait)
except asyncio.TimeoutError:
break
_, payload, _ = item
if len(payload) > remaining and batch:
self.queue.put_nowait(item)
break
batch.append(item)
batch_bytes += len(payload)
return batch
async def _run(self):
while True:
batch = await self._take_batch()
try:
last_sequence = self.wal.append_many(
[(record_type, payload) for record_type, payload, _ in batch]
)
for *_, future in batch:
if not future.done():
future.set_result(last_sequence)
except BaseException as exc:
for *_, future in batch:
if not future.done():
future.set_exception(exc)
# 连续失败应触发熔断,避免业务继续填满内存队列。
await asyncio.sleep(0.2)
finally:
for _ in batch:
self.queue.task_done()
关键点:
max_queue必须有界,否则磁盘变慢时上游会耗尽内存;max_wait是攒批上限,不是允许丢数据的窗口;- 提交成功前不能给业务返回成功;
- 批大小同时限制记录数和字节数,避免超大帧阻塞;
- WAL 写入失败时,要区分可重试 I/O 错误和不可重试的存储故障;
- 优雅停机时要 drain 队列,再关闭 WAL。
如果业务流量有毛刺,还要记录:
- 请求进入队列到提交开始的等待时间;
- WAL 写入和
fsync耗时; - 队列长度和丢弃率;
- 每秒提交记录数和字节数。
六、Checkpoint:控制 WAL 长度和恢复时间
WAL 不能无限增长。Checkpoint 的作用是把某个序列号之前的状态固化成快照,让恢复时只需回放其后的增量。
1. Checkpoint 不是简单 truncate
一个危险流程是:
text
写快照文件
成功后立刻清空 WAL
如果快照尚未真正持久化、目录项尚未持久化,或者清空 WAL 后进程崩溃,可能出现快照和日志都无法恢复的窗口。
更稳妥的顺序是:
text
暂停或锁定状态变更
↓
复制一致状态,并记录 last_sequence
↓
快照写入临时文件 + fsync
↓
rename 快照 + fsync 目录
↓
确认快照可加载
↓
轮转或截断 sequence <= last_sequence 的 WAL
↓
记录 checkpoint 元数据
实际数据库通常还有更精细的两阶段和崩溃恢复机制,但原则相同:新快照未确认可恢复前,不能破坏旧快照和旧日志。
2. 恢复时按 sequence 过滤
如果 checkpoint 后 WAL 没有及时删除,重启时可能既读到旧快照,又回放快照之前的日志。恢复逻辑必须记住快照的 last_sequence,只应用 sequence > last_sequence 的记录。
python
def should_apply(snapshot_last_sequence: int, record_sequence: int) -> bool:
return record_sequence > snapshot_last_sequence
3. 保留上一份快照
推荐至少保留两代:
text
snapshot-000123.json 当前快照
snapshot-000120.json 上一份快照
wal-000124.log 当前日志
wal-000121.log checkpoint 前待确认日志
checkpoint.json 指向当前快照和日志的清单
清理旧文件时,要从 checkpoint 元数据出发,不要只按文件时间删。现场存储异常时,多保留一代往往比省几 MB 空间更有价值。
4. 应用必须幂等
WAL 回放和断网补传都可能重复执行。记录设计要支持幂等:
- 每条业务事件有唯一 ID;
- 使用
(source, event_id)或(device_id, sequence)去重; - 状态更新使用版本号或条件写;
- 副作用命令要区分"重放日志"和"重新下发控制命令";
| 副作用类型 | 回放建议 |
|---|---|
| 更新内存状态 | 可按日志重放 |
| 写本地数据库 | 使用唯一键或事务幂等 |
| 上报云端 | 云端按 event_id 去重 |
| 下发设备命令 | 通常不能盲目重放,需要业务确认 |
七、原子写入:防止半份状态文件
下面是带文件和目录 fsync 的原子写示例:
python
import os
import tempfile
def atomic_write(path: str, data: bytes):
directory = os.path.dirname(path) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
prefix=".tmp-",
dir=directory,
)
try:
view = memoryview(data)
while view:
written = os.write(fd, view)
if written <= 0:
raise OSError("short write")
view = view[written:]
os.fsync(fd)
os.close(fd)
fd = -1
os.replace(tmp_path, path)
dir_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
finally:
if fd >= 0:
os.close(fd)
try:
os.unlink(tmp_path)
except FileNotFoundError:
pass
工程上还要注意:
- 权限、owner、SELinux 标签是否需要在替换后恢复;
- 并发写入者是否需要应用级锁;
- 临时文件必须和目标文件在同一个文件系统;
- 对大状态文件应优先使用快照 + WAL,而不是频繁整文件重写;
- 配置文件中不要保存明文密钥。
八、文件系统与存储介质选择
| 文件系统 | 特点 | 工程注意点 |
|---|---|---|
| ext4 | 工业网关常用,日志和工具链成熟 | 仍需显式 fsync,建议评估 data=ordered 与挂载参数 |
| XFS | 高吞吐、大文件表现好 | 更适合大容量写入和归档场景 |
| f2fs | 面向闪存设计 | 需验证内核版本、介质和厂商支持 |
| btrfs | 快照、校验、压缩 | 数据库 / WAL 文件要谨慎处理 CoW 与碎片,确认厂商支持 |
| overlayfs | 容器常见 | 容器内路径不一定等于真正持久卷,要挂载独立 volume |
工业网关还要确认:
- eMMC / SD / NVMe / SATA 的写入寿命和容量规划;
- 小块随机写造成的写放大;
- 磨损均衡和保留空间;
- 掉电后文件系统恢复时间;
fsck是否适合现场自动执行;- 是否存在只读保护机制;
- 存储满时系统日志会不会挤掉业务数据。
挂载选项里的 noatime 可以减少不必要写入,但 commit= 参数不能替代业务 fsync。文件系统 journal 保证元数据一致性,不等价于每个业务文件内容都已按期望时机持久化。
九、优先评估成熟存储引擎
自研 WAL 适合作为教学或非常小的状态机。多数项目应优先评估 SQLite、嵌入式时序库、嵌入式 KV 存储或远端数据库,把精力放在业务状态和幂等设计上。
以 SQLite 为例:
sql
PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA busy_timeout=5000;
journal_mode=WAL 与本文讨论的通用 WAL 思想相似,但实现细节由 SQLite 管理。synchronous=FULL 提供更强的掉电持久性;NORMAL 在 WAL 模式下通常能保证一致性,但可能牺牲部分提交后的掉电持久性,关键数据不要只按默认值配置。
使用嵌入式数据库时,还要关注:
- WAL 文件增长和 checkpoint 频率;
- 事务大小和锁竞争;
- flash 写放大;
- 数据库文件所在 volume 是否真的持久化;
- 备份时使用在线快照 API,而不是直接复制正在写的文件;
- 升级时的 schema migration 和回滚策略。
十、掉电和故障测试不能省
kill -9 只验证进程崩溃,不能验证掉电。持久化功能至少要覆盖以下测试。
| 测试 | 目的 |
|---|---|
| 进程 kill | 验证 page cache 之外的状态可恢复 |
| 磁盘写满 | 验证背压、告警和不会覆盖关键 WAL |
| 半条 WAL 尾部 | 验证恢复停在最后完整记录 |
| WAL 中段 CRC 错误 | 验证进入告警,而不是静默跳过 |
| 断电重启 | 验证真实介质持久化和文件系统恢复 |
| 短时反复掉电 | 验证电源保持、写缓存和存储栈 |
| 断网 24 小时 | 验证缓存上限、水位和补传幂等 |
| 升级后回放旧 WAL | 验证 schema 版本兼容 |
| 存储只读 | 验证网关进入安全模式并告警 |
上线前建议把掉电测试自动化成固定脚本,并在真实网关硬件上执行。只在上位机模拟器里测试,无法暴露电源、eMMC、驱动和文件系统的组合问题。
十一、监控指标
| 指标 | 含义 | 告警建议 |
|---|---|---|
wal_queue_depth |
等待提交的记录数 | 持续增长说明磁盘变慢或上游突发 |
wal_queue_wait_ms |
请求进入队列到提交耗时 | 关注 p95 / p99 |
wal_fsync_ms |
单次 WAL 提交耗时 | 与设备基线比较 |
wal_bytes_written |
WAL 写入速率 | 用于容量和寿命评估 |
wal_size_bytes |
当前日志大小 | 结合 checkpoint 水位 |
checkpoint_duration_ms |
快照耗时 | 过长说明状态过大或存储抖动 |
checkpoint_last_age |
最近 checkpoint 距今时间 | 超过阈值告警 |
disk_free_bytes |
剩余空间 | 分层告警,避免写满 |
disk_readonly |
文件系统是否只读 | 立即告警 |
wal_replay_records |
启动回放记录数 | 反映恢复时间和 checkpoint 效果 |
data_loss_window_seconds |
设计 RPO | 超过业务阈值告警 |
监控本身也要持久化边界清楚。如果监控日志和业务数据共享同一块小 eMMC,需要设置轮转和保留策略,否则观测系统可能先把存储写满。
十二、常见坑
坑 1:把 write() 当落盘
write() 成功只说明数据进入内核,不保证掉电后可读。需要落盘语义时必须 fsync 或使用数据库事务。
坑 2:WAL 没有 CRC 和序列号
掉电可能产生半条记录,存储也可能损坏。没有校验的 replay 会把脏数据当成业务状态。
坑 3:所有异常都当可重试
存储只读、权限错误、schema 不兼容、payload 过大,通常不会因为重试而变好。连续失败应熔断并进入安全模式。
坑 4:队列无上限
磁盘一旦变慢,无界队列会耗尽内存。必须有界,并在高水位触发背压或降采样。
坑 5:Checkpoint 后直接清 WAL
新快照未确认持久化前,不能删除旧 WAL。恢复时也要按 last_sequence 过滤,避免重复应用。
坑 6:忽视目录 fsync
文件内容正确,但 rename / unlink 的目录项没有持久化,掉电后仍可能恢复到旧状态。
坑 7:在容器里写镜像层
容器内路径可能是 overlayfs 镜像层,容器重建后数据消失。网关状态必须挂载明确的数据卷。
坑 8:只测 kill,不测掉电
kill -9 不代表掉电。真实电源、写缓存、eMMC 和文件系统行为必须上机验证。
十三、落地清单
- 给每类数据标注 RPO、保留时长和幂等键;
- 明确确认成功必须发生在
fsync之后,还是允许批量提交; - WAL 记录包含版本、类型、序列号、长度和 CRC;
- 恢复时处理半条尾部、中段损坏和序列跳变;
- Group Commit 队列有界,并输出排队、提交和失败指标;
- Checkpoint 记录
last_sequence,保留上一代快照; - 原子写入同时 fsync 文件和目录;
- 确认存储介质、写缓存、电源保持时间和挂载路径;
- 磁盘写满时优先保护关键日志和状态;
- 建立掉电、只读、断网、升级回放和 CRC 错误的自动化测试。
在 Zenova EdgeOS 的边缘数据链路中,持久化水位、WAL 增长、fsync 延迟和 checkpoint 结果会被纳入统一的运行时观测与告警体系,减少网关在断电、断网或存储老化时出现不可解释的数据缺口。
TL;DR
持久化的第一步是定义故障边界和 RPO。write()、flush()、fsync()、rename 和目录 fsync 的语义不同,不能混用。WAL 的核心是"先持久化日志,再改主状态",并且要有 CRC、序列号、版本和可处理的半条尾部。Group Commit 可以合并 fsync,但不能让未落盘数据提前返回成功。Checkpoint 通过 last_sequence 控制 WAL 长度和恢复时间,必须保证新快照确认可恢复后再清理旧日志。工业网关还要同时评估 eMMC 寿命、电源掉电、文件系统、容器数据卷、磁盘写满和真实掉电测试。