П’ять SQL-патернів, які замінюють цикл на Python — і чому вони виграють
Найдорожчий Python, який я рев’юю, — це for order in orders після SELECT *. Останній рядок у групі, місяць до місяця, умовні лічильники й upsert — це запити. Як я пишу їх у Postgres, замість тягнути таблицю.
Найдорожчий Python, який я рев’юю, — не хендлер FastAPI без типів. Це for order in orders, який живе, бо ніхто не написав віконну функцію. Цикл виглядає чесно. Так само звіт, що тривав чотири секунди, стає сорока, а в понеділок після вдалого вікенда продажів — таймаутом.
Я вже писала, як обираю тип бази. Це наступний шар: SQL, який я справді пишу, коли питання продукту — «останнє замовлення клієнта», «виручка цього місяця проти минулого» або «встав бронювання, якщо слот вільний». Postgres відповідає за один round trip. Python теж — після того як завантажив таблицю. Парна стаття — як цей запит має вийти з Python, не тягнучи купу назад.
1. Цикл, який завантажує таблицю
Типовий скрипт: SELECT * FROM orders, далі групування в dict, далі найсвіжіший created_at на customer_id. На 800 рядках здається нормальним. На 800 000 — це тікет у підтримку. База вже має індекс, сортування і пам’ять для цього. Ви за це заплатили. Python як другий рушій запитів — так вартість вилазить у затримці, не в рахунку, який планували.
- Якщо результат менший за джерело, а правило має назву — last, sum, rank, exists — це запит.
- Якщо правило — бізнес-есе (повернення з людським винятком, абзац політики) — може лишитись у Python.
2. Останній рядок у групі — DISTINCT ON і ROW_NUMBER
Поточна підписка на робочий простір. Останній статус інвойсу на замовлення. Водій, який пінгував востаннє. Це перший цикл, який я видаляю. У Postgres пишу DISTINCT ON (workspace_id) і ORDER BY workspace_id, created_at DESC. Портативний родич — ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at DESC), далі лишаю rn = 1.
DISTINCT ON коротший, і я ставлю його, коли Postgres мій. ROW_NUMBER пишу, коли той самий запит має пережити переїзд, або коли потрібні ранг 1 і 2 — попередній план, другий результат. Не тягніть усі підписки й не зводьте їх у Python. На нічиїх помилитеся і завантажите колонки, які ніколи не малюєте.
3. Накопичувальні суми і «з минулого місяця» — SUM OVER і LAG
Місяць до місяця — не джойн таблиці на себе всередині циклу. LAG(revenue) OVER (PARTITION BY workspace_id ORDER BY month) кладе минулий місяць у той самий рядок. SUM(amount) OVER (PARTITION BY customer_id ORDER BY created_at) — накопичувальний баланс. Віконні функції лишають зерно рядка і все одно бачать сусідів. GROUP BY згортає. Цикл на Python збирає сусідів у dict і забуває часові пояси.
Якщо потрібні і детальний рядок, і підсумок групи, SUM(amount) OVER (PARTITION BY order_id) б’є другий запит і б’є N+1 з ORM. Увесь трюк: лишити зерно, яке малюватимете, а сусідство порахувати в рушії.
4. Умовні лічильники — FILTER замість if
COUNT(*) FILTER (WHERE status = paid) поруч із COUNT(*) FILTER (WHERE status = refunded) — один прохід. Версія на Python — один прохід і два лічильники, доки хтось не додасть третій статус у другому циклі або експорт не виросте і ви не пройдете двічі. FILTER читається. CASE у SUM працює на рушіях без FILTER. У Postgres я ставлю FILTER, бо намір на сторінці: цей підрахунок має предикат.
5. Upsert замість select-потім-insert
Гонка: прочитати «чи вільний цей email», потім вставити. Дві вкладки, два воркери, один unique, який знаходите в проді. INSERT ... ON CONFLICT (email) DO UPDATE — або DO NOTHING — транзакція, яку база вже вміє. Та сама родина: UPDATE ... WHERE version = $version для оптимістичного лока. Check-then-write у Python — це гірша база, написана вручну.
- Слоти бронювання, ключі ідемпотентності, «створи клієнта, якщо немає» — це upsert, не if.
- Унікальний індекс — частина продукту. Без нього ON CONFLICT — театр.
6. Як я перевіряю, що не вигадала повільніший запит
EXPLAIN (ANALYZE, BUFFERS) на повільному шляху. Якщо бачите sequential scan на таблиці, яка виросла, потрібен індекс під партицію і порядок — (workspace_id, created_at DESC) для DISTINCT ON. Віконна функція не безкоштовна. Вона все одно дешевша за відправку купи в застосунок. Якщо план сортує два мільйони рядків для дашборда на двадцять — додайте межу по даті. SQL, що замінює цикл, усе ще може бути поганим циклом усередині рушія.
Висновок: назвіть результат — напишіть запит
Python — добре місце для HTTP, політик і речення, яке бачить користувач. Погане місце, щоб заново винаходити GROUP BY. П’ять форм — last-per-group, lag, накопичувальна сума, умовні агрегати, upsert — і більшість «розумних» скриптів, які я рев’юю, стають в’ю або одним запитом у функції репозиторію. Інша стаття — Python, який відмовляється це скасувати.
Готові обговорити проєкт?
Я senior веброзробниця, спеціалізуюсь на React і Next.js - відкрита до фриланс-проєктів по всьому світу.
Місто
Київ, Україна
Upwork
Переглянути профільTelegram
Напишіть меніViber
Напишіть мені