JSON原子写入:用tempfile+os.replace防止数据损坏
量化交易系统里,最怕的不是策略亏钱,而是数据文件损坏。策略亏钱还能复盘,数据文件坏了,整个回测和实盘环境直接瘫痪。今天聊一个看似简单但极其关键的问题:如何安全地写入JSON文件。
数据损坏的典型场景
先看一个最常见的写法:
import json
def save_data(data, filepath):
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
这段代码在99%的情况下没问题。但剩下的1%会要命:
- 程序崩溃:
json.dump执行到一半,进程被kill或抛出未捕获异常,文件只写入了一半 - 断电:数据还在操作系统的page cache里,没来得及落盘,断电后文件变成空文件或乱码
- 磁盘空间不足:写了一半,磁盘满了,文件截断
- 并发写入:两个进程同时写同一个文件,互相覆盖
结果就是:你辛辛苦苦跑了几天的回测数据,或者实盘策略的状态文件,一夜之间变成一堆乱码。而且这种损坏是静默的——程序下次启动读取JSON时,json.load 直接抛 JSONDecodeError,你才知道数据没了。
原子操作原理:要么成功,要么保持原样
解决思路很简单:先写临时文件,再原子替换。
核心是 os.replace()(Python 3.3+)。它在POSIX系统上对应 rename() 系统调用,在Windows上对应 MoveFileEx。这个操作是原子的——操作系统保证要么替换成功,要么原文件不变,不存在中间状态。
流程如下:
- 把数据写入同一个目录下的临时文件
- 调用
os.replace(tmp_path, target_path),一次性替换目标文件 - 如果写入失败,临时文件还在,目标文件完好无损
为什么临时文件必须放在同一个目录?因为 rename 在同一个文件系统内是原子操作,跨文件系统(比如 /tmp 和 /data 不在一个挂载点)会退化成复制+删除,失去原子性。
代码实现:一个健壮的原子写入函数
直接上代码:
import json
import os
import tempfile
from pathlib import Path
from typing import Any, Union
def atomic_write_json(
data: Any,
filepath: Union[str, Path],
*,
encoding: str = 'utf-8',
indent: int = 2,
ensure_ascii: bool = False,
fsync: bool = True,
) -> None:
"""
原子写入JSON文件。
Args:
data: 要写入的数据,必须是JSON可序列化的
filepath: 目标文件路径
encoding: 文件编码
indent: JSON缩进
ensure_ascii: 是否转义非ASCII字符
fsync: 是否调用fsync强制落盘(更安全但更慢)
"""
filepath = Path(filepath)
# 确保目标目录存在
filepath.parent.mkdir(parents=True, exist_ok=True)
# 在目标文件同目录下创建临时文件
# delete=False: 不自动删除,我们需要手动控制
fd, tmp_path = tempfile.mkstemp(
dir=str(filepath.parent),
prefix=f'.{filepath.name}.',
suffix='.tmp'
)
try:
# 将JSON写入临时文件
with os.fdopen(fd, 'w', encoding=encoding) as f:
json.dump(data, f, ensure_ascii=ensure_ascii, indent=indent)
f.flush()
# 强制将数据从用户态缓冲区刷到内核
if fsync:
os.fsync(f.fileno())
# 原子替换目标文件
os.replace(tmp_path, filepath)
# 可选:fsync目录,确保目录项也落盘
# 对极端数据安全场景(如数据库WAL)有必要
if fsync:
dir_fd = os.open(str(filepath.parent), os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except Exception:
# 任何异常,清理临时文件
try:
os.unlink(tmp_path)
except OSError:
pass
raise
关键点解析
tempfile.mkstemp 的 dir 参数:必须指定为目标文件所在目录。这样 os.replace 才能保证原子性。临时文件名用了 .{filename}.xxx.tmp 格式,隐藏文件,避免被其他工具误扫。
os.fdopen(fd, 'w'):mkstemp 返回的是文件描述符,用 os.fdopen 包装成文件对象,方便用 json.dump。注意用完必须关闭,with 语句会处理。
f.flush() + os.fsync():flush() 把Python缓冲区数据推到操作系统,fsync() 强制操作系统把数据写入磁盘。如果不开 fsync,断电时数据可能还在page cache里,文件虽然替换了,但内容是旧的。量化交易场景,数据就是钱,建议默认开启。
异常处理:写入过程中任何异常,临时文件都会被删除,目标文件保持原样。os.replace 本身几乎不会失败,唯一的例外是权限问题或目标路径是目录。
目录fsync:这是很多人忽略的细节。os.replace 成功只代表数据写入了,但目录项(文件名到inode的映射)可能还没落盘。极端情况下(系统崩溃),文件可能"消失"。对普通应用没必要,但对关键交易数据,值得加上。
读取时的防御性处理
原子写入解决了写入端的问题,但读取端也要有防御意识。即使写入是原子的,读取时也可能遇到文件被外部程序修改、磁盘坏道等问题。
import json
from pathlib import Path
from typing import Any, Optional
def read_json_safe(
filepath: Path,
default: Optional[Any] = None,
*,
encoding: str = 'utf-8'
) -> Any:
"""
安全读取JSON文件,失败时返回默认值。
注意:返回默认值可能导致静默数据丢失。
生产环境建议记录日志并告警。
"""
filepath = Path(filepath)
if not filepath.exists():
return default
try:
with open(filepath, 'r', encoding=encoding) as f:
return json.load(f)
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
# 这里应该打日志,而不是静默处理
# logger.error(f"Failed to read {filepath}: {e}")
return default
还有一个进阶技巧:写入前备份。对特别重要的文件,可以在原子替换前把原文件复制一份带时间戳的备份:
import shutil
from datetime import datetime
def write_json_with_backup(data: Any, filepath: Path, backup_count: int = 5):
"""写入JSON,并保留最近N份备份。"""
filepath = Path(filepath)
# 如果原文件存在,先备份
if filepath.exists():
backup_dir = filepath.parent / '.backups'
backup_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = backup_dir / f'{filepath.name}.{timestamp}.bak'
shutil.copy2(filepath, backup_path)
# 清理旧备份,只保留最近backup_count份
backups = sorted(backup_dir.glob(f'{filepath.name}.*.bak'))
for old_backup in backups[:-backup_count]:
old_backup.unlink()
atomic_write_json(data, filepath)
性能考量
原子写入比直接写入慢,主要开销在 fsync。如果数据文件很大(几MB以上),每次全量写入都会产生磁盘I/O。优化方向:
- 分批写入:如果数据是append-only的日志,不要用JSON文件,改用SQLite或专门的日志格式
- 降低fsync频率:对非关键数据,
fsync=False可以大幅提升性能 - 内存缓存:高频更新的状态文件,先在内存里聚合,定期落盘
一个实用的取舍:策略状态文件(比如持仓、订单状态)每次更新都原子写入,因为数据量小但极其关键;市场数据缓存(比如日线行情)可以批量写入,丢一点还能重新下载。
实战:交易状态文件的原子保存
拿一个简单的实盘策略状态管理举例:
import json
import time
from pathlib import Path
class StrategyState:
"""策略状态管理器,保证每次保存都是原子的。"""
def __init__(self, state_file: Path):
self.state_file = Path(state_file)
self.state = self._load()
def _load(self) -> dict:
"""加载状态,文件不存在时返回空状态。"""
if self.state_file.exists():
with open(self.state_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {
'positions': {},
'orders': {},
'last_sync': None,
'version': 1
}
def update_position(self, symbol: str, qty: float, price: float):
"""更新持仓,立即持久化。"""
self.state['positions'][symbol] = {
'qty': qty,
'price': price,
'updated_at': time.time()
}
# 每次更新都原子保存
atomic_write_json(self.state, self.state_file)
def save(self):
"""手动保存。"""
atomic_write_json(self.state, self.state_file)
这个类保证:任何一次 update_position 要么完整写入,要么保持上一次的状态。即使程序在写入过程中崩溃,重启后加载的也是最近一次完整保存的状态,不会出现半截JSON。
总结
os.replace是原子操作,配合临时文件可以实现安全的JSON写入- 临时文件必须和目标文件在同一个目录,否则失去原子性
fsync决定数据是否真正落盘,关键数据建议开启- 读取端也要防御,JSON解析失败时要有降级方案
- 对高频更新的状态文件,保持"写入即原子"的习惯
这套方案不局限于JSON,任何文本文件、配置文件、模型参数文件都可以用同样的模式。把它封装成一个通用工具函数,放进你的工具库里,以后写文件就再也不用担心数据损坏了。
更多内容请关注本站,后续会分享更多Python量化交易和自动化实战技巧。