Pull to refresh

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

Reading time 8 min
Views 15K

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



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


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


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


  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. Соблюсти атомарность обновления счётчика в условиях высокой конкурентности тоже бывает не так просто.

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

Only registered users can participate in poll. Log in, please.
Как устроены счётчики на вашем текущем проекте?
20.99% Обновление счётчиков хаотично разбросано по коду 17
28.4% За подсчёт одного счётчика ответственен один кусок кода 23
27.16% Счётчики обновляются инкрементно 22
33.33% Счётчики пересчитываются полностью 27
23.46% Инкрементное обновление совмещено с периодическим полным пересчётом 19
13.58% Обновление счётчиков происходит на основе сравнения старого и нового состояния объекта 11
22.22% Для атомарного обновления используется UPDATE field=field+increment 18
9.88% Для атомарного обновления используется блокировка записи (типа SELECT FOR UPDATE) 8
25.93% Счётчики хранятся в отдельной таблице или отельной бд (например Redis) 21
20.99% Для подсчёта счётчиков используются ORM-события 17
16.05% Для работы со счётчиками используются триггеры БД 13
8.64% У меня всё по-другому и я расскажу об этом в комментариях 7
81 users voted. 90 users abstained.
Tags:
Hubs:
+24
Comments 48
Comments Comments 48

Articles