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



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


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


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


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

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

Как устроены счётчики на вашем текущем проекте?

Проголосовало 75 человек. Воздержалось 87 человек.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться с друзьями
-->

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


  1. alltiptop
    30.09.2016 16:12

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


    1. izzholtik
      30.09.2016 16:20

      Вам туда: https://habrahabr.ru/post/220869/


    1. ur001
      30.09.2016 16:28
      +4

      На картинке счётчик постов, если приглядеться :)


  1. x893
    30.09.2016 16:42
    +1

    Когда то делал подобное под Telligent Community. Было проще и без заумных рассуждений.


    1. ur001
      30.09.2016 16:50
      +2

      А расскажете? Как было, что было проще? И да, какие из моих рассуждений показались вам заумными, может я переформулирую? :)


      1. x893
        30.09.2016 18:03
        -2

        Да рассказывать особо нечего. Заумные — в смысле сложные.


        1. ur001
          30.09.2016 18:22
          +1

          Ну так я же старался проще :) Подскажите что кажется сложным. Я надеялся, что самым понятным будет код, кроме которого можно особо ничего и не читать


  1. ionicman
    30.09.2016 17:13
    +2

    Хм… Вроде как никогда не считал это проблемой. В большинстве случаев точность этих данных не сильно важна.

    У нас в двигле за счетчики ответственен один конфигурируемый класс (в конфигурации задаются имена и пределы счетчиков) ничего заумного и хитрых формул. БД — PostgreSQL.

    Например счетчик постов устроен так — при опубликовании поста идет вызов из этого класса фии increment( counterName, +1), при скрытии increment( counterName, -1 ). В случае попытки декриментировать/инкрементировать меньше/больше предела возвращает false и не трогает счетчик. Счетчик просто хранится в ячейке таблицы БД счетчиков. Это просто ячейка — ничего более. Консистентность не проверяется.

    Обычно работа со счетчиками идет в коде. Однако есть БД-шная часть с точно таким-же функционалом — можно на триггеры навешивать.

    В том-же классе есть спец. функция indexing — она пересчитывает все известные и зарегистрированные в классе счетчики по заложенным в конфигурацию алгоритмам. На момент работы блочит таблицу счетчиков на запись.

    P.S. Есть также возможность ленивых счетчиков (используется редко) — это когда просто отдаются данные и раз в какое-то время вызывается функция конкретно их перерасчета. Тоже работает норм.

    P.P.S. indexing умеет перерассчитывать только один или несколько конкретных счетчиков, а не только все.


    1. ur001
      30.09.2016 17:38

      Хм… Не до конца понял. В одном из проектов я тоже делал возможность пересчёта одного или нескольких счётчиков. Счётчики задаются декларативными правилами типа:


      class Experience(models.Model):
          review_count = models.PositiveSmallIntegerField(u"Число отзывов", default=0, db_index=True)
          review_rated_count = models.PositiveSmallIntegerField(u"Число отзывов с оценками", default=0)
          review_rating_sum = models.FloatField(u"Сумма оценок", default=0)
      
          class Counters(Counters):
              review_count = Counter('Review.experience', lambda review: review.published)
      
              review_rated_count = Counter(
                  'Review.experience', 
                  lambda review: review.published and bool(review.rate)
              )
      
              review_rating_sum = Counter(
                  'Review.experience',
                  lambda review: review.rate if review.published and review.rate is not None else 0
              )
      
      class Review(models.Model):
              experience = models.ForeignKey(Experience, related_name='reviews')

      Т.е. тут тоже обычные счётчики даже не в отдельной таблице, а прямо в основных моделях.


      В вашем случае increment( counterName, ±1) вызывается вручную в методах публикации/распубликации или это происходит автоматически на основе конфигурации? Как примерно устроена конфигурация? Как работает пересчёт?


      1. ionicman
        30.09.2016 20:31
        +1

        Может как вызываться вручную, так и быть повешен на триггеры.

        Каждый счетчик в конфигурации это: имя, начальное значение и ф-я перерасчета.

        Например перерасчет счетчика постов будет чем-то аля:
        counter.value = $DB->selectCell( «select count(*) from `posts` where `show`='1'» ) в MySQL нотации.

        Если счетчик сложный, но при этом укладывается в язык DB (обычно так и есть) — то обычно пишется хранимка, которая потом вызывается. Ну и, если индексация делается на уровне БД — то это всегда хранимка.


        1. ur001
          30.09.2016 20:49

          А понял, спасибо


        1. ionicman
          30.09.2016 20:51

          Торопился :) Поправляюсь:

          Каждый счетчик в конфигурации это: имя, начальное значение, мин (или отсутствие его), макс (или отсутствие его) и ф-я перерасчета.


  1. kristoferrobin
    30.09.2016 18:45
    +1

    у нас вся работа построена через очереди, соответственно в каждый месседж в очереди просто добавлено какой счетчик оно меняет и как, например {«counter»:«payments_mts_123», «value»:-1} соответственно что бы ни происходило, это отражается на счетчиках. ну и у нас финансовые транзакции и показатели, соответственно там и проще и сложнее


    1. ur001
      30.09.2016 19:00

      У нас тоже финансовые операции присутствуют, и, некоторые напрямую зависят от счётчиков. По этому когда я пришёл на проект и обнаружил MyISAM, первым делом бросился переводить на InnoDB и заворачивать в транзакции. Но к тому моменту накопилось столько мусорного кода, что банально не найдены и необеззаражены все места где что-то типа


      order = Order.objects.get(pk=order_id)
      order.status = Order.STATUS_PENDING_PAYMENT
      order.save()

      Т.е. без транзакций и SELECT FOR UPDATE, без, хотя бы save(update_fields=['status']) и прочее. И, конечно, это ломает счётчики.


      А кто у вас добавляет в очередь пометку о необходимости обновить счётчик?


      1. kristoferrobin
        05.10.2016 12:11

        тот, кому можно работать с очередью на запись. просто в очередь встает новая запись и все, выполняется последовательно соответственно изменения применяются атомарно и все путем


  1. sbnur
    30.09.2016 19:02
    +1

    хорошая картинка — напоминает о том, что любой счетчик выводится из рабочего (правильного) состояния дополнительной нагрузкой
    Например, в южных сранах в электросчетчике делается маленькое отверстие, куда капается сироп. Сквозь дырочку в счетчик попадают фараоновы муравьи и забивают весь механизм
    В результтате, ничего не работает, несмотря на хитрую механику


  1. olegchir
    30.09.2016 20:03
    +2

    Пользуясь случаем, спрошу:
    У меня на Хабре постоянно отображается рейтинг -0.8, который упал до этой отметки однажды с чего-то в районе 60.
    С тех пор этот рейтинг никуда не двигается, независимо от полученных плюсов и минусов за комментарии, хотя раньше эта цифра плясала постоянно.
    Я писал в поддержку, но ответа не получил.
    Есть ли какой-нибудь хак, как на Хабре можно пересчитать счетчкики?
    Спасибо.


    1. ur001
      30.09.2016 20:48

      Не, не могу помочь. На Хабре я давно не работаю


  1. kilonet
    01.10.2016 11:00

    не хранить значения счетчиков, а вычислять, результаты вычисления кэшировать — чем такой вариант не подходит?


    1. ur001
      01.10.2016 12:23

      Иногда такой подход оправдан. Но для некоторых вещей просто неприемлем. Например, из Трипстера: запись на экскурсию с ограниченным числом участников. После того, как максимальное число людей оплатило, регистрация на событие закрывается, неоплаченные заказы отменяются, всем участникам и гиду приходят уведомления.


      Второй вариант — значения счётчиков вам постоянно нужны для выборок.


  1. AlexanderY
    01.10.2016 11:49

    Можно поподробнее, почему неправильно «Пересчитывать счётчик полностью при каждом изменении связанных с ним объектов»? Может, я неправильно понял, что имелось в виду. Зачем вообще инкрементировать счетчик в коде? Обычно это что-то вроде «SELECT COUNT(*) WHERE isDeleted == false AND isDraft == false». Добавьте своих условий, оберните в любимую ORM и готово.

    Если дело в нагрузке — ну, вы же используете какие-нибудь виды кэша? memcache, например. Фактически, задача сводится к задаче инвалидации кэша.


    1. ur001
      01.10.2016 12:02

      Да, я имел в виду именно это. И да, дело, по большей части, в дороговизне полного пересчёта на каждое изменение. Никакое кеширование, тут помочь не может — что именно вы будете кешировать? :)


      1. AlexanderY
        01.10.2016 12:15

        Кешировать именно значение счетчика.
        0. При первом запросе страницы посчитали (SELECT COUNT...), положили в кэш.
        1. При последующих запросах значение счетчика берется их кэша.
        2. Что-то изменилось — удалили из кэша сохраненное значение.
        3. GOTO 0.

        Прогнозирую, что при слишком частых изменениях данные в кэше надолго не будут задерживаться, но тут зависит от частного случая. Если речь о такой нагрузке и счетчике с шестью нулями, то можно не удалять кэш после каждого изменения, а просто устанавливать время его «жизни», скажем, на 5-10 минут. Не суть важно, что написано на странице: 352874 комментария или 352875 комментариев.


        1. ur001
          01.10.2016 12:28

          А, понятно. Я сначала не понял, что вы про хранение счётчиков только в кеше. Я тут ответил.


  1. HarpyWar
    01.10.2016 14:48
    +1

    Можно добавить триггеры в БД на события insert и delete, с увеличением и уменьшением значения поля счетчика. Возможно, ещё и на update, если того требует логика (как пример с постами-черновиками). Так счетчик всегда будет актуальным.


    1. ur001
      01.10.2016 16:04

      Да, только ели счётчиков много полностью на триггерах их довольно тяжело поддерживать. Добавление новых, переименование или изменение условий на существующих счётчиках, миграция структуры бд и т.п. будет каждый раз головной болью.


      Идея ловить тригерами изменения и отправлять на обработку в очередь на той же БД (PGQ) мне, в принципе понравилась. Этот подход позволял отлавливать только изменения, при этом писать логику их обработки на нормальном питоне. Но сама PGQ по сравнению с Celery отвратительна — это раз. Триггеры отлавливающие изменения приходилось обновлять вместе с миграцией БД, короче не слишком приятно выходило. Но потенциал в этом подходе есть.


  1. JuriyOgijenko
    01.10.2016 20:24

    Вообще не так все. Для правильного подсчета счетчиков надо использовать очередь событий (для этого kafka можно взять) в которую писать лайк или анлайк. На очереди висит consumer(s) который пересчитывает в кеш количество лайков.


    1. ur001
      01.10.2016 21:02

      А чем очередь помогает? Считать в очереди или не нет это вопрос нужен ли вам синхронный счётчик или можно иметь значение с задержкой. В некоторых случаях при отказе в обновлении счётчика нужно откатить транзакцию оменить всё действие. Иногда это вопрос скорости отклика, если на какое-то действие (лайк, к примеру) обновляется слишком много счётчиков и это приведёт к ощутимой для пользователя задержке при синхронном обновлении, то нужно перенести часть рассчётов в очередь.


      Допустим у вас в профиле список хабов, в которые вы внесли максимальный вклад. Это запрос по таблице счётчиков [hub_id, user_id] --> rating с сортировкой по убыванию рейтинга. Рейтинг на хабе это, к примеру, сумма рейтингов опубликованных и неудалённых постов пользователя на хабе. Что вы будете класть в очередь? Как на основе этого будете обновлять счётчик? Чем поможет в этом случае кеш?


      1. ggrnd0
        01.10.2016 21:49

        А чем очередь помогает?

        Как минимум она позволяет отложить обновление счетчика на завтра, а еще…
        Увеличивает доступность сервиса, так как ресурсоемкие вычисления производятся последовательно, а не параллельно.
        Увеличивается отзывчивость интерфейса, так как пользователь не ждет обновления счетчика.


        Вообще все это написано в любой книжке по очередям или ESB.


        Что вы будете класть в очередь?

        В случае изменения рейтинга/удаления/отправки в черновик в очередь отправится id поста.


        Как на основе этого будете обновлять счётчик?

        Когда worker дойдет до идентификатора, произойдет перерасчет рейтинга пользователя/вклада в хаб/счетчика суммарно сожженных пользователями хабра калорий.


        Если прилетит НЛО, достаточно отправить id пользователя в другую очередь.
        Обработка очереди соответствующая.


        Чем поможет в этом случае кеш?

        кешем будет


        таблице счётчиков [hub_id, user_id] --> rating с сортировкой по убыванию рейтинга.


        1. ur001
          01.10.2016 22:32

          Увеличивает доступность сервиса, так как ресурсоемкие вычисления производятся последовательно, а не параллельно.
          Увеличивается отзывчивость интерфейса, так как пользователь не ждет обновления счетчика.

          Ну тут мы кажется сошлись во мнениях, я в комментарии написал то же самое.


          В случае изменения рейтинга/удаления/отправки в черновик в очередь отправится id поста.

          Ну допустим пользователь отредактировал пост сменив хаб и скрыв в черновики. Как мы узнаем что хаб был сменён (а значит нужно вычесть рейтинг поста из рейтинга в старом хабе) и что пост не был в черновиках до этого?


          кешем будет таблице счётчиков [hub_id, user_id] --> rating с сортировкой по убыванию рейтинга.

          Ну если я потом смогу по этому кешу сделать запрос


          SELECT hub_id, rating
          FROM user_hub_rating
          ORDER BY rating DESC
          LIMIT 10

          То у нас просто разное понимание терминологии что считать кешом.


          1. ggrnd0
            01.10.2016 22:47

            Ну допустим пользователь отредактировал пост сменив хаб и скрыв в черновики. Как мы узнаем что хаб был сменён (а значит нужно вычесть рейтинг поста из рейтинга в старом хабе) и что пост не был в черновиках до этого?

            Например, можно в changelog посмотреть наборы старых/новых значений полей и не пересчитывать счетчик если существенно ничего не поменялось.
            Сам changelog можно как хранить в БД, так и передавать набор изменений в очередь вместе с id.


            То у нас просто разное понимание терминологии что считать кешом.

            кеш — это термин, он не накладывает каких либо ограничений/требований к реализации.
            Никто не заставляет хранить кеши в памяти, не сбрасывая их на диск/в таблицу БД.
            Более того, многие распределенные кеши поддерживают sql-подобные запросы.


            1. ur001
              01.10.2016 23:40

              Ок. Нам в очередь пришёл id поста и мы посмотрели по changelog-у, или получили сразу в виде параметров помимо post_id следующие поля: old_hub_id, old_is_published, old_is_deleted, old_user_id, и из базы (которая к этому моменту кстати могла опять измениться) или как-то ещё вычислили актуальные на момент срабатывания счётчика hub_id, is_published, is_deleted, user_id. Да, может это звучит необычно, но на dirty реализована передача черновика другому пользователю, по этому предположим что автор тоже может измениться. Какой вы напишите обработчик для обновления вашего «кеша» рейтинга пользователя на хабе?


              1. ggrnd0
                02.10.2016 00:01

                И не забываем о коллективном авторстве (вроде было такое на хабре?)...


                Вот посчитал ты пары
                (old_hab_id, old_user_id) и (new_hub_id, new_user_id)
                и по ним и обновляешь свой кеш


                [hub_id, user_id] --> rating

                обновятся не более 2 строк.


                Не смотря на перерасчет рейтинга 1-2 пользователей/хабов, такой перерасчет будет проще и безопаснее расчета дельты рейтинга и ее суммирования с текущим рейтингом.


                Так как мы можем использовать агрегатор в очереди сообщений, то вместо 100500 обновлений рейтинга на каждый +1/-1 к посту, фактически будет сделано меньшее количество обновлений рейтинга, что сгладит нагрузку при перерасчете рейтинга.


                Как обмануть пользователя ответил ниже


                1. ur001
                  02.10.2016 00:22

                  Если схлопывать несколько обновлений в один, то готов с натяжкой согласиться. Тем не менее, два SELECT SUM() имеет сложность O(N1) + O(N2), где N1 и N2 число постов в старом и новом хабе (это при наличии индексов, при отсутствии это число всех постов на Хабре). Т.е. SQL, каким бы волшебным он не казался, честно пробежится по всем постам хаба и просуммирует рейтинг. Если схлопывания, про который вы писали, нет (а это иногда ограничение бизнес-требований), при большой соц. сети с кучей показателей, то полный пересчёт на производительности скажется драматически.


                  1. ggrnd0
                    02.10.2016 00:44

                    где N1 и N2 число постов в старом и новом хабе (это при наличии индексов, при отсутствии это число всех постов на Хабре)

                    Не забываем про фильтр по пользователю. Если пользователь не гиперпроизводительный расчет будет близок к O(1), и не превысит O(sqrt(N1)+sqrt(N2)) — оценка достаточна точна для хабов с >1000 постов.


                    Без индексов в принципе жить тяжело. Просто не надо так делать…
                    Либо поможет Lucene/ElasticSearch/что-нибудь еще — но это тоже будет своеобразный индекс.


                    при большой соц. сети с кучей показателей

                    При сверхвысоких нагрузках даже (очередь сообщений+агрегатор+распределенный кеш) будет мало, и останется последний вариант в дополнение


                    image


                    Хотя бы потому, что с таким парком серверов каждый день будут происходить различные поломки.
                    Да даже повреждения памяти высокоэнергетической космической частицей не удастся избежать.


                    1. ur001
                      02.10.2016 01:00

                      Рейтинг пользователя на хабе скорее всего да, не вызовет большого перебора, немного ступил. Но одновременно с ним нужно пересчитывать ещё рейтинг хаба. Там будет перебор всех постов хаба. Но мне всё же странно, почему вы наставиваете на полном пересчёте вместо инкрементного обновления, когда оно намного производительнее. Вам не верится что инкрементное обновление может не сбиться? :) Думаете что 1+1+1 в какой-то момент может стать 2 или 4, если повторять эту операцию много раз?


                      1. ggrnd0
                        02.10.2016 01:09

                        1+1+1 наврядли выдаст 4, скорее 2.


                        Что делать если при обновлении рейтинга произойдет ошибка?
                        Откатывать всю транзакцию и пользователь увидит подобное сообщение?


                        Произошла ошибка. Ваш голос не был учтен. Попробуй еще раз.
                        Если ошибка повторится попробуйте позже.

                        Но одновременно с ним нужно пересчитывать ещё рейтинг хаба.

                        Обновление рейтинга хаба, можно вызывать реже.


                        1. ggrnd0
                          02.10.2016 01:27

                          Приведу пример, не счетчик.


                          Задача Создание тикета в Каяко по звонку call-центра


                          Проблема:


                          При создании тикета/комментария в Каяко отправляется email-оповещение.
                          Так как оно отправляется синхронно, то при недоступности SMTP-сервера, будет выброшен Exception.
                          Сам тикет будет создан, но его id ты уже не получишь.

                          Если бы отправка email происходила в фоне, такой проблемы не было бы.
                          Либо если бы была нормальная обработка Exception...


                          При этом откат транзакции по объективным причинам будет неправильным решением.
                          Так как пользователь может не получить сообщения об ошибке, если например тикет создан по входящему email-письму...


                          1. ur001
                            02.10.2016 01:50

                            Логично что отправка письма в фоне с повтором при неудаче, т.к. уведомление тут вторично. Во всех проектах отправка email/sms/push и прочих уведомлений делается так. С утвердительным ответом от платёжной системы наоборот, т.к. оплата важнее. А к чему это вы?


                            1. ggrnd0
                              02.10.2016 02:14

                              С утвердительным ответом от платёжной системы наоборот, т.к. оплата важнее.

                              А вот и нет, транзакция между банками может висеть днями.
                              И тут важно не быстрее все прогнать, а ничего не упустить.
                              И очередь сообщений, а скорее полноценная ESB как нельзя кстати.


                              А к чему это вы?

                              Это пример постобработки действия пользователя. Обновление счетчиков тоже постобработка и так же может иметь нетривиальную логику и условия выполнения.


                          1. JuriyOgijenko
                            02.10.2016 21:31

                            Так как оно отправляется синхронно

                            А почему синхронно?

                            Ведь для создания тикета отправка письма это side effect. А как вообще вы ошибки хендлите? Как сделать еще N попыток отправки. У вас же в любой момент времени может отвалиться любая часть приложения/инфраструктуры. Как вы вообще достигаете HA?


                            1. ggrnd0
                              02.10.2016 21:49

                              А почему синхронно?

                              Ведь для создания тикета отправка письма это side effect. А как вообще вы ошибки хендлите? Как сделать еще N попыток отправки.

                              Это не мой код, это Kayako.


                              У вас же в любой момент времени может отвалиться любая часть приложения/инфраструктуры. Как вы вообще достигаете HA?

                              в данном случае никак.
                              Оператор ищет тикет в каяке и привязывает его к звонку.


                        1. ur001
                          02.10.2016 01:58

                          Откатывать транзакцию или нет — зависит от бизнес-требований. Можем ли мы совершить действие если обновлние сётчика не гарантировано? Можем ли мы позволить себе иметь задержку при рассчёте счётчика? Можем ли мы не менять значение счётчика, если было подряд +1 и -1, или, например нам важно получить рейтинг 100, а потом обратно 99, т.к. при достижении 100 срабатывает триггер и пост становится золотым. Очереди — это замечательно, очень удобно и, во многих случаях незаменимо. Они просто не имеют, ИМХО, прямого отношения к теме топика.


                          1. ggrnd0
                            02.10.2016 02:04

                            Ну например есть опрос:


                            Как устроены счётчики на вашем текущем проекте?
                            о У меня всё по-другому и я расскажу об этом в комментариях

                            Так что упоминание очереди вполне по теме.
                            Сам диалог начался с ваших вопросов:


                            А чем очередь помогает? Что вы будете класть в очередь? Как на основе этого будете обновлять счётчик? Чем поможет в этом случае кеш?


                            1. ur001
                              02.10.2016 02:16

                              Ну хорошо :) Вы используете очередь. В очереди пересчитываете счётчик полностью, т.к. инкрементное обновление считаете ненадёжным. Благодаря «схлопыванию» удаётся снизить накладные расходы, т.к. «при реактивном изменении счетчика (100500 хомячков в секунду) расчет производится только 1 раз на over 9000 фактических изменений». Я правильно вас понял?


                              1. ggrnd0
                                02.10.2016 02:23

                                Абсолютно.


          1. JuriyOgijenko
            02.10.2016 21:27

            Ну допустим пользователь отредактировал пост сменив хаб и скрыв в черновики. Как мы узнаем что хаб был сменён (а значит нужно вычесть рейтинг поста из рейтинга в старом хабе) и что пост не был в черновиках до этого?


            Вообще для таких случаев используется CRDT. Вам просто в очередь надо положить unpublish событие. Ну и в очередь обычо складываются не только post_id, а и пачка мета информации.


    1. ggrnd0
      01.10.2016 21:23
      +1

      В дополнение скажу.
      В случае когда вычисление факта изменения счетчика не тривиально, частота обновления зашкаливает, и/или высокая точность отображения счетчика не требуется:
      1) о фактах изменения сущности по которой агрегируется счетчик писать в очередь id сущности.
      2) в очереди поставить агрегатор, который будет собирать уникальные id в течение некоторого времени — пусть будет 1 минута.
      3) по истечении таймера собранный массив id отправлять на перерасчет счетчика


      Сам счетчик хранится как пожелает разработчик: поле сущности, спец таблица, распределнный кеш.


      Если же счетчик нужен для валидации некоторого действия всегда следует вычислять данный счетчик.
      Если возможны конкурентные операции требующие валидации и изменения счетчика, следует организовать последовательное исполнение этих операций, например используя очередь сообщений и 1го потребителя (возможно разделение на несколько потоков по хешу, исключающему параллельное изменение одного и того же счетчика).


      Преимущества следующие:
      1) при реактивном изменении счетчика (100500 хомячков в секунду) расчет производится только 1 раз на over 9000 фактических изменений.
      Пользователю же на интерфейсе можно просто показать изменение (#like) -> (#like+1).
      Но в случае высокой частоты обновления счетчика (#like в facebook/vk) достаточно отобразить текущее значение счетчика, так как у пользователя счетчик уже на 100% устарел.
      2) если счетчик не менялся, пересчитываться он не будет.


      Естественно эффективно для больших проектов с высокой частотой обновлений.
      Для Facebook, VKontakte, Habr, SO подходит.
      Для микроблога с <50 комментариев в сутки избыточно.