Bulk в Django ORM | Курс Django ORM урок 5.4
Цель урока
Замерить реальную производительность save() в цикле против bulk_create() и bulk_update(). Разобрать паттерн "накопление и сброс" для обработки больших объемов данных, и понять как транзакции влияют на скорость батчевых операций.
Необходимые знания
- Урок 2.5: update(), bulk_create(), bulk_update(), delete()
- Урок 5.3: iterator(), only()
- Базовое понимание INSERT и UPDATE в SQL
В уроке 2.5 разобрали как работают bulk_create() и bulk_update(), какой SQL они генерируют и какие у них ограничения. Здесь сосредоточимся на производительности: реальные цифры, оптимальный batch_size и паттерны для обработки больших датасетов.
Реальные замеры производительности
Замеры на PostgreSQL в Docker, вставка 10 000 объектов. Функция для замера:
import time
from django.db import connection, reset_queries
from decimal import Decimal
def benchmark(label, func):
reset_queries()
start = time.perf_counter()
func()
elapsed = time.perf_counter() - start
queries = len(connection.queries)
print(f"{label}: {elapsed:.3f}s, запросов: {queries}")
category = Category.objects.first()
# Вариант 1: save() в цикле
def create_one_by_one():
for i in range(10_000):
Product.objects.create(
name=f"P{i}", slug=f"p-{i}", price=Decimal("100"), stock=10, category=category
)
benchmark("save() в цикле", create_one_by_one)
# save() в цикле: 8.423s, запросов: 10000
# Вариант 2: bulk_create без batch_size
def create_bulk():
products = [
Product(name=f"P{i}", slug=f"p-{i}", price=Decimal("100"), stock=10, category=category)
for i in range(10_000)
]
Product.objects.bulk_create(products)
benchmark("bulk_create()", create_bulk)
# bulk_create(): 0.187s, запросов: 1
# Вариант 3: bulk_create с batch_size=500
def create_bulk_batched():
products = [
Product(name=f"P{i}", slug=f"p-{i}", price=Decimal("100"), stock=10, category=category)
for i in range(10_000)
]
Product.objects.bulk_create(products, batch_size=500)
benchmark("bulk_create(batch_size=500)", create_bulk_batched)
# bulk_create(batch_size=500): 0.312s, запросов: 20
Аналогичная картина для обновления 10 000 строк:
products = list(Product.objects.all()[:10_000])
# save() в цикле: 12.1s, запросов: 10000
# bulk_update(): 0.89s, запросов: 1
# bulk_update(batch_size=500): 1.1s, запросов: 20
Разница в 45-60 раз на localhost. На продакшне с сетевой задержкой 1-5 мс разрыв будет ещё больше.
Выбор batch_size
batch_size влияет на баланс между памятью, количеством запросов и скоростью:
batch_size=None → 1 запрос, максимальная память, риск превысить лимит ~65k параметров PostgreSQL
batch_size=1000 → 10 запросов, хороший баланс для большинства случаев
batch_size=100 → 100 запросов, меньше памяти на батч, больше round-trip
Правило: batch_size=500-1000 для строк весом до 1 кБ. Если строки большие (много текстовых полей), уменьшайте до 200-500.
QuerySet.update() vs bulk_update()
Оба метода обновляют несколько строк, но для разных задач:
QuerySet.update() - когда значение одинаково для всех строк:
# Один запрос, вычисление на уровне SQL
Product.objects.filter(category=category).update(
price=F("price") * Decimal("1.1")
)
UPDATE shop_product SET price = price * 1.1 WHERE category_id = 1;
bulk_update() - когда у каждой строки своё значение:
# Разные цены для каждого продукта из внешнего источника
for product in products:
product.price = external_prices[product.id]
Product.objects.bulk_update(products, ["price"])
UPDATE shop_product SET price = CASE id
WHEN 1 THEN 99.50
WHEN 2 THEN 149.00
...
END
WHERE id IN (1, 2, ...);
| Подход | Когда использовать | Запросов |
|---|---|---|
save() в цикле |
Логика в сигналах, один-два объекта | N |
QuerySet.update() |
Одинаковая логика для всех строк | 1 |
bulk_update() |
Разные значения для разных строк | N/batch_size |
bulk_create() |
Вставка множества объектов | N/batch_size |
Паттерн: накопление и сброс
При обработке большого датасета комбинируем iterator() из урока 5.3 с bulk_update():
BATCH_SIZE = 500
def recalculate_prices():
updates = []
for product in Product.objects.only("id", "price", "category_id").iterator(chunk_size=1000):
new_price = calculate_new_price(product)
if new_price != product.price:
product.price = new_price
updates.append(product)
if len(updates) >= BATCH_SIZE:
Product.objects.bulk_update(updates, ["price"])
updates.clear()
if updates:
Product.objects.bulk_update(updates, ["price"])
Этот паттерн:
- Читает строки чанками по 1000 через серверный курсор (
iterator) в памяти только текущий чанк - Накапливает только изменившиеся объекты
- Сбрасывает батчами по 500 через один UPDATE с CASE WHEN
bulk_create в транзакции
При batch_size Django делает N запросов, каждый из которых по умолчанию в autocommit:
# N/batch_size отдельных commit
Product.objects.bulk_create(products, batch_size=500)
# Один commit для всех батчей
from django.db import transaction
with transaction.atomic():
Product.objects.bulk_create(products, batch_size=500)
Явная транзакция дает два преимущества:
- Атомарность: либо все батчи успешно, либо откат. Без транзакции при ошибке на 15-м батче первые 14 уже зафиксированы
- Скорость: commit один раз вместо N/batch_size раз. На 10 000 строк с
batch_size=500- 1 commit вместо 20
Практическое задание
1) Напишите функцию import_products(data), которая принимает список словарей и создает продукты через bulk_create() с batch_size=200. Посмотрите SQL через connection.queries.
2) Замерьте время: создание 5000 объектов Review через save() в цикле vs bulk_create(). Выведите разницу в секундах и количество запросов.
3) Напишите функцию apply_category_discount(category_id, percent), которая уменьшает цену всех продуктов категории на заданный процент. Реализуйте через QuerySet.update() и через загрузку + bulk_update(). Замерьте оба варианта.
4) Используйте bulk_create() с update_conflicts=True для синхронизации продуктов: если продукт по slug уже существует, обновить price и stock, если нет, создать.
5) Реализуйте deactivate_out_of_stock() тремя способами и замерьте каждый:
save()в циклеQuerySet.update()iterator()+bulk_update()
Возможные ошибки
bulk_update с полями, которых нет в объектах
products = Product.objects.only("name") # загружены только id + name
for p in products:
p.price = Decimal("100") # установили в Python
Product.objects.bulk_update(products, ["price", "stock"])
# stock не был загружен, bulk_update запишет дефолтное значение поля!
bulk_update берет значения из атрибутов объекта. Если поле не было загружено, атрибут содержит дефолтное значение модели, а не реальное значение из БД.
Убедитесь что все поля из update_fields загружены.
bulk_create без транзакции при большом batch_size
# При ошибке на 15-м батче, первые 14 уже в БД, данные в несогласованном состоянии
Product.objects.bulk_create(products, batch_size=500)
# Правильно
with transaction.atomic():
Product.objects.bulk_create(products, batch_size=500)
Связь со следующим уроком
В уроке 6.1 переходим к транзакциям: transaction.atomic(), savepoints и on_commit(). Увидим как именно работает atomic() который использовали здесь, и как выполнить код после успешного commit. Например, отправить email только если данные действительно сохранились.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru