Comment stocker efficacement de petits fichiers texte
Préparation
Créons un jeu de données de test avec de nombreux fichiers texte.
import os
import json
import random
import string
from datetime import datetime
workspace_dir = '/tmp/data'
output_dir = f'{workspace_dir}/jsons'
os.makedirs(output_dir, exist_ok=True)
def generate_random_string(length):
return ''.join(random.choices(string.ascii_letters + string.digits + string.punctuation + ' ', k=length))
for i in range(1000000):
data = {
'text': generate_random_string(random.randint(0, 1000)),
'created_at': int(datetime.now().timestamp())
}
filename = os.path.join(output_dir, f'{i}.json')
with open(filename, 'w') as json_file:
json.dump(data, json_file, separators=(',', ':'))
Par exemple, nous générerons 1 000 000 de fichiers json avec une structure (pour l'efficacité, ils n'ont pas d'espaces dans les fichiers, cela est atteignable avec le paramètre separators=(',', ':')
dans la méthode json.dump
):
{
"text": "random string",
"created_at": 1727625937
}
Mesurons la taille réelle :
du -sk /tmp/data/jsons
4000000 jsons
C'est 4 Go, cependant la taille devrait être <= 1 Go (1000000 * 1 Ko). C'est parce que j'utilise un système de fichiers avec une taille d'enregistrement de 4 Ko (J'utilise OS X, mais sous Linux la situation sera la même), donc j'ai 1000000 * 4 Ko = 4 Go.
Création d'un fichier texte fusionné
Nous avons besoin d'un générateur d'abord pour parcourir les fichiers :
import os
import json
def json_file_reader(directory):
json_files = [filename for filename in os.listdir(directory) if filename.endswith('.json')]
sorted_files = sorted(json_files, key=lambda x: int(os.path.splitext(x)[0]))
for filename in sorted_files:
file_path = os.path.join(directory, filename)
if os.path.isfile(file_path):
with open(file_path, 'r', encoding='utf-8') as json_file:
yield os.path.splitext(filename)[0], json_file.read()
Aussi, je le trie par nom de fichier, mais ce n'est pas nécessaire.
Script fusionné, nous devons ajouter un identifiant au dictionnaire json sinon il serait impossible de comprendre le fichier source :
with open(f'{workspace_dir}/merged.json', 'w', encoding='utf-8') as infile:
for id, data in json_file_reader(output_dir):
dict = json.loads(data)
dict['id'] = int(id)
infile.write(f'{json.dumps(dict, ensure_ascii=False, separators=(',', ':'))}\n')
du -k /tmp/data/merged.json
557060 merged.json
Maintenant, ce n'est que 557 Mo ! Plus de 7x plus petite ! Nous ne gaspillons plus de clusters. La taille moyenne du fichier est de 557 octets, ce qui correspond parfaitement à notre fonction de contenu random.randint(0, 1000)
.
Format binaire
Essayons maintenant d'utiliser une structure binaire optimisée.
Tout d'abord, nous devons sérialiser notre structure :
import struct
def pack(data):
text_bytes = data['text'].encode('utf-8')
format_string = f'iiH{len(text_bytes)}s'
return struct.pack(format_string, data['id'], data['created_at'], len(text_bytes), text_bytes)
def unpack(data):
offset = 10
i, ts, l = struct.unpack('iiH', data[:offset])
text = struct.unpack(f'{l}s', data[offset:offset + l])[0].decode()
return {
'id': i,
'created_at': ts,
'text': text
}
# test
packed = pack({"id": 1, "created_at": 1727625937, "text": "Hey!"})
print(f"{packed} -> {unpack(packed)}")
Créez maintenant un fichier binaire :
# %%
with open(f'{workspace_dir}/merged.struct.bin', 'wb') as infile:
for id, data in json_file_reader(output_dir):
dict = json.loads(data)
dict['id'] = int(id)
infile.write(pack(dict))
du -k /tmp/data/merged.struct.bin
507908 merged.struct.bin
Nous avons encore réduit le fichier à environ 508 Mo.
Messagepack
Parfois, il n'est pas possible d'avoir un schéma prédéfini, surtout si nous voulons stocker un json arbitraire. Essayons MessagePack.
Installez-le d'abord : pip install msgpack
.
Générons maintenant le fichier binaire fusionné :
import msgpack
with open(f'{workspace_dir}/merged.msgpack.bin', 'wb') as infile:
for id, data in json_file_reader(output_dir):
dict = json.loads(data)
dict['id'] = int(id)
infile.write(msgpack.packb(dict))
du -k /tmp/data/merged.msgpack.bin
524292 merged.msgpack.bin
La taille est légèrement plus grande qu'avec le protocole personnalisé ~524M, mais je pense que c'est un prix raisonnable pour un format sans schéma. Messagepack a un outil très pratique pour l'accès aléatoire :
with open(f'{workspace_dir}/merged.msgpack.bin', 'rb') as file:
file.seek(0)
unpacker = msgpack.Unpacker(file)
obj = unpacker.unpack()
print(obj)
print(unpacker.tell()) # offset actuel
Compression
Allons plus loin et essayons de compresser ces fichiers avec lz4 et zstd :
# dir
-> time tar -cf - jsons | lz4 - jsons.tar.lz4
Compressed 3360880640 bytes into 628357651 bytes ==> 18.70%
tar -cf - jsons 19.93s user 299.75s system 61% cpu 8:38.46 total
lz4 - jsons.tar.lz4 2.20s user 1.41s system 0% cpu 8:38.46 total
-> time tar -cf - jsons | zstd -o jsons.tar.zst
-> time lz4 merged.json
Compressed 558739918 bytes into 522708179 bytes ==> 93.55%
lz4 merged.json 0.47s user 0.35s system 152% cpu 0.534 total
Résultats
nom | taille brute | réduite | taille lz4 | temps lz4 | taille zstd | temps zstd |
---|---|---|---|---|---|---|
Fichiers en dir | 4G | 1x | 628M | 8:38 | 464M | 8:55 |
Fichier texte fusionné | 557M | 7.18x | 523M | 0.53s | 409M | 1.62s |
Fichier bin fusionné (struct) | 508M | 7.87x | 510M | 0.46 | 410M | 1.47s |
Fichier bin fusionné (msgpack) | 524M | 7.63x | 514M | 0.36s | 411M | 1.6s |