原子写入需先写同分区临时文件再rename,因os.rename跨挂载点会失败;临时文件须用os.createtemp创建并defer清理,避免竞态和权限问题;json序列化时未导出字段无效,指针零值序列化为null,需omitempty控制省略。

为什么不能直接 json.Marshal 后 os.WriteFile
直接序列化后写入会留下中间态:文件可能被其他进程读到半截内容,或写入中途崩溃导致损坏。原子写入的核心是「先写临时文件,再 os.Rename」——这个系统调用在大多数 POSIX 文件系统上是原子的,但要注意:它只在同分区下有效,跨挂载点会失败并报 EXDEV 错误。
os.Rename 失败时如何 fallback 到同分区临时文件
临时文件必须和目标路径在同一挂载点,否则 os.Rename 会失败。稳妥做法是用 filepath.Dir(dst) 作为临时文件父目录,并确保使用 os.O_CREATE | os.O_EXCL | os.O_WRONLY 创建临时文件(防竞态):
tmpFile, err := os.OpenFile(filepath.Join(filepath.Dir(dst), "."+filepath.Base(dst)+".tmp"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
return err // 可能是文件已存在(冲突)或权限问题
}
- 不要用
ioutil.TempFile——它默认在/tmp,大概率跨分区 - 务必设
0600权限,避免临时文件内容被未授权读取 - 写完立即
tmpFile.Close(),再调os.Rename,否则 Windows 下会失败
结构体字段导出与 JSON 标签的常见陷阱
未导出字段(小写开头)不会被 json.Marshal 序列化,即使加了 json: tag 也无效;而导出字段若含 json:"-" 会被跳过。更隐蔽的问题是嵌套结构体中的指针字段:
type Config struct {
Timeout int `json:"timeout"`
LogPath *string `json:"log_path"` // 如果 LogPath == nil,序列化后是 null,不是省略
}
- 想让零值字段不出现,用
json:",omitempty",但注意它对指针、切片、map 的零值(nil)才生效 - 时间字段建议用
time.Time+json:"ts,string"避免 Unix 时间戳歧义 - 如果结构体含
sync.Mutex等不可序列化字段,json.Marshal会 panic,得提前清理或用中间 DTO 结构体
完整函数模板与关键校验点
一个生产可用的原子写入函数至少要覆盖:序列化错误、写临时文件错误、重命名错误、清理残留临时文件。以下是最简健壮骨架:
func WriteJSONAtomically(dst string, v interface{}) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
dir := filepath.Dir(dst)
tmp, err := os.CreateTemp(dir, "."+filepath.Base(dst)+".*.tmp")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // 防止 panic 或 early return 后残留
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return err
}
return nil
}
-
os.CreateTemp比手拼路径更安全(自动处理并发创建冲突) -
defer os.Remove放在CreateTemp后立刻执行,确保任何失败路径都清理临时文件 - 没调
Chmod——因为CreateTemp默认是0600,且目标文件权限应由后续os.Chmod(dst, ...)显式控制
真正麻烦的是并发写同一文件:Go 本身不提供文件级写锁,得靠业务层约定或外部工具(如 flock),这点常被忽略。











