>  기사  >  백엔드 개발  >  안정적이고 안정적인 파일 작업을 위해 Python을 사용하는 방법

안정적이고 안정적인 파일 작업을 위해 Python을 사용하는 방법

巴扎黑
巴扎黑원래의
2017-04-05 14:34:081366검색

프로그램에는 업데이트된 파일이 필요합니다. 대부분의 프로그래머는 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)

간단해 보이죠? 처음 보이는 것만큼 단순해 보이지 않을 수도 있습니다. 프로덕션 서버에서 애플리케이션을 디버깅하고 있는데 종종 이상한 동작이 발생합니다.

​ 제가 본 실패 모드의 예는 다음과 같습니다.

  • 런어웨이 서버 프로세스에서 너무 많은 로그가 유출되어 디스크가 가득 찼습니다. write()는 파일을 자른 후 예외를 발생시키며 파일은 비어 있게 됩니다.


  • 애플리케이션의 여러 인스턴스가 병렬로 실행됩니다. 각 인스턴스가 끝난 후 여러 인스턴스의 출력이 혼합되기 때문에 파일 내용은 결국 성경이 됩니다.


  • 쓰기 작업을 완료한 후 애플리케이션은 일부 후속 작업을 트리거합니다. 몇 초 후에 전원이 꺼집니다. 서버를 다시 시작한 후 이전 파일 내용을 다시 보았습니다. 다른 애플리케이션으로 전달된 데이터는 더 이상 파일에 표시되는 데이터와 일치하지 않습니다.

아래에는 새로운 내용이 없습니다. 이 기사의 목적은 시스템 프로그래밍 경험이 부족한 Python 개발자에게 일반적인 방법과 기술을 제공하는 것입니다. 개발자가 이러한 방법을 자신의 코드에 쉽게 적용할 수 있도록 코드 예제를 제공하겠습니다.

"신뢰성"은 무엇을 의미합니까?

광범위하게 말하면, 신뢰성이란 작업이 지정된 모든 조건에서 필요한 기능을 수행할 수 있음을 의미합니다. 파일 작업의 경우 이 기능은 파일 내용을 생성, 교체 또는 추가하는 문제입니다. 여기에서는 데이터베이스 이론에서 영감을 얻을 수 있습니다. 클래식 트랜잭션 모델의 ACID 속성은 안정성을 향상시키는 지침 역할을 합니다.

시작하기 전에 먼저 예제가 ACID의 네 가지 속성과 어떻게 관련되어 있는지 살펴보겠습니다.

  • 원자성 에서는 이 트랜잭션이 완전히 성공하거나 완전히 실패해야 합니다. 위의 예에서 디스크가 가득 차면 부분 콘텐츠가 파일에 기록될 수 있습니다. 또한 파일이 기록되는 동안 다른 프로그램이 파일을 읽는 경우 부분적으로 완료된 버전을 얻거나 쓰기 오류가 발생할 수도 있습니다.


  • 일관성은 작업이 시스템의 한 상태에서 다른 상태로 이동해야 함을 의미합니다. 일관성은 내부 일관성과 외부 일관성이라는 두 부분으로 나눌 수 있습니다. 내부 일관성은 파일의 데이터 구조가 일관적이라는 것을 의미합니다. 외부 일관성은 파일 내용이 관련 데이터와 일치함을 의미합니다. 이 경우 애플리케이션을 이해하지 못하기 때문에 일관성을 유추하기 어렵습니다. 그러나 일관성에는 원자성이 필요하므로 적어도 내부 일관성은 보장되지 않는다고 말할 수 있습니다.


  • 격리여러 개의 동일한 트랜잭션이 동시 실행 트랜잭션에서 다른 결과로 이어지는 경우 격리가 위반됩니다. 위의 코드에는 작업 실패나 기타 격리 실패에 대한 보호 기능이 없다는 것이 분명합니다.


  • 내구성은 변화가 영구적이라는 것을 의미합니다. 사용자에게 성공 여부를 알려주기 전에, 쓰기 캐시뿐만 아니라 데이터 저장소가 신뢰할 수 있는지 확인해야 합니다. 위 코드가 성공적으로 데이터를 썼다는 전제는 write() 함수를 호출하고 디스크 I/O가 즉시 실행된다는 가정입니다. 그러나 POSIX 표준은 이러한 가정을 보장하지 않습니다.

가능하면 데이터베이스 시스템을 사용하세요

ACID의 4가지 속성을 얻을 수 있다면 신뢰성 향상에 있어 장기적인 진전을 이룬 것입니다. 하지만 이를 위해서는 많은 코딩 노력이 필요합니다. 바퀴를 재발명하는 이유는 무엇입니까? 대부분의 데이터베이스 시스템에는 이미 ACID 트랜잭션이 있습니다.

안정적인 데이터 스토리지는 이미 해결된 문제입니다. 안정적인 저장소가 필요하다면 데이터베이스를 사용하세요. 수십 년간의 노력 없이 스스로 이 문제를 해결하는 능력은 수년 동안 이 문제에 집중해 온 사람들만큼 좋지 않을 가능성이 매우 높습니다. 대규모 데이터베이스 서버를 설치하고 싶지 않다면 ACID 트랜잭션이 있고 작고 무료이며 Python의 표준 라이브러리에 포함되어 있는 sqlite를 사용할 수 있습니다.

기사는 여기서 끝났어야 했지만 데이터를 사용하지 않는 데에는 몇 가지 타당한 이유가 있습니다. 일반적으로 파일 형식이나 파일 위치 제약이 있습니다. 이 두 가지 모두 데이터베이스 시스템에서 제어하기 어렵습니다. 그 이유는 다음과 같습니다.

  • 다른 애플리케이션에서 생성된 고정된 형식이나 고정된 위치의 파일을 처리해야 합니다.


  • 다른 애플리케이션에서 사용할 수 있도록 파일을 작성해야 합니다(동일한 제약 조건이 적용됨)


  • 我们的文件必须方便人阅读或者修改。

  ...等等。你懂的。

  如果我们自己动手实现可靠的文件更新,那么这里有一些编程技术供参考。下面我将展示四种常见的操作文件更新模式。在那之后,我会讨论采取哪些步骤在每个文件更新模式下满足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.name
os.rename(tempname, filename)

  该方法与截断-写方法相比对错误更具有鲁棒性。请看下面对原子性和一致性的讨论。很多应用使用该方法。

  这两个模式很常见,以至于linux内核中的ext4文件系统甚至可以自动检测到这些模式,自动修复一些可靠性缺陷。但是不要依赖这一特性:你并不是总是使用ext4,而且管理员可能会关掉这一特性。

  追加

  第三种模式就是追加新数据到已存在的文件:

with open(filename, 'a') as f:
   f.write(model.output())

  这个模式用来写日志文件和其它累积处理数据的任务。从技术上讲,它的显著特点就是极其简单。一个有趣的扩展应用就是常规操作中只通过追加操作更新,然后定期重新整理文件,使之更紧凑。

  Spooldir

  这里我们将目录做为逻辑数据存储,为每条记录创建新的唯一命名的文件:

with open(unique_filename(), 'w') as f:
   f.write(model.output())

  该模式与附加模式一样具有累积的特点。一个巨大的优势是我们可以在文件名中放入少量元数据。举例来说,这可以用于传达处理状态的信息。spooldir模式的一个特别巧妙的实现是maildir格式。maildirs使用附加子目录的命名方案,以可靠的、无锁的方式执行更新操作。md和gocept.filestore库为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属性到文件更新

  下面,我将尝试加强文件更新模式。反过来让我们看看可以做些什么来满足ACID属性。我将会尽可能保持简单,因为我们并不是要写一个完整的数据库系统。请注意本节的材料并不彻底,但是可以为你自己的实验提供一个好的起点。

  原子性

  写-替换模式提供了原子性,因为底层的os.rename()是原子性的。这意味着在任意给定时间点,进程或者看到旧的文件,或者看到新的文件。该模式对写错误具有天然的鲁棒性:如果写操作触发异常,重命名操作就不会被执行,所有就没有用损坏的新文件覆盖正确的旧文件的风险。

  附加模式并不是原子性的,因为有附加不完整记录的风险。但是有个技巧可以使更新具有原子性:为每个写操作标注校验和。之后读日志的时候,忽略所有没有有效校验和的记录。以这种方式,只有完整的记录才会被处理。在下面的例子中,应用做周期性的测量,每次在日志中附加一行JSON记录。我们计算记录的字节表示形式的CRC32校验和,然后附加到同一行:

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 log
read 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,但是同时符号链接已经改变时就会出现竞争条件。

  隔离性

  隔离性意味着对同一文件的并发更新是可串行化的——存在一个串行调度使得实际执行的并行调度返回相同的结果。“真实的”数据库系统使用像MVCC这种高级技术维护可串行性,同时允许高等级的可并行性。回到我们的场景,我们最后使用加锁来串行文件更新。

  对截断-写更新进行加锁是容易的。仅仅在所有文件操作前获取一个独占锁就可以。下面的例子代码从文件中读取一个整数,然后递增,最后更新文件:

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))

  使用写-替换模式加锁更新就有点儿麻烦啦。像 截断-写那样使用锁可能导致更新冲突。某个幼稚的实现可能看起来像这样:

def update():
   with open(filename) as f:
      fcntl.flock(f, fcntl.LOCK_EX)
      n = int(f.read())
      n += 1
      with tempfile.NamedTemporaryFile(
            'w', dir=os.path.dirname(filename), delete=False) as tf:
         tf.write('{}\n'.format(n))
         tempname = tf.name
      os.rename(tempname, filename)

  这段代码有什么问题呢?设想两个进程竞争更新某个文件。第一个进程运行在前面,但是第二个进程阻塞在fcntl.flock()调用。当第一个进程替换了文件,释放了锁,现在在第二个进程中打开的文件描述符指向了一个包含旧内容的“幽灵”文件(任意路径名都不可达)。想要避免这个冲突,我们必须检查打开的文件是否与fcntl.flock()返回的相同。所以我写了一个新的LockedOpen上下文管理器来替换内建的open上下文。来确保我们实际打开了正确的文件:

class LockedOpen(object):

    def __init__(self, filename, *args, **kwargs):
        self.filename = filename
        self.open_args = args
        self.open_kwargs = kwargs
        self.fileobj = None

    def __enter__(self):
        f = open(self.filename, *self.open_args, **self.open_kwargs)
        while True:
            fcntl.flock(f, fcntl.LOCK_EX)
            fnew = open(self.filename, *self.open_args, **self.open_kwargs)
            if os.path.sameopenfile(f.fileno(), fnew.fileno()):
                fnew.close()
                break
            else:
                f.close()
                f = fnew
        self.fileobj = f
        return f

    def __exit__(self, _exc_type, _exc_value, _traceback):
        self.fileobj.close()
    def update(self):
        with LockedOpen(filename, 'r+') as f:
            n = int(f.read())
            n += 1
            with tempfile.NamedTemporaryFile(
                    'w', dir=os.path.dirname(filename), delete=False) as tf:
                tf.write('{}\n'.format(n))
                tempname = tf.name
            os.rename(tempname, filename)

  给追加更新上锁如同给截断-写更新上锁一样简单:需要一个排他锁,然后追加就完成了。需要长期运行的会将文件长久的打开的进程,可以在更新时释放锁,让其它进入。

  spooldir模式有个很优美的性质就是它不需要任何锁。此外,你建立在使用灵活的命名模式和一个健壮的文件名分代。邮件目录规范就是一个spooldir模式的好例子。它可以很容易的适应其它情况,不仅仅是处理邮件。

  持久性

  持久性有点特殊,因为它不仅依赖于应用,也与OS和硬件配置有关。理论上来说,我们可以假定,如果数据没有到达持久存储,os.fsync()或os.fdatasync()调用就没有返回结果。在实际情况中,我们有可能会遇到几个问题:我们可能会面对不完整的fsync实现,或者糟糕的磁盘控制器配置,它们都无法提供任何持久化的保证。有一个来自 MySQL 开发者 的讨论对哪里会发生错误进行了详尽的讨论。有些像PostgreSQL 之类的数据库系统,甚至提供了持久化机制的选择 ,以便管理员在运行时刻选择最佳的一个。然而不走运的人只能使用os.fsync(),并期待它可以被正确的实现。

  通过截断-写模式,在结束写操作以后关闭文件以前,我们需要发送一个同步信号。注意通常这还牵涉到另一个层次的写缓存。glibc 缓存 甚至会在写操作传递到内核以前,在进程内部拦住它。同样为了得到空的glibc缓存,我们需要在同步以前对它flush():

with open(filename, 'w') as f:
   model.write(f)
   f.flush()
   os.fdatasync(f)

  要不,你也可以带参数-u调用Python,以此为所有的文件I/O获得未缓冲的写。

  大多数时候相较os.fsync()我更喜欢os.fdatasync(),以此避免同步元数据的更新(所有权、大小、mtime…)。元数据的更新可最终导致磁盘I/O搜索操作,这会使整个过程慢不少。

  对写-替换风格更新使用同样的技巧只是成功了一半。我们得确保在代替旧文件之前,新写入文件的内容已经写入了非易失性存储器上了,但是替换操作怎么办?我们不能保证那个目录更新是否执行的刚刚好。在网络上有很多关于怎么让同步目录更新的长篇大论。但是在我们这种情况,旧文件和新文件都在同一个目录下,我们可以使用简单的解决方案来逃避这个这题。

os.rename(tempname, filename)
dirfd = os.open(os.path.dirname(filename), os.O_DIRECTORY)
os.fsync(dirfd)
os.close(dirfd)

  我们调用底层的os.open()来打开目录(Python自带的open()方法不支持打开目录),然后在目录文件描述符上执行os.fsync()。

  对待追加更新和我以及说过的截断-写是相似的。

  spooldir模式与写-替换模式同样的目录同步问题。幸运地是,可以使用同样的解决方案:第一步同步文件,然后同步目录。

 总结

  这使可靠的更新文件成为可能。我已经演示了满足ACID的四大性质。这些展示的实例代码充当一个工具箱。掌握这编程技术最大的满足你的需求。有时,你并不需要满足所有的ACID性质,可能仅仅需要一到两个。我希望这篇文章可以帮助你去做已充分了解的决定,什么该去实现以及什么该舍弃。

  英文来源:http://blog.gocept.com/2013/07/15/reliable-file-updates-with-python/

위 내용은 안정적이고 안정적인 파일 작업을 위해 Python을 사용하는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.