Как считать счётчики и не сбиться со счёта
Число подписчиков блога. Число опубликованных постов пользователя. Число положительных и отрицательных голосов за комментарий. Число оплаченных заказов товара. Вам приходилось считать что-то подобное? Тогда, готов поспорить, что оно у вас периодически сбивалось. Да ладно, даже у вконтакта сбивалось:
Не знаю как у вас, но в моей жизни счётчики — едва ли не первая проблема после инвалидации кеша и нейминга. Не стану утверждать, что решил её окончательно. Просто хочу поделиться с сообществом подходом, к которому я пришёл в процессе работы над Хабром, Дару
даром, Дёрти, Трипстером и другими проектами. Надеюсь это поможет кому-то сэкономить время и нервные клетки.
Как неправильно считать счётчики
Начну с двух самых распространённых неправильных подходов к счётчикам.
Инкрементно увеличивать / уменьшать значение счётчика во всех местах где может произойти изменение (создание, редактирование, публикация, распубликация поста, удаление модератором, изменение в админке и т.д.).
А также различные комбинации этих подходов (например делать инкремент в нужных местах, а, раз в сутки, полностью пересчитывать в фоне). Почему эти подходы неправильные? Если кратко, ответ таков: я пробовал, у меня не получилось.
А как же правильно?
Наверняка, описанный в статье метод не единственный. Но я пришёл к двум важным принципам, и, ИМХО, они применимы для всех «правильных» методов:
Обновление одного счётчика должно происходить в одном месте.
Нижеследующий раздел — попытка объяснить как я к ним пришёл. Последовательно, шаг за шагом, на примере усложняющихся требований к счётчику публикаций. В объяснении я буду использовать псевдокод на Python.
В поисках формулы: от простого к сложному
Самый простой вариант. Нам нужен счётчик всех созданных постов.
Теперь введём в проект понятие «черновик», чтобы пользователь мог сохранить недописанный пост и доработать позже, как на Хабре. Счётчику же добавим условие считать не все, а только опубликованные посты.
Дальше поймём, что удалять пост из базы без возможности восстановления плохо. Вместо этого добавим флаг is_deleted . Удалённые посты, конечно, тоже не должны считаться счётчиком.
Уже довольно замороченный код… Тем не менее мы добавляем в проект мультиблоговость. У поста появляется поле blog_id , а для блога хотелось бы иметь собственный счётчик постов (естественно, опубликованных и неудалённых). При этом стоит предусмотреть возможность переноса поста из одного блога в другой. Про общий счётчик постов забудем.
Замечательно. Т.е. отвратительно! Даже не хочется думать о счётчике который считает не просто число постов в блоге, а число постов в блоге для каждого пользователя [user_id, post_id] → post_count. А они нам понадобились, например, чтобы вывести статистику в профиль пользователя.
Но давайте обратим внимание на код переноса поста из одного блога в другой. Неожиданно он оказался проще и короче. Вдобавок, он очень похож на код создания / удаления! Фактически это и происходит: удаление поста со старого блога и создание на новом. Можем ли мы применить этот же принцип для случая, когда блог остаётся прежним? Да.
Единственный минус в том, что каждый раз при сохранении поста счётчик будет дважды обновляться. В добавок, чаще всего впустую. Давайте сначала посчитаем инкремент счётчика, а потом обновим его, если нужно?
Уже намного лучше. Давайте теперь избавимся от дублирования post.is_published and not post.is_deleted , создав функцию counter_value . Пусть она возвращает 1 для поста который считается и 0 для удалённого или распубликованного.
Теперь мы готовы к тому, чтобы объединить события create/change/delete в одно. При создании/удалении вместо одного из параметров post_old / post_new просто передадим None .
Супер! А теперь вернёмся к подсчёту постов в блогах для каждого пользователя. Оказывается это теперь довольно просто.
Обратите внимание, приведённый выше код учитывает смену автора публикации, если это когда-нибудь понадобится. Так же легко добавить учёт других параметров: достаточно добавить новый ключ для increments .
Двигаемся дальше. На нашей серьёзной мультиблоговой платформе наверняка появились рейтинги публикаций. Допустим, мы хотим считать не просто число постов, а их суммарный рейтинг для каждого пользователя на каждом блоге для вывода «лучших авторов». Исправим counter_value так, чтобы он возвращал не 1/0, а рейтинг поста, если он опубликован, и 0 в остальных случаях.
Универсальная формула
Если обобщить, то вот абстрактная формула универсального счётчика:
Напоследок
Как же без ложки дёгтя! Приведённая формула идеальна, но если вынести её из сферического вакуума в жестокую реальность, то ваши счётчики всё равно могут сбиваться. Происходить это будет по двум причинам:
Перехватить все возможные сценарии изменения объектов, на практике, не простая задача. Если вы используете ORM предоставляющий сигналы создания/изменения/удаления, и вам даже удалось написать велосипед сохраняющий старое состояние объекта, то вызов raw-запроса или множественного обновления по условию всё вам испортит. Если вы напишите, например, Postgres-триггеры отслеживающие изменения и отправляющие их сразу в PGQ, то… Ну попробуйте )
Задавайте вопросы. Критикуйте. Расскажите как справляетесь со счётчиками вы.