
1. 被覆盖的文件教会我的事append到底在解决什么1.1 一次日志丢失事故背后的根因先说说我自己的经历吧。几年前我在做一个数据采集脚本每天的采集量在几十万条左右脚本跑在服务器上通过 crontab 定时执行。有一天我登录服务器想看历史日志发现文件里只剩了最后一天的记录前面一周的数据全部没了。当时脑子嗡了一下。排查了半天最后发现罪魁祸首就是这一行with open(collect.log, w) as f: f.write(content)这个w模式每次打开文件都会先清空原文件内容再从头写入。脚本每天凌晨运行一次等于每天都在格式化前一天的数据。而文件追加写入的需求——保留已有内容、只在末尾继续写——恰好是w模式的天敌。这个教训特别值钱。从那以后凡是涉及日志、埋点、采集记录这类历史数据不能丢的场景我第一反应就是选对打开模式而不是先想怎么写。Python 里文件追加写入的答案其实就是 open 函数的a模式但这个模式背后的 filestream 机制、缓冲策略、并发行为很多教程讲得不够透才导致大家在真正用的时候踩坑。1.2 Python文件打开模式横向对比Python 内置的 open 函数有几种常用模式我相信大部分读者都见过但未必仔细对比过。我把它们按写入行为分成三类。模式文件不存在时文件存在时文件指针位置写入结果w创建清空原内容开头只保留本次写入a创建保留原内容末尾在末尾追加历史不丢r报错保留原内容开头从开头覆盖写w创建清空原内容开头可读可写但会清空a创建保留原内容末尾可读可写写永远在末尾x创建报错文件已存在开头独占创建防覆盖重点说a和a的区别a只能写不能读a可以写也可以读但读的时候需要先把文件指针挪到指定位置因为 append 模式下指针永远被拉到文件末尾。这个细节很多人不知道——用a打开后想直接从头读文件结果是空白因为指针在末尾。文件追加写入append在最底层对应的是操作系统层面的O_APPEND标志。Linux 内核在处理带O_APPEND的文件描述符时每次 write 系统调用都会把偏移量自动调整到文件末尾再执行写入。这意味着什么意味着即使你的代码里没有显式地seek到末尾内核也会保证新数据追加在文件尾部。这个机制接下来要展开讲因为它是理解并发追加安全性的关键。2. append模式的工作机制文件指针末尾、缓冲与系统调用2.1 filestream在append时的实际执行链路很多人写代码时会把文件对象file object和文件流filestream混为一谈实际上它们是两个层面的东西。Python 的open()返回的是一个文件对象它内部封装了一个操作系统级别的文件描述符file descriptor。写入操作的过程大致是你调用f.write(some text)Python 把字符串编码成字节序列字节序列进入缓冲层buffer可能先在内存里攒着缓冲区满或主动 flush 时通过 write 系统调用传给内核内核根据文件描述符上的打开标志比如O_APPEND把偏移量定位到文件末尾数据写入文件元数据更新这里有个关键点a模式触发的是内核级别的追加语义而不是 Python 层面挪到末尾再写这种模拟效果。所以即使两个进程同时以a模式打开同一个文件每个进程内部的文件偏移量可能不同但内核在处理每次 write 时都会强制把偏移量指向当时文件的末尾。这让单次 write 的追加具备原子性不会出现两个进程互相覆盖对方数据的问题。我用一个生活类比解释w模式像是把一张白纸拿过来先用橡皮把旧字全擦掉再从顶行开始写字a模式则像是在本子最后一行底下继续写根本不管前面有多少页内容。橡皮擦和底下续写的区别就是w和a的区别。2.2 缓冲区落盘为什么写完没立刻看到数据初学者遇到最多的问题之一是明明代码执行了f.write(...)为什么用文本编辑器打开文件却看不到内容原因就在缓冲区策略。Python 的 open 函数默认对文本文件使用系统默认缓冲策略通常是 8KB不同平台可能有差异。也就是说写入的数据会先囤积在内存缓冲区里只有缓冲区满了、文件关闭、或者显式调用 flush 的时候数据才会真正落盘。我之前跑过一个脚本用a模式做日志记录程序运行到一半崩溃了最后发现崩溃前的几百条日志全丢了。因为异常导致文件没有正常 close数据还在缓冲区里没写进去。处理方案很简单日志类的高频写入场景牺牲一点性能换可靠性设置buffering1行缓冲或者写入后显式调用flush()。行缓冲的意思是每写入一行就刷新一次。这在日志场景下是最稳妥的策略。with open(app.log, a, encodingutf-8, buffering1) as log: log.write([INFO] 服务启动\n) # 这行写完数据会立即落盘如果你对实时性要求更高还有更狠的招flush()之后再加os.fsync(f.fileno())强制把内核页缓存里的数据也刷到物理磁盘上。这个操作比较重服务器断电时能救数据但高频执行会严重影响性能只建议在关键节点用比如程序启动、结束、异常捕获时。3. 三个可直接复用的实战场景代码3.1 日志记录器时间戳加行号的追加写入日志是最典型的文件追加写入场景。很多团队在生产环境不用现成的 logging 库而是自己写一个轻量级的日志函数原因无外乎是不想引入复杂度、格式需要严格自定义、或者就几十行代码的事。我自己常用的版本是这样import time import traceback def append_log(message: str, levelINFO, logfileservice.log): ts time.strftime(%Y-%m-%d %H:%M:%S) line f[{ts}] [{level}] {message}\n with open(logfile, a, encodingutf-8) as f: f.write(line) # 用法 append_log(用户登录成功, levelINFO) append_log(数据库连接失败, levelERROR)这个函数虽然简单但有几处细节值得注意第一每次都重新 open 文件天然解决了文件被删后新建、句柄过期的问题。有些程序喜欢常驻文件句柄结果运维做日志切割重命名旧文件、新建同名文件后程序还在往老的 inode 里写日志全写到被切割走的历史文件里了。每次 open 反而躲开了这个问题。第二我在 write 的内容末尾手动加了\n而不是依赖 print 的 end 参数或 logging 的默认行为。原因后面会讲手动控制换行在追加模式里更可靠尤其是跨平台场景。第三encode 明确指定为utf-8。Python 在 Windows 上默认使用系统编码一般是 gbk如果不显式指定日志文件可能以混合编码方式写入之后用工具解析时会乱。这个坑很隐蔽我后面还会专门讲。3.2 数据采集断点续写程序崩溃后不丢已有数据爬虫和数据处理任务天生适合文件追加写入因为这类任务往往要跑很久分批次产出数据。如果用w模式每次重新运行时历史数据都会被清空如果用列表在内存里攒着最后一次性写文件程序中途崩溃就等于全没了。断点续写的核心思路是每处理完一条或一批数据立即追加到文件。这样不管程序什么时候死掉已经处理完的数据都在磁盘上。import json import time HEADER [timestamp, user_id, action] def init_file(filepathrecords.jsonl): 首次运行时创建文件并写入表头 import os if not os.path.exists(filepath): with open(filepath, w, encodingutf-8) as f: f.write(\t.join(HEADER) \n) def append_record(record: dict, filepathrecords.jsonl): line json.dumps(record, ensure_asciiFalse) \n with open(filepath, a, encodingutf-8) as f: f.write(line) # 模拟采集 init_file() for i in range(1000): rec { timestamp: int(time.time()), user_id: fu_{i}, action: click } append_record(rec) if i % 100 0: print(f已写入 {i} 条)这里我选择了 JSON Lines 格式每行一个 JSON 对象而不是 CSV。原因是字段结构变化时JSON Lines 的容错性更好——某一行坏了跳过那一行就行不会像 CSV 那样因为一个字段的逗号导致整行解析错位。日志文件用 JSON Lines 还有一个额外好处就是可以直接接入后续的日志分析管道几乎零转换成本。这个方案的弱点也有每处理一条数据就 open 一次文件性能开销比较大。如果把批量放大到千条级别建议每攒满 N 条再批量追加一次。实测下来攒 100 条批量写一次性能可以提升一个数量级而数据的丢失窗口只有最后没来得及落盘的那 100 条完全在可控范围内。3.3 多线程共享同一个文件句柄的正确姿势多线程场景下做文件追加核心问题有两个一个是 Python 的 GIL 会不会保护文件写入另一个是多个线程各自 open 还是共享一个句柄。先说结论不同线程各自 open 同一个文件用a模式追加在 Linux 下基本安全因为O_APPEND保证单次 write 的原子性但在 Windows 下如果没有用文件锁保护可能出现写入交错。而共享同一个文件句柄时多个线程的 write 调用在 Python 层面也不一定是原子的稳妥的做法是加线程锁。我生产环境里用过的一种模式是共享句柄 外部锁import threading class ThreadSafeAppender: def __init__(self, filepath): self.filepath filepath self._lock threading.Lock() def append(self, content: str): with self._lock: with open(self.filepath, a, encodingutf-8) as f: f.write(content) appender ThreadSafeAppender(thread.log) def worker(thread_id): for i in range(1000): msg f[thread-{thread_id}] msg {i}\n appender.append(msg) threads [threading.Thread(targetworker, args(tid,)) for tid in range(4)] for t in threads: t.start() for t in threads: t.join()锁的存在让打开-写入-关闭这个三连操作成为一个整体。虽然这种写法在性能上有代价但换来的是一条条完整、不交错的日志内容。你要是跑一下不加锁的版本就会发现不同线程写出来的内容会在文件里互相插队比如[thread-1]写到一半被[thread-2]的内容切开了。看起来是小概率事件但数据量一上来必现。4. 追加写入翻车现场四个高频坑与排错记录4.1 编码不一致导致的中文乱码追加写入比普通写入更容易出编码问题原因在于一个文件被多次打开、多次追加如果两次写入时指定的编码不一样文件里就会混入两种编码序列解析时必定乱码。最典型的场景是第一次创建文件时用了encodingutf-8之后某个脚本用默认编码Windows 下是 gbk往里面追加内容。GBK 和 UTF-8 的中文字节序列完全不兼容轻则乱码重则读取时报UnicodeDecodeError。排查这个问题的过程也很有代表性。用户报日志文件乱码第一反应是用记事本打开看发现部分中文变成锟斤拷一类的东西——这个标志性的乱码其实是 UTF-8 字节被错误按 GBK 解码导致。这种问题要从源头控制而不是事后修复。我的规矩是任何涉及追加写入的代码打开文件时永远显式写encodingutf-8。即使你的程序只在本地跑、用户确认不会传给别人也写上。因为将来你会换机器、换系统、换同事维护代码显式指定可以省掉一整类跨环境问题的排查成本。4.2 换行符被吞的 Windows 问题换行符问题是我认为最容易被忽略的坑因为它看不见。在 Linux/macOS 上文本文件的行结束符是\n在 Windows 上是\r\n。Python 为了跨平台便利open 文本模式时默认启用 universal newlines 转换写文件时把\n转换成\r\n读文件时反过来处理。听起来很贴心但遇到追加写入就可能出事如果一个文件第一段内容由 Python 在 Windows 上追加写入了\r\n第二段内容由另一个脚本没经过转换处理追加了纯\n那么这个文件就出现了两种行结束符混用的情况。后续再读取时某些行首尾会多出半个字符的残留程序按行解析时就会出现莫名的空行或字段错位。我的经验是对于程序生成、程序消费的日志/数据文件统一用a模式打开配合newline\n参数让写入行为在不同操作系统上保持一致。with open(data.csv, a, encodingutf-8, newline\n) as f: f.write(1,zhangsan,28\n)我自己用这个方法在 Windows 和 Linux 之间来回搬运数据文件再也没有因为换行符问题浪费过排查时间。4.3 循环内反复open的隐形性能杀手有一次一个同事找我优化一个脚本说写入一万条数据要十几分钟。我看了代码发现他在一个 for 循环里每次迭代都 open 一次文件而每次 open 都会触发系统调用、创建文件描述符、分配缓冲区。一万次 open/write/close 循环性能必然崩溃。修复的思路有两个方向。第一个方向如果数据是一次性批量产出直接循环内多次 write只 open 一次。# 慢版本1万次 open/close for i in range(10000): with open(result.txt, a) as f: f.write(f{i}\n) # 快版本只 open 一次 with open(result.txt, a, encodingutf-8) as f: for i in range(10000): f.write(f{i}\n)第二个方向如果数据是持续增量式的比如每秒钟来一条就不要每次 open而是常驻一个文件对象配合 flush 策略控制落盘时机或者攒一批再写。实测数据1万行文本循环内 open 的写法耗时大约是单次 open 写法的 30 倍以上。这还只是文本量不大时的差距数据量越大差距越夸张。当然之前提到的每次 open 能规避日志切割后的句柄过期是个优点但这个优点不应该通过牺牲性能来换。折中方案是程序启动时 open 一次拿到句柄运行时每隔几分钟重新 open 一次比如每次写之前判断文件是否存在被切割了就重开。兼顾了可靠性和性能。4.4 并发写入导致的行交错与数据脏读多进程或多线程同时追加时O_APPEND只保证单次 write 系统调用是原子的不保证你调用一次 f.write() 就对应一次系统调用。什么意思Python 的f.write()传的是一段字符串这段字符串可能有好几行。一次write(aaaa\nbbbb\n)在内核里可能是一次系统调用也可能是多次——取决于缓冲区、文件系统、以及 write 的数据量。如果写入过程中有其他进程也往文件末尾追加那么两个进程的数据可能交错aaaa\n写进去了另一个进程插入了xxxx\n然后你的bbbb\n再接在后面。这种问题肉眼很难发现因为文件内容看起来只是顺序变了一点。但在数据管道里一个错位的记录可能会让整批数据解析失败。解决方法是把每条记录本身做成单次 write。即每条日志、每行数据单独调用一次 write并且保证这一行数据不大到触发内部切分。如果真的需要批量写入就在内存里构造好整块内容一次 write 到底。另外一个更彻底的做法是使用文件锁fcntl.flock 或跨平台的 portalocker让多进程在写入时互斥这个放到下一节详细讲。5. 进阶把append写入打磨成生产级方案5.1 with语句与文件自动关闭的可靠性保障追加写入最怕的就是写了但没落盘和句柄没释放。用 with 语句管理文件对象Python 会在代码块结束后自动调用close()这在绝大多数情况下已经够用。但自动关闭不等于强制落盘close 会把缓冲区内容写入内核但内核不一定立刻写入磁盘。如果你做的是金融交易记录、订单快照这类不能丢的数据建议在关键节点手动调用flush()甚至os.fsync()。一个保底策略是每条记录写入后调用 flush每 N 条或者每 5 分钟调用一次 fsync。这样即使程序崩溃最多丢失最近几秒的数据而 fsync 的调用频率降低一个数量级性能损耗也可以接受。import os def durable_append(filepath, content): with open(filepath, a, encodingutf-8) as f: f.write(content) f.flush() # 把 Python 缓冲区的数据交给内核 os.fsync(f.fileno()) # 把内核页缓存的数据刷到磁盘在服务器断电、进程被 kill -9 这类极端情况下flush 能保住 Python 缓冲区里的数据fsync 能保住内核缓存里的数据。做基础设施类项目时这两个调用值得留。5.2 flush、缓冲大小与持久化策略取舍有人会问为什么不干脆每次都 fsync因为它慢。每次 fsync 都是一次磁盘同步操作固态硬盘上可能还好机械硬盘上每次都同步吞吐量直接掉一个数量级。缓冲策略本质上是在数据安全性和写入速度之间做权衡。我习惯的默认配置是系统日志buffering1行缓冲每行即落盘批量数据采集默认缓冲攒够 8KB 自动落盘配合每 100 条手动 flush 一次高价值交易数据每条 flush每 10 条 fsync缓冲大小设置为buffering0意味着无缓冲Python 的每次 write 都会直接触发系统调用性能最差。buffering1是行缓冲每写入一个\n就刷新缓冲区。bufferingNN 1是块缓冲攒到 N 字节才往内核写。块缓冲适合批量写入但实时性差行缓冲适合日志但每行都有一次额外的系统调用开销。没有银弹按场景选。5.3 文件锁与原子追加的工程实践多进程并发追加时虽然O_APPEND已经保证单次 write 不会覆盖别人但前面说了多行内容可能交错。要让多条记录组成的一组数据整体原子性地写入就要引入文件锁。Linux 下常用的文件锁接口是 fcntl.flock它是劝告锁也就是说其他进程不配合就形同虚设。所以要在所有写入方统一使用同一个锁协议。import fcntl def locked_append(filepath, content): with open(filepath, a, encodingutf-8) as f: fcntl.flock(f, fcntl.LOCK_EX) f.write(content) f.flush() fcntl.flock(f, fcntl.LOCK_UN)注意一个细节flock的锁是和文件描述符open file description关联的不是和文件名关联。所以就算日志切割后文件被重命名只要你的文件句柄没变锁依然有效且保护的是同一个打开的文件描述。在使用 with 加锁时锁会在 close 时自动释放但要小心 close 触发之前先释放锁顺序要正确。Windows 上 fcntl 不可用替代方案有两个一个是使用portalocker这个库它封装了跨平台的锁逻辑另一个是使用msvcrt.locking。我自己在需要跨平台的工程里直接选用 portalocker三行代码搞定避免为各平台维护两套锁逻辑。最后一种工程上很常见的原子追加思路不直接写目标文件而是先写一个临时文件再通过 os.rename 把它替换成目标文件。由于 rename 在 POSIX 系统上是原子操作要么看到旧内容要么看到新内容不会出现中间状态。这个方案适合每批结果整体替换的场景比单条追加更适合报表生成、快照更新这类需求。但它的语义已经不是追加了而是整文件替换严格说不属于本文讨论范围但放在工程工具箱里值得记一笔。6. 写在最后一些在项目里的实际感受文件追加写入在编程里算是基础中的基础但越是基础的东西越值得把细节抠透。我在真实项目里见过太多因为w模式误清空数据的案例也见过不少因为追加时的编码、换行、缓冲问题排查到深夜的同事。这个功能的难点从来不在怎么用而在于什么时候用哪种方式以及出了问题时从哪个角度排查。我个人在做项目时的几条默认规则分享给大家第一所有涉及历史数据的文件一律用a或a模式除非有极其明确的理由用w。第二所有 open 调用都显式指定编码和换行参数不依赖平台默认值。第三高频写入场景必须考虑缓冲和批量低频写入场景考虑持久性两者方向不同。第四多进程并发追加前先想清楚你的单次写入到底对应多少次系统调用必要时加锁。写代码时多花这几分钟后面省下来的排查时间都是成倍的。希望大家都能写出健壮的文件追加写入代码不再被日志被覆盖这种低级事故折磨。