Число подписчиков блога. Число опубликованных постов пользователя. Число положительных и отрицательных голосов за комментарий. Число оплаченных заказов товара. Вам приходилось считать что-то подобное? Тогда, готов поспорить, что оно у вас периодически сбивалось. Да ладно, даже у вконтакта сбивалось:
Не знаю как у вас, но в моей жизни счётчики — едва ли не первая проблема после инвалидации кеша и нейминга. Не стану утверждать, что решил её окончательно. Просто хочу поделиться с сообществом подходом, к которому я пришёл в процессе работы над Хабром, Дару~даром, Дёрти, Трипстером и другими проектами. Надеюсь это поможет кому-то сэкономить время и нервные клетки.
Как неправильно считать счётчики
Начну с двух самых распространённых неправильных подходов к счётчикам.
-
Инкрементно увеличивать / уменьшать значение счётчика во всех местах где может произойти изменение (создание, редактирование, публикация, распубликация поста, удаление модератором, изменение в админке и т.д.).
- Пересчитывать счётчик полностью при каждом изменении связанных с ним объектов.
А также различные комбинации этих подходов (например делать инкремент в нужных местах, а, раз в сутки, полностью пересчитывать в фоне). Почему эти подходы неправильные? Если кратко, ответ таков: я пробовал, у меня не получилось.
А как же правильно?
Наверняка, описанный в статье метод не единственный. Но я пришёл к двум важным принципам, и, ИМХО, они применимы для всех «правильных» методов:
-
Обновление одного счётчика должно происходить в одном месте.
- В момент обновления нужно знать о состоянии объекта до и после его изменения.
Нижеследующий раздел — попытка объяснить как я к ним пришёл. Последовательно, шаг за шагом, на примере усложняющихся требований к счётчику публикаций. В объяснении я буду использовать псевдокод на Python.
В поисках формулы: от простого к сложному
Самый простой вариант. Нам нужен счётчик всех созданных постов.
@on('create_post')
def update_posts_counter_on_post_create(post):
posts_counter.update(+1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
posts_counter.update(-1)
Теперь введём в проект понятие «черновик», чтобы пользователь мог сохранить недописанный пост и доработать позже, как на Хабре. Счётчику же добавим условие считать не все, а только опубликованные посты.
@on('create_post')
def update_posts_counter_on_post_create(post):
if post.is_published:
posts_counter.update(+1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if post.is_published:
posts_counter.update(-1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
if post_old.is_published != post_new.is_published:
# Флаг опубликованности изменился,
# теперь выясним произошла публикация или распубликация
if post_new.is_published:
posts_counter.update(+1)
else:
posts_counter.update(-1)
Дальше поймём, что удалять пост из базы без возможности восстановления плохо. Вместо этого добавим флаг is_deleted
. Удалённые посты, конечно, тоже не должны считаться счётчиком.
@on('create_post')
def update_posts_counter_on_post_create(post):
if post.is_published and not post.is_deleted:
update_posts_counter(+1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if post.is_published and not post.is_deleted:
update_posts_counter(-1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
is_published_changed = post_old.is_deleted != post_new.is_deleted
is_deleted_changed = post_old.is_deleted != post_new.is_deleted
# Публикация / распубликация
if is_published_changed and not is_deleted_changed:
if post_new.is_published:
update_posts_counter(+1)
else:
update_posts_counter(-1)
# Удаление / восстановление
if not is_deleted_changed and not is_published_changed:
if post_new.is_deleted:
update_posts_counter(-1)
else:
update_posts_counter(+1)
# Так тоже может быть, но счётчик в этом случае не изменится
if is_published_changed and is_deleted_changed:
pass
Уже довольно замороченный код… Тем не менее мы добавляем в проект мультиблоговость.
У поста появляется поле blog_id
, а для блога хотелось бы иметь собственный счётчик постов
(естественно, опубликованных и неудалённых). При этом стоит предусмотреть возможность переноса поста из одного блога в другой. Про общий счётчик постов забудем.
@on('create_post')
def update_posts_counter_on_post_create(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, +1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, -1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
# Блог поста не изменился, делаем как раньше
if post_old.blog_id == post_new.blog_id:
is_published_changed = post_old.is_deleted != post_new.is_deleted
is_deleted_changed = post_old.is_deleted != post_new.is_deleted
# Публикация / распубликация
if is_published_changed and not is_deleted_changed:
if post_new.is_published:
update_posts_counter(post_new.blog_id, +1)
else:
update_posts_counter(post_new.blog_id, -1)
# Удаление / восстановление
if not is_deleted_changed and not is_published_changed:
if post_new.is_deleted:
update_posts_counter(post_new.blog_id, -1)
else:
update_posts_counter(post_new.blog_id, +1)
# Перенос в другой блог
else:
if post_old.is_published and not post_old.is_deleted:
update_blog_post_counter(post_old.blog_id, -1)
if post_new.is_published and not post_new.is_deleted:
update_blog_post_counter(post_new.blog_id, +1)
Замечательно. Т.е. отвратительно! Даже не хочется думать о счётчике который считает не просто число постов в блоге, а число постов в блоге для каждого пользователя [user_id, post_id] → post_count. А они нам понадобились, например, чтобы вывести статистику в профиль пользователя...
Но давайте обратим внимание на код переноса поста из одного блога в другой. Неожиданно он оказался проще и короче. Вдобавок, он очень похож на код создания / удаления! Фактически это и происходит: удаление поста со старого блога и создание на новом. Можем ли мы применить этот же принцип для случая, когда блог остаётся прежним? Да.
@on('create_post')
def update_posts_counter_on_post_create(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, +1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, -1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
if post_old.is_published and not post_old.is_deleted:
update_blog_post_counter(post_old.blog_id, -1)
if post_new.is_published and not post_new.is_deleted:
update_blog_post_counter(post_new.blog_id, +1)
Единственный минус в том, что каждый раз при сохранении поста счётчик будет дважды обновляться. В добавок, чаще всего впустую. Давайте сначала посчитаем инкремент счётчика, а потом обновим его, если нужно?
@on('create_post')
def update_posts_counter_on_post_create(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, +1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if post.is_published and not post.is_deleted:
update_blog_post_counter(post.blog_id, -1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
increments = defaultdict(int)
if post_old.is_published and not post_old.is_deleted:
increments[post_old.blog_id] -= 1
if post_new.is_published and not post_new.is_deleted:
increments[post_new.blog_id] += 1
for blog_id, increment in increments.iteritems():
if increment:
update_blog_post_counter(blog_id, increment)
Уже намного лучше. Давайте теперь избавимся от дублирования post.is_published and not post.is_deleted
, создав функцию counter_value
. Пусть она возвращает 1 для поста который считается и 0 для удалённого или распубликованного.
counter_value = lambda post: int(post.is_published and not post.is_deleted)
@on('create_post')
def update_posts_counter_on_post_create(post):
if counter_value(post):
update_blog_post_counter(post.blog_id, +1)
@on('delete_post')
def update_posts_counter_on_post_delete(post):
if counter_value(post):
update_blog_post_counter(post.blog_id, -1)
@on('change_post')
def update_posts_counter_on_post_change(post_old, post_new):
increments = defaultdict(int)
increments[post_old.blog_id] -= counter_value(post_old)
increments[post_new.blog_id] += counter_value(post_new)
for blog_id, increment in increments.iteritems():
if increment:
update_blog_post_counter(blog_id, increment)
Теперь мы готовы к тому, чтобы объединить события create/change/delete в одно. При создании/удалении вместо одного из параметров post_old
/post_new
просто передадим None
.
@on('change_post')
def update_posts_counter_on_post_change(post_old=None, post_new=None):
counter_value = lambda post: int(post.is_published and not post.is_deleted)
increments = defaultdict(int)
if post_old:
increments[post_old.blog_id] -= counter_value(post_old)
if post_new:
increments[post_new.blog_id] += counter_value(post_new)
for blog_id, increment in increments.iteritems():
if increment:
update_blog_post_counter(blog_id, increment)
Супер! А теперь вернёмся к подсчёту постов в блогах для каждого пользователя. Оказывается это теперь довольно просто.
@on('change_post')
def update_posts_counter_on_post_change(post_old=None, post_new=None):
counter_value = lambda post: int(post.is_published and not post.is_deleted)
increments = defaultdict(int)
if post_old:
increments[post_old.user_id, post_old.blog_id] -= counter_value(post_old)
if post_new:
increments[post_new.user_id, post_new.blog_id] += counter_value(post_new)
for (user_id, blog_id), increment in increments.iteritems():
if increment:
update_user_blog_post_counter(user_id, blog_id, increment)
Обратите внимание, приведённый выше код учитывает смену автора публикации, если это когда-нибудь понадобится. Так же легко добавить учёт других параметров: достаточно добавить новый ключ для increments
.
Двигаемся дальше. На нашей серьёзной мультиблоговой платформе наверняка появились рейтинги публикаций. Допустим, мы хотим считать не просто число постов, а их суммарный рейтинг для каждого пользователя на каждом блоге для вывода «лучших авторов». Исправим counter_value
так, чтобы он возвращал не 1/0, а рейтинг поста, если он опубликован, и 0 в остальных случаях.
@on('change_post')
def update_posts_counter_on_post_change(post_old=None, post_new=None):
counter_value = lambda post: post.rating if (post.is_published and not post.is_deleted) else 0
increments = defaultdict(int)
if post_old:
increments[post_old.user_id, post_old.blog_id] -= counter_value(post_old)
if post_new:
increments[post_new.user_id, post_new.blog_id] += counter_value(post_new)
for (user_id, blog_id), increment in increments.iteritems():
if increment:
update_user_blog_post_counter(user_id, blog_id, increment)
Универсальная формула
Если обобщить, то вот абстрактная формула универсального счётчика:
@on('change_obj')
def update_some_counter(obj_old=None, obj_new=None):
counter_key = lambda obj: ...
counter_value = lambda obj: ...
if obj_old:
increments[counter_key(obj_old)] -= counter_value(obj_old)
if obj_new:
increments[counter_key(obj_new)] += counter_value(obj_new)
for counter_key, increment in increments.iteritems():
if increment:
update_counter(counter_key, increment)
Напоследок
Как же без ложки дёгтя! Приведённая формула идеальна, но если вынести её из сферического вакуума в жестокую реальность, то ваши счётчики всё равно могут сбиваться. Происходить это будет по двум причинам:
-
Перехватить все возможные сценарии изменения объектов, на практике, не простая задача. Если вы используете ORM предоставляющий сигналы создания/изменения/удаления, и вам даже удалось написать велосипед сохраняющий старое состояние объекта, то вызов raw-запроса или множественного обновления по условию всё вам испортит. Если вы напишите, например, Postgres-триггеры отслеживающие изменения и отправляющие их сразу в PGQ, то… Ну попробуйте )
- Соблюсти атомарность обновления счётчика в условиях высокой конкурентности тоже бывает не так просто.
Задавайте вопросы. Критикуйте. Расскажите как справляетесь со счётчиками вы.
Автор: ur001