逐次的に読めるシリアライズファイルを作る/読む

TUT Advent Calendar 2018 7日目の記事です。

雉も鳴かずば打たれまいとはよく言ったもので、煽りをしたばっかりに書かざるを得なくなったので書いています。 もう少し真面目なネタを書くべきですが、あいにくと年末の一大イベントに向けてスケジュールがカッツカツになっているので簡単なネタでお茶を濁します。 使える人には使えるし、知ってる人走っているちょっとした豆知識です。

TL;DR

  • データのリストからひとつずつデータを取り出して、ひとつごとにシリアライズすると、読み込みが先頭から逐次的に行える
  • その代わり、ランダムアクセスを実現するのは難しい(そのためにはすべてをオンメモリにするか,データベースを持ち込む必要がある)
  • 必要とするメモリ量が少なくなるのでメインメモリが多くないラップトップでもデータを眺めるだけなら出来る

あらまし

皆さんは100万件や1000万件、あるいはそれ以上の数のファイルを日常的に扱うことがあると思います。*1 しかし、あなたの手元のラップトップにはメモリが8GBしかありません! これではデータをすべてメモリに載せるのは厳しそうです……。 という状況で使える軽いテクニックの話です。

たとえばTwitterJSONが1000万件程度収められているケースを考えると、これは明らかに8GBのような貧弱なメモリに載りそうにはありません。 いま、単純におのおののデータを1回だけ眺めて必要な値を抜き出すというタスクを考えると

  1. データリストの先頭からひとつデータを取り出す
  2. 必要な処理を行う
  3. データリスト末端でなければ1.に戻る

を繰り返すだけでよく、すべてのデータをオンメモリとする必要はなくなります。

そこで、すべてのデータを一回ずつ取り出せる構造を考えたくなってきました。 最も単純な方法としては、JSONをstringfyした行データを用意して1行ずつ読み込んでいく方法があります。 たしかにこれは便利ですが、そのかわり生データがそのまま保存されるため、今度はストレージが爆発する可能性があって嬉しくないです。 なんらかの圧縮法(たとえばgzipやbzip)を使って、うまくデータを圧縮しながらかつ行単位で読み込みが出来るようにならないでしょうか……。

実はこれは簡単に実現できます。具体的には次のコードを実行すれば十分です。

import gzip
data = [ ... ] # JSONオブジェクトのリスト
with gzip.open("data.dump", "wb") as f:
     f.write(str(data).encode("UTF-8"))
     f.write("\n".encode("UTF-8"))

これで実現できました。 ほんとに?となるので試してみましょう。

適当なデータを用意して、いい感じにダンプしてみます。 今回はテストデータを適当な件数生成してくれるdatabasetestdata.comを使ってJSONデータを10000件程度生成してみます。

Database test data generator - Fill your database with random test data!

サンプルコードはこんな感じです。

import json
import bz2
import gzip
import string

with open("sample.json") as f:
    d = json.load(f)


# gzipで試す
with gzip.open("sample.gz", "wb") as f:
    for _ in d:
        f.write(json.dumps(_).encode("UTF-8"))
        f.write("\n".encode("UTF-8"))

with gzip.open("sample.gz") as f:
    result = []
    for i, _ in enumerate(f):
        s = "".join(filter(lambda x: x in string.printable, _.decode("UTF-8")))
        a = json.loads(s)
        result.append(a == d[i])

print("gzip result :", all(result))

# bzip2で試す
with bz2.BZ2File("sample.bz2", "wb") as f:
    for _ in d:
        f.write(json.dumps(_).encode("UTF-8"))
        f.write("\n".encode("UTF-8"))

with bz2.BZ2File("sample.bz2") as f:
    result = []
    for i, _ in enumerate(f):
        s = "".join(filter(lambda x: x in string.printable, _.decode("UTF-8")))
        a = json.loads(s)
        result.append(a == d[i])

print("bzip2 result :", all(result))

実行結果はこうなりました

akahana@stokuno $ python3 sample.py
gzip result : True
bzip2 result : True

うまくいきましたね。

ついでにファイルサイズを確認してみましょう。

akahana@stokuno $ du -h sample.*
216K    sample.bz2
396K    sample.gz
1.7M    sample.json

どうやらいい感じに削減できているようです。

この方法のメリットは - プログラム実行時のメモリ使用量が全展開に比べて抑えられる - 逐次読み出しのみが必要なタスクでは十分に動作する という点です。

一方でデメリットとして - データを読み出す際のコストが重たい。特に時間は通常の読み込みよりも大幅に要する - すべてのデータを一度に圧縮するケースに比べて効率が低い

の2点です。 データを読み出す際に毎回gzipなどのアルゴリズムを用いてデータの展開を行う必要があるため、必然的に生データに比べてデータ読み出しに必要な時間が増えます。 また、データ圧縮時に利用する辞書が各データごとに用意されるため、全体を1度で圧縮するケースに比べれば効率が低いと言えるでしょう。

一方で限られたユースケースでは強力に動作します。 特に、メモリの制約を持つ中であるデータをシーケンシャルに読んで特定のタスクを実行したい(たとえばデータフィルタリング)ということを大規模なデータに行う際には有効な手法だと思います。

使える機会がどれくらいあるか、わかりませんが。知っておくと便利な手法でしょう。

まとめ

今回のAdCalでは大規模なデータの逐次処理に関してメモリやストレージにある程度制約が設けられている環境で動作させる方法を紹介しました。これらの方法は、データに対する単純な処理をメモリ使用量を少なくした状態で行いたいというケースにおいて役立ちます。 そういうケースがいかほど存在するかは別にして、頭の片隅に置いておくといいでしょう。

*1:要出典