本文共 9797 字,大约阅读时间需要 32 分钟。
程序需要更新文件。虽然大部分程序员知道在执行I/O的时候会发生不可预期的事情,但是我经常看到一些异常幼稚的代码。在本文中,我想要分享一些如何在Python代码中改善I/O可靠性的见解。
考虑下述Python代码片段。对文件中的数据进行某些操作,然后将结果保存回文件中:
with open(filename) as f: input = f.read()output = do_something(input)with open(filename, 'w') as f: f.write(output)
看起来很简单吧?可能看起来并不像乍一看这么简单。我在产品服务器中调试应用,经常会出现奇怪的行为。 |
这是我看过的失效模式的例子:
下面没有什么新的内容。本文的目的是为在系统编程方面缺少经验的Python开发者提供常见的方法和技术。我将会提供代码例子,使得开发者可以很容易的将这些方法应用到自己的代码中。 |
广义的讲,可靠性意味着在所有规定的条件下操作都能执行它所需的函数。至于文件的操作,这个函数就是创建,替换或者追加文件的内容的问题。这里可以从数据库理论上获得灵感。经典的事务模型的ACID性质作为指导来提高可靠性。
开始之前,让我们先看看我们的例子怎样和ACID4个性质扯上关系:
尽可能使用数据库系统如果我们能够获得ACID 四个性质,那么我们增加可靠性方面取得了长远发展。但是这需要很大的编码功劳。为什么重复发明轮子?大多数数据库系统已经有ACID事务了。 可靠性数据存储已经是一个已解决的问题。如果你需要可靠性存储,请使用数据库。很可能,没有几十年的功夫,你自己解决这方面的能力没有那些已经专注这方面好些年的人好。如果你不想安装一个大数据库服务器,那么你可以使用,它具有ACID事务,很小,免费的,而且它包含在Python的中。 |
文章本该在这里就结束的,但是还有一些有根有据的原因,就是不使用数据。它们通常是文件格式或者文件位置约束。这两个在数据库系统中都不好控制。理由如下:
...等等。你懂的。 如果我们自己动手实现可靠的文件更新,那么这里有一些编程技术供参考。下面我将展示四种常见的操作文件更新模式。在那之后,我会讨论采取哪些步骤在每个文件更新模式下满足ACID性质。 |
文件可以以多种方式更新,但是我认为至少有四种常见的模式。这四种模式将做为本文剩余部分的基础。
这可能是最基本的模式。在下述例子中,假设的域模型代码读数据,执行一些计算,然后以写模式重新打开存在的文件:
with open(filename, 'r') as f: model.read(f)model.process()with open(filename, 'w') as f: model.write(f)此模式的一个变种以读写模式打开文件(Python中的“加”模式),寻找到开始的位置,显式调用truncate(),重写文件内容。
with open(filename, 'a+') as f: f.seek(0) model.input(f.read()) model.compute() f.seek(0) f.truncate() f.write(model.output())
该变种的优势是只打开文件一次,始终保持文件打开。举例来说,这样可以简化加锁。 |
写-替换另外一种广泛使用的模式是将新内容写到临时文件,之后替换原始文件: with tempfile.NamedTemporaryFile( 'w', dir=os.path.dirname(filename), delete=False) as tf: tf.write(model.output()) tempname = tf.nameos.rename(tempname, filename) 该方法与截断-写方法相比对错误更具有鲁棒性。请看下面对原子性和一致性的讨论。很多应用使用该方法。 这两个模式很常见,以至于linux内核中的ext4文件系统甚至可以自动,自动修复一些可靠性缺陷。但是不要依赖这一特性:你并不是总是使用ext4,而且管理员可能会关掉这一特性。 |
第三种模式就是追加新数据到已存在的文件:
with open(filename, 'a') as f: f.write(model.output())
这个模式用来写日志文件和其它累积处理数据的任务。从技术上讲,它的显著特点就是极其简单。一个有趣的扩展应用就是常规操作中只通过追加操作更新,然后定期重新整理文件,使之更紧凑。 |
这里我们将目录做为逻辑数据存储,为每条记录创建新的唯一命名的文件:
with open(unique_filename(), 'w') as f: f.write(model.output())
该模式与附加模式一样具有累积的特点。一个巨大的优势是我们可以在文件名中放入少量元数据。举例来说,这可以用于传达处理状态的信息。spooldir模式的一个特别巧妙的实现是格式。maildirs使用附加子目录的命名方案,以可靠的、无锁的方式执行更新操作。和库为maildir操作提供了方便的封装。
如果你的文件名生成不能保证唯一的结果,甚至有可能要求文件必须实际上是新的。那么调用具有合适标志的低等级os.open():
fd = os.open(filename, os.O_WRONLY | os.O_CREAT| os.O_EXCL, 0o666)with os.fdopen(fd, 'w') as f: f.write(...)在以O_EXCL方式打开文件后,我们用os.fdopen将原始的文件描述符转化为普通的Python文件对象。
下面,我将尝试加强文件更新模式。反过来让我们看看可以做些什么来满足ACID属性。我将会尽可能保持简单,因为我们并不是要写一个完整的数据库系统。请注意本节的材料并不彻底,但是可以为你自己的实验提供一个好的起点。 原子性写-替换模式提供了原子性,因为底层的os.rename()。这意味着在任意给定时间点,进程或者看到旧的文件,或者看到新的文件。该模式对写错误具有天然的鲁棒性:如果写操作触发异常,重命名操作就不会被执行,所有就没有用损坏的新文件覆盖正确的旧文件的风险。 |
with open(logfile, 'ab') as f: for i in range(3): measure = {'timestamp': time.time(), 'value': random.random()} record = json.dumps(measure).encode() checksum = '{:8x}'.format(zlib.crc32(record)).encode() f.write(record + b' ' + checksum + b'\n')该例子代码通过每次创建随机值模拟测量。
$ cat log{"timestamp": 1373396987.258189, "value": 0.9360123151217828} 9495b87a{"timestamp": 1373396987.25825, "value": 0.40429005476999424} 149afc22{"timestamp": 1373396987.258291, "value": 0.232021160265939} d229d937
想要处理这个日志文件,我们每次读一行记录,分离校验和,与读到的记录比较。
with open(logfile, 'rb') as f: for line in f: record, checksum = line.strip().rsplit(b' ', 1) if checksum.decode() == '{:8x}'.format(zlib.crc32(record)): print('read measure: {}'.format(json.loads(record.decode()))) else: print('checksum error for record {}'.format(record))现在我们通过截断最后一行模拟被截断的写操作:
$ cat log{"timestamp": 1373396987.258189, "value": 0.9360123151217828} 9495b87a{"timestamp": 1373396987.25825, "value": 0.40429005476999424} 149afc22{"timestamp": 1373396987.258291, "value": 0.23202当读日志的时候,最后不完整的一行被拒绝:
$ read_checksummed_log.py logread measure: {'timestamp': 1373396987.258189, 'value': 0.9360123151217828}read measure: {'timestamp': 1373396987.25825, 'value': 0.40429005476999424}checksum error for record b'{"timestamp": 1373396987.258291, "value":'
添加校验和到日志记录的方法被用于大量应用,包括很多数据库系统。 |
spooldir中的单个文件也可以在每个文件中添加校验和。另外一个可能更简单的方法是借用写-替换模式:首先将文件写到一边,然后移到最终的位置。设计一个保护正在被消费者处理的文件的命名方案。在下面的例子中,所有以.tmp结尾的文件都会被读取程序忽略,因此在写操作的时候可以安全的使用。
newfile = generate_id()with open(newfile + '.tmp', 'w') as f: f.write(model.output())os.rename(newfile + '.tmp', newfile)最后, 截断-写是非原子性的。很遗憾我不能提供满足原子性的变种。在执行完截取操作后,文件是空的,还没有新内容写入。如果并发的程序现在读文件或者有异常发生,程序中止,我们既看不久的版本也看不到新的版本。
我谈论的关于原子性的大部分内容也可以应用到一致性。实际上,原子性更新是内部一致性的前提条件。外部一致性意味着同步更新几个文件。这不容易做到,锁文件可以用来确保读写访问互不干涉。考虑某目录下的文件需要互相保持一致。常用的模式是指定锁文件,用来控制对整个目录的访问。
写程序的例子:
with open(os.path.join(dirname, '.lock'), 'a+') as lockfile: fcntl.flock(lockfile, fcntl.LOCK_EX) model.update(dirname)读程序的例子:
with open(os.path.join(dirname, '.lock'), 'a+') as lockfile: fcntl.flock(lockfile, fcntl.LOCK_SH) model.readall(dirname)
该方法只有控制所有读程序才生效。因为每次只有一个写程序活动(独占锁阻塞所有共享锁),所有该方法的可扩展性有限。 |
更进一步,我们可以对整个目录应用写-替换模式。这涉及为每次更新创建新的目录,更新完成后改变符合链接。举例来说,镜像应用维护一个包含压缩包和列出了文件名、文件大小和校验和的索引文件的目录。当上流的镜像更新,仅仅隔离地对压缩包和索引文件进项原子性更新是不够的。相反,我们需要同时提供压缩包和索引文件以免校验和不匹配。为了解决这个问题,我们为每次生成维护一个子目录,然后改变符号链接激活该次生成。
mirror|-- 483| |-- a.tgz| |-- b.tgz| `-- index.json|-- 484| |-- a.tgz| |-- b.tgz| |-- c.tgz| `-- index.json`-- current -> 483
新的生成484正在被更新的过程中。当所有压缩包准备好,索引文件更新后,我们可以用一次原子调用os.symlink()来切换current符号链接。其它应用总是或者看到完全旧的或者完全新的生成。读程序需要使用os.chdir()进入current目录,很重要的是不要用完整路径名指定文件。否在当读程序打开current/index.json,然后打开current/a.tgz,但是同时符号链接已经改变时就会出现竞争条件。 |
隔离性意味着对同一文件的并发更新是可串行化的——存在一个串行调度使得实际执行的并行调度返回相同的结果。“真实的”数据库系统使用像这种高级技术维护可串行性,同时允许高等级的可并行性。回到我们的场景,我们最后使用加锁来串行文件更新。
对截断-写更新进行加锁是容易的。仅仅在所有文件操作前获取一个独占锁就可以。下面的例子代码从文件中读取一个整数,然后递增,最后更新文件:
def update(): with open(filename, 'r+') as f: fcntl.flock(f, fcntl.LOCK_EX) n = int(f.read()) n += 1 f.seek(0) f.truncate() f.write('{}\n'.format(n))使用 写-替换模式加锁更新就有点儿麻烦啦。像 截断-写那样使用锁可能导致更新冲突。某个幼稚的实现可能看起来像这样:
转载地址:http://iwfob.baihongyu.com/