Как считать счётчики и не сбиться со счёта

Счётчик постов


Число подписчиков блога. Число опубликованных постов пользователя. Число положительных и отрицательных голосов за комментарий. Число оплаченных заказов товара. Вам приходилось считать что-то подобное? Тогда, готов поспорить, что оно у вас периодически сбивалось. Да ладно, даже у вконтакта сбивалось:


75a0b8490b224ac29bd34c27e02fe3ab.jpg


Не знаю как у вас, но в моей жизни счётчики — едва ли не первая проблема после инвалидации кеша и нейминга. Не стану утверждать, что решил её окончательно. Просто хочу поделиться с сообществом подходом, к которому я пришёл в процессе работы над Хабром, Дару~даром, Дёрти, Трипстером и другими проектами. Надеюсь это поможет кому-то сэкономить время и нервные клетки.


Как неправильно считать счётчики


Начну с двух самых распространённых неправильных подходов к счётчикам.


  1. Инкрементно увеличивать / уменьшать значение счётчика во всех местах где может произойти изменение (создание, редактирование, публикация, распубликация поста, удаление модератором, изменение в админке и т.д.).
  2. Пересчитывать счётчик полностью при каждом изменении связанных с ним объектов.

А также различные комбинации этих подходов (например делать инкремент в нужных местах, а, раз в сутки, полностью пересчитывать в фоне). Почему эти подходы неправильные? Если кратко, ответ таков: я пробовал, у меня не получилось.


А как же правильно?


Наверняка, описанный в статье метод не единственный. Но я пришёл к двум важным принципам, и, ИМХО, они применимы для всех «правильных» методов:


  1. Обновление одного счётчика должно происходить в одном месте.
  2. В момент обновления нужно знать о состоянии объекта до и после его изменения.

Нижеследующий раздел — попытка объяснить как я к ним пришёл. Последовательно, шаг за шагом, на примере усложняющихся требований к счётчику публикаций. В объяснении я буду использовать псевдокод на 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)

Напоследок


Как же без ложки дёгтя! Приведённая формула идеальна, но если вынести её из сферического вакуума в жестокую реальность, то ваши счётчики всё равно могут сбиваться. Происходить это будет по двум причинам:


  1. Перехватить все возможные сценарии изменения объектов, на практике, не простая задача. Если вы используете ORM предоставляющий сигналы создания/изменения/удаления, и вам даже удалось написать велосипед сохраняющий старое состояние объекта, то вызов raw-запроса или множественного обновления по условию всё вам испортит. Если вы напишите, например, Postgres-триггеры отслеживающие изменения и отправляющие их сразу в PGQ, то… Ну попробуйте:)
  2. Соблюсти атомарность обновления счётчика в условиях высокой конкурентности тоже бывает не так просто.

Задавайте вопросы. Критикуйте. Расскажите как справляетесь со счётчиками вы :)

Комментарии (1)

  • 30 сентября 2016 в 16:12

    0

    По картинке думал будет инструкция по настоящим счётчикам как на картинке, а тут очередная фигня про веб:(

© Habrahabr.ru