Что: ac7a2a8173b2f4685d98d94f9d0e7579c23ecf28
Когда: 2023-02-21 13:36:31+03:00
Темы: 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-программистов, которые даже данные между двумя БД не смогут перелить хоть сколько-то эффективно.
Сгенерирован: SGBlog 0.34.0