Как реализовать атомарное обновление счетчика лайков в базе данных для избежания гонок
Что проверяют
Видите ли вы проблему потерянного обновления в схеме «прочитал, прибавил, записал» и знаете ли, что база умеет делать инкремент сама. Следом спрашивают про популярный пост, который лайкают тысячи людей в секунду, и про защиту от двойного лайка.
Как отвечать
- атомарный UPDATE:
UPDATE posts SET likes = likes + 1 WHERE id = %s RETURNING likes. Вычисление идёт внутри базы, строка блокируется до конца транзакции, параллельные запросы выстраиваются в очередь и ничего не теряют.RETURNINGесть в PostgreSQL и SQLite, в MySQL его нет; - в Django:
Post.objects.filter(pk=post_id).update(likes=F("likes") + 1). Если нужен объект, после такой записи зовитеpost.refresh_from_db(); - в SQLAlchemy:
update(Post).where(Post.id == post_id).values(likes=Post.likes + 1); - пессимистичная блокировка:
select_for_update()илиwith_for_update()тоже решает задачу, но держит строку дольше и оправдана, только когда между чтением и записью есть логика; - оптимистичная: колонка
versionиUPDATE ... WHERE id = %s AND version = %s, повтор, если обновилось ноль строк.
Дальше про реальную модель. Источник правды это таблица likes с уникальным ключом (user_id, post_id): INSERT ... ON CONFLICT DO NOTHING, и счётчик растёт, только если вставка прошла. Счётчик на посте это денормализация для быстрого показа.
Для горячей строки есть два пути: INCR в Redis с периодическим сбросом в базу или шардированный счётчик, когда на пост заводится N строк, прибавка идёт в случайную, а при показе они суммируются.
Где спотыкаются
Пишут post.likes += 1; post.save() и считают, что транзакция спасёт. На уровне изоляции READ COMMITTED, который в PostgreSQL стоит по умолчанию, два таких запроса прочитают одно значение, и один лайк пропадёт.
Проверяют решение на SQLite. SELECT ... FOR UPDATE там не поддерживается, SQLAlchemy молча опускает with_for_update(), и тест ничего не говорит о том, как блокировки поведут себя в PostgreSQL.
Забывают, как ведёт себя F() в Django. После post.likes = F("likes") + 1 и save() в атрибуте лежит выражение, а не число, и повторный save() прибавит лайк ещё раз.