Як рахувати лічильники і не збитися з рахунку


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

Не знаю як у вас, але в моєму житті лічильники — чи не перша проблема після инвалидации кеша і неймінгу. Не стану стверджувати, що вирішив її остаточно. Просто хочу поділитися з співтовариством підходом, до якого я прийшов у процесі роботи над Хабром, Дару~дар, Дерті, Трипстером та іншими проектами. Сподіваюся це допоможе комусь заощадити час і нервові клітини.
Як неправильно вважати лічильники
Почну з двох найпоширеніших неправильних підходів до лічильників.
  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. Дотримати атомарність оновлення лічильника в умовах високої конкурентності теж буває не так просто.
Задавайте питання. Критикуйте. Розкажіть як справляєтеся з лічильниками ви.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.