[О блоге]
[наверх]
[пред]
[2023-02-21 13:36:31+03:00]
[ac7a2a8173b2f4685d98d94f9d0e7579c23ecf28]
Темы: [hate][python]
Уровень курсов/практикума по Python
Один знакомый just-for-fun решил поучаствовать в одном курсе по Python.
Дали задание: написать скрипт по переливке данных из нескольких таблиц
из SQLite3 БД в таблицы в PostgreSQL. Названия полей не совпадают, но
количество и суть остаётся прежней. Типа должно быть так, как условие:
data = load_from_sqlite3(...)
save_to_pgsql(data)
# и нужно использовать dataclass для данных
Изначально задачу он решил просто загрузив все данные в виде списка
словариков. И я считаю что вполне себе разумное решение, когда никаких
условий по ресурсам не поставили. Решение отвергли, мол данных может
быть много. И у меня претензия: почему об этом не сказано в задании?
Программа которая рассчитана на перезаливку терабайт -- это одно, а
наколеночное one-time решение когда всё умещается в памяти -- это
другое. И оба варианта стоит уметь делать.
Затем была написана версия одобренная мною, которая бы прошла моё ревью.
Её отвергли и в итоге дали типа эталонного варианта который бы прошёл. Я
не поверил своим глазам что итоговый вариант оказался проходным, а "наш"
нет. Призвал опытного коллегу, чтобы ещё и он, как третье лицо, оценил.
Он тоже оказался полностью на "нашей" стороне, не понимая как эталонный
вариант мог пройти ревью.
Как загружаются данные в эталонном варианте? Схематично как-то так,
опуская всякие мелочи типа инициализации подключения к БД, подключения
адаптеров/конвертеров для UUID/datetime типов данных, транзакции и прочее.
В чём-то наверное ошибся, пишу по памяти, но суть точно передам.
def load_from_sqlite3():
for klass, table in self.dataclasses2tables.items()
yield [klass(**converter(obj)) for obj in load_table(table)]
def load_table(table):
self.conn.prepare_data = prepare_data
self.conn.execute("SELECT * FROM %s" % table)
while True:
objs = self.conn.fetchmany(batch_size)
if objs is None:
break
yield from objs
def prepare_data(conn, row):
data = {}
for idx, name in conn.cols_description():
data[name] = row[idx]
def converter(obj):
if "foo" in obj:
obj["Foo"] = obj["foo"]
del obj["foo"]
if "bar_id" in obj:
obj["BarId"] = obj["bar_id"]
del obj["bar_id"]
...
Сохранение выглядело как-то так:
def save_to_pgsql(data):
for rows in data:
table = klass2table[row[0].__class__]
self.conn.executemany(
"INSERT INTO %s ..." % (...),
[astuple(row) for row in rows]
)
"Наш" вариант:
def load_from_sqlite3():
for klass, (table, colnames) in self.tables2dataclasses.items()
yield klass, load_table(klass, table, colnames)
def load_table(table, klass):
self.conn.prepare_data = lambda (conn, row): klass(*row)
self.conn.execute("SELECT %s FROM %s" % (",".join(colnames), table))
while True:
objs = self.conn.fetchmany(batch_size)
if objs is None:
break
yield from objs
def save_to_pgsql(data):
for klass, rows in data:
table = klass2table[klass.__class__]
stmt = "INSERT INTO %s ..." % (...)
batch = []
for row in rows:
batch.append(astuple(row))
if len(batch) == BatchSize:
self.conn.executemany(stmt, batch)
batch = []
if len(batch) > 0:
self.conn.executemany(stmt, batch)
Собственно, главная проблема "эталонного" кода: полная загрузка всех
данных каждой таблицы в память. Ибо и в load_from_sqlite3 и в
save_to_pgsql используются list comprehension-ы. Чем этот вариант
отличался бы от кода где не было бы генераторов вовсе? Который бы просто
разом загружал все данные в память списком и его передавал в save_to_pgsql?
Да ничем! Это полнейший fail. Мог ли рецензент ошибиться и не заметить
двойных квадратных скобок? Отнюдь, ведь они аж в двух местах присутствуют!
Это полностью нивелирует вообще всё что написано касательно генераторов
и обработкой пачками, раз всё равно всё загружается в память.
Отдельное безумие это сложность кода который конвертирует данные в
dataclass представление. Для *КАЖДОЙ* строки полученной из БД, он в
цикле идёт по описанию схемы таблицы чтобы переложить элементы кортежа в
именованные ключи словаря. А затем, переименовывает поля, внутри словаря.
А затем ещё и удаляет del-ом старое оставшееся название. Лютое безумие с
точки зрения процессора. Python -- ОЧЕНЬ медленный язык. Каждая строчка,
каждое действие, каждое обращение к методу это ощутимый overhead. Раз
речь про переливание данных, возможно миллиардов строчек, то миллиард
небольших операций превращается в воочию осязаемое время.
Например сделать
d["x"] = d.pop("y")
быстрее чем
d["x"] = d["y"]
del d["y"]
Сделать полностью в памяти второй словарик и только заполнять его, не
модифицируя исходный -- ещё быстрее.
Но зачем это всё? Мы можем чётко сопоставить порядок полей таблицы БД и
порядок полей dataclass-а. Почему бы просто не делать:
@dataclass
class Foo:
bar: int
baz: int
row = conn.execute("SELECT bar, baz ...")
Foo(*row)
Никаких словарей, никаких преобразований, дорогих. Просто сопоставить
порядок полей, один раз в запрос передав вместо "*" чёткое условие
выборки. Знание о связи полей dataclass и полями таблицы что одной, что
другой БД -- всё равно зашивается в коде. В "нашем" случаев это просто
кортежи/словарики лежащие в klass2table.
И тут нет совсем уж хаков типа: appender = data.append ; appender(row)
Также как и все мы понимаем что если нам реально нужно ещё быстрее
скопировать данные, то тогда вообще стоит использовать COPY конструкцию,
вместо *many вызовов.
У нас удивляются что мало толковых ИТ-специалистов. А вот нечего, если
крупные курсы/практикумы считают неприемлемым более короткий,
существенно более быстрый, просасывающий любые объёмы данных, код.
Отвратительно. И я не просто так призвал опытного коллегу рассудить,
ведь, быть может, это я уже совершенно забыл Python? И ведь появится
тьма новоиспечённых Python-программистов, которые даже данные между
двумя БД не смогут перелить хоть сколько-то эффективно.
[оставить комментарий]