Как эффективно хранить маленькие текстовые файлы
Подготовка
Создадим тестовый набор данных с множеством текстовых файлов.
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=(',', ':'))
Например, мы сгенерируем 1 000 000 JSON-файлов со структурой (для эффективности в файлах нет пробелов, этого можно достичь с помощью параметра separators=(',', ':')
в методе json.dump
):
{
"text": "случайная строка",
"created_at": 1727625937
}
Давайте измерим фактический размер:
du -sk /tmp/data/jsons
4000000 jsons
Это 4Гб, однако размер должен быть <= 1Гб (1000000 * 1Kb). Это связано с тем, что я использую файловую систему с размером записи 4Кб (у меня OS X, но на Linux ситуация будет такой же), поэтому у меня есть 1000000 * 4Кб = 4Гб.
Создание объединенного текстового файла
Сначала нам нужен генератор для итерации по файлам:
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()
Также я сортирую по имени файла, но это не обязательно.
Объединенный скрипт, нам нужно добавить id в JSON-словарь, иначе будет невозможно определить исходный файл:
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
Теперь это всего 557Мб! Более чем в 7 раз меньше! Мы больше не тратим кластеры. Средний размер файла составляет 557 байт, что идеально соответствует нашей функции random.randint(0, 1000)
.
Бинарный формат
Теперь давайте попробуем использовать оптимизированную бинарную структуру.
Во-первых, нам нужно сериализовать нашу структуру:
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
}
# тест
packed = pack({"id": 1, "created_at": 1727625937, "text": "Привет!"})
print(f"{packed} -> {unpack(packed)}")
Теперь создадим бинарный файл:
# %%
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
Мы еще сократили файл до ~ 508М.
Messagepack
Иногда невозможно иметь предопределенную схему, особенно если мы хотим хранить произвольный JSON. Попробуем MessagePack.
Сначала установите: pip install msgpack
.
Теперь создадим объединенный бинарный файл:
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
Размер немного больше, чем при использовании пользовательского протокола ~524М, но я думаю, что это разумная плата за отсутствие схемы. Messagepack имеет очень удобный инструмент для случайного доступа:
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()) # текущий смещение
Сжатие
Давайте попробуем сжать эти файлы с помощью lz4 и zstd:
# каталог
-> time tar -cf - jsons | lz4 - jsons.tar.lz4
Сжато 3360880640 байтов до 628357651 байтов ==> 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
Сжато 558739918 байтов до 522708179 байтов ==> 93.55%
lz4 merged.json 0.47s user 0.35s system 152% cpu 0.534 total
Результаты
название | необработанный размер | уменьшение | размер lz4 | время lz4 | размер zstd | время zstd |
---|---|---|---|---|---|---|
Файлы в каталоге | 4G | 1x | 628M | 8:38 | 464M | 8:55 |
Объединенный текстовый файл | 557M | 7.18x | 523M | 0.53s | 409M | 1.62s |
Объединенный бинарный файл (struct) | 508M | 7.87x | 510M | 0.46 | 410M | 1.47s |
Объединенный бинарный файл (msgpack) | 524M | 7.63x | 514M | 0.36s | 411M | 1.6s |