Несколько баз данных¶
Приложение с несколькими подключениями выбирает подключение для каждого запроса: через using= в вызове
или через маршрутизатор, который решает за все запросы модели, отдельно для чтения и записи.
using=¶
Подключение везде выбирается одинаково — одним именем using, которое принимает имя подключения
("replica") или клиент (клиент транзакции, клиент из autonomous(), маршрутизатора или
Connections.get(connection_alias)):
У QuerySet: .using(alias_or_client) — копия запроса, закреплённая за этим подключением. С него
начинается любой запрос, поэтому это покрывает create, get, get_or_create, update_or_create,
filter, bulk_create, bulk_update, raw, count, ...: Book.objects.using("replica").filter(...),
Book.objects.using(transaction).create(...).
У объекта: аргумент using= у save, delete, hard_delete, delete_preview, restore и
refresh_from_db; у записей через связь (add, remove, set, clear, create, ...), в том числе
«многие-ко-многим»; и у prefetch_related_objects().
В остальных местах: Transactions.atomic(using), on_commit(..., using=),
on_rollback(..., using=), execute_sql(..., using=). Клиент автономной транзакции передаётся так же:
async with Transactions.autonomous() as connection:
book = await Book.objects.using(connection).create(title="Notes")
await book.tags.add(tag, using=connection)
Объект помнит своё подключение¶
Объект модели помнит имя подключения, из которого он загружен (включая объекты, построенные
select_related()/prefetch_related(), stream(), union() и raw()), или в которое последний раз
сохранён (save(), create(), get_or_create(), bulk_create()). Каждая следующая операция с ним без
using= по умолчанию идёт туда: save(), delete(), restore(), refresh_from_db(),
prefetch_related_objects(), ожидание прямой связи (await book.author) и связи объекта
(author.books.all()/.filter()/.count()/.values(), add()/remove()/clear() у
«многие-ко-многим»):
book = await Book.objects.all().using("replica").get(title="Notes")
author = await book.author # прочитан из "replica", а не из "default"
books = await author.books.all() # тоже "replica"
Подключение ищется по имени, когда выполняется запрос, поэтому внутри открытого atomic() на том же
подключении используется транзакция. Явный using=/.using() всегда важнее, как и маршрутизатор, у которого
есть мнение о запрашиваемой модели. Связанная модель, подключение по умолчанию которой отличается от
подключения модели объекта (связь между разными базами), и связь, прочитанная из объекта модели, которую
направляет маршрутизатор, сохраняют собственный выбор подключения. Queryset из Prefetch("books",
Book.objects.filter(...)) читает из подключения родителя, как и простой prefetch_related("books"), если сам
не выбрал подключение через .using().
Маршрутизаторы¶
Напишите обычный класс — наследовать ничего не нужно, hare вызывает его методы по имени — с одним или
обоими методами, каждый из которых возвращает имя подключения (или None):
class ReportingRouter:
def db_for_read(self, model: type[Model]) -> str | None:
if model._meta.app == "reporting":
return "reporting_replica"
return None
def db_for_write(self, model: type[Model]) -> str | None: ...
Любого из методов может не быть вовсе — hare пропускает маршрутизатор для действия, которого тот не
реализует, а не выдаёт ошибку. Побеждает первый настроенный маршрутизатор, вернувший не None; если
таких нет, используется обычное подключение модели по умолчанию. Возвращённое имя, которому не
соответствует ни одно настроенное подключение, даёт ConfigurationError: маршрутизатор, который
сработал, но назвал подключение с опечаткой или устаревшее, считается настоящей ошибкой настройки и не
пропускается молча.
Чтение, от которого зависит запись, идёт через подключение db_for_write: проверка существования в
get_or_create(), чтение с блокировкой в update_or_create() и select_for_update(). На реплике может
ещё не быть строки, которая уже есть на основной базе (отставание репликации или строка, созданная раньше
в ещё открытой транзакции), и промах там создал бы дубликат.
Регистрируются через Hare.init(routers=[...]) — списком путей через точку
("library.routers.ReportingRouter") или самих классов; каждый создаётся без аргументов. Каждый
HareContext хранит свои маршрутизаторы в отдельном ConnectionRouter (context.router), который
опрашивает их по очереди, — поэтому маршрутизаторы разных контекстов никогда не смешиваются.
Маршрутизатор выбирает, куда идут запросы, а не где лежат таблицы: makemigrations, migrate и drift
работают с default_connection каждого приложения и маршрутизаторы не спрашивают. Поэтому маршрутизатор
разводит запросы между подключениями с одними и теми же таблицами — основной базой и её репликами; модель,
чья таблица лежит в другой базе, относится к приложению, у которого default_connection — эта база.
Маршрутизатор, отправивший модель на подключение без её таблицы, получает там ошибку запроса
(no such table).
Чтение своих записей¶
Реплика получает запись позже основной базы — запрос, который выдаёт книгу и сразу читает выдачу, может
не найти её там. Поэтому, как только asyncio-задача (запрос) записала через подключение, каждое чтение
модели, которую db_for_write направляет в это подключение (или которая пишется через своё
default_connection, когда db_for_write возвращает None), идёт в него, а не в подключение из
db_for_read, — до конца задачи. Другая задача — другой запрос — продолжает читать из реплики. Задача,
запущенная из записавшей, тоже видит её записи; её собственные записи остаются при ней — запустившая её
задача читает как прежде. Открытый Transactions.atomic("primary") — не запись: чтение до первой записи
блока идёт туда, куда направляет db_for_read, — в реплику, а не в транзакцию. Транзакция, которая
читает строки, чтобы их изменить, читает их через .using("primary") или внутри
Routing.using_primary() — они берут открытую транзакцию.
Запись — то, что меняет строки: save(), delete() и restore() объекта, create(), update(),
delete(), bulk_create() и bulk_update() у QuerySet, add(), remove(), clear() и set() связи
«многие-ко-многим» — через подключение, выбранное маршрутизатором или названное в .using(). Вопрос,
через какое подключение пишется модель (Model.get_connection(for_write=True)), ничего не пишет и чтений
не меняет.
Настройка конфигурации read_your_writes_seconds заканчивает это через столько секунд после последней
записи задачи, когда реплика успела догнать основную базу:
await Hare.init(
config={**HARE_CONFIG, "routers": ["library.routers.PrimaryReplicaRouter"], "read_your_writes_seconds": 5}
)
Routing.using_primary() (блок with или async with) отправляет каждое чтение своего блока в
подключение, через которое модель записывается, — писала задача или нет; это нужно, например, для
проверки сразу после записи, сделанной в другом месте:
from hare.core.routing import Routing
async with Routing.using_primary():
loan = await Loan.objects.get(pk=loan_id)
Routing.forget_writes() снова пускает чтения текущей задачи в реплики — для задачи, которая обслуживает
несколько запросов подряд. .using() у QuerySet и транзакция выбирают подключение сами, и это их не
касается. Без маршрутизаторов ничего из этого не работает.