Проектування новинної стрічки в соціальних мережах



Так склалося, що за останні пару років я встиг взяти участь в розробці декількох соціальних мереж. Головна задача, яку доводилося вирішувати в кожному з цих проектів, полягала у формуванні новинної стрічки користувачів. При чому важливою умовою була можливість масштабування цієї стрічки в умовах зростання числа користувачів (точніше, числа зв'язків між ними) і, як наслідок, — кількості контенту, який вони деливерят один одному.

Моя розповідь буде про те, як я, перемагаючи труднощі, вирішував завдання формування новинної стрічки. А також я розповім про підходи, які напрацювали хлопці з проекту Socialite, якими вони поділилися на MongoDB World.

Як формувати стрічку?
Отже, для початку абсолютно банальна інформація про те, що будь-яка новинна стрічка формується з активності користувачів, з якими ми дружимо (або яких ми фолловим/читаємо/etc). Отже, завдання формування стрічки — це завдання доставки контенту від автора його фоловерам. Стрічка, як правило, складається з абсолютно різношерстого контенту: котиків, коубов, комедійних відео, будь то текстові статусів і іншого. Поверх цього ми маємо репости, коменти, лайки, тегування користувачів на цих статусах/фоточках/відео. Отже, основні завдання, що виникає перед розробниками соціальної мережі — це:

1. Агрегація всієї активності, всього контенту, що постять користувачі. Назвемо це умовно сервісом контенту. На пост-гелловінської картинці вище, він зображений киплячим магічним котлом, який перетравлює і агрегує всілякі різні об'єкти в колекцію більш-менш однорідних документів.

2. Доставка контенту його фоловерам. Доручимо цей процес сервісу стрічки, який представлений колбочками. Таким чином, коли користувач хоче почитати свою стрічку, він йде за своєю персональною колбочкой, бере її, з нею підходить до казанка і ми наливаємо йому потрібний шматочок контенту.

Начебто простіше нікуди. Давайте більш детально розглянемо підходи в реалізації формування персональної новинної стрічки (іншими словами, доставки контенту від автора його читачам). Пару-трійку цікавих труднощів я вам гарантую.

Формуємо стрічку при читанні
Даний підхід передбачає формування стрічки на льоту. Тобто коли користувач запитує свою новинну стрічку, ми витягаємо з нашого контент сервісу записи людей, на які підписаний користувач, сортуємо їх за часом і отримуємо новинну стрічку. Ось, загалом-то, і все. Я думаю, що це найбільш очевидний і інтуїтивно-зрозумілий підхід. На схемі виглядає він приблизно так:



Коли користувач постить щось нове, процес абсолютно тривіальний: необхідно зробити всього один запис в сервіс контенту. Ця простота і недивна, т. к. доставка стрічки йде при читанні, а отже все найцікавіше саме там.

ОК, переходимо до читання стрічки. Щоб сформувати стрічку для конкретного користувача, потрібно взяти список людей, на яких він підписаний. З цим списком ми йдемо до контент сервісу і витягуємо посади цих людей. Звичайно, необов'язково брати прям всі-всі записи, як правило, можна взяти якусь частину з цього, необхідну для формування початку стрічки або наступною її частини. Але в будь-якому випадку розмір одержуваних даних буде багато більше того, що в результаті ми повернемо користувачеві. Пов'язано це з тим, що активність наших друзів абсолютно нерівномірна і заздалегідь ми не знаємо, скільки постів треба взяти від кожного з них, щоб показати потрібну частину стрічки.

Але це ще не найбільша проблема даного підходу. Очевидно, що по мірі росту мережі, найшвидше буде рости колекція контенту. І рано чи пізно настане необхідність шардировать цю колекцію. І, природно, шардирование буде відбуватися по авторам контенту (наприклад, по їх ID). Так от, самий великий мінус цього підходу полягає в тому, що наш запит буде зачіпати дуже велика кількість довільних шардов. Якщо ви звичайно не фолловіть однієї людини.

Давайте тепер стисло підведемо підсумки по доставці олени на читання.

З плюсів:
  • Простата реалізації. Саме тому такий підхід добре використовувати «по дефолту». Наприклад, для того, щоб швидко зробити працюючу демоверсію, Proof on Concept, etc.
  • Відсутність необхідності в додатковому сховищі для копій вмісту у фоловерів.
Тепер про мінуси:
  • Читання стрічки зачіпає багато шарды, що, без сумніву, позначиться на швидкості такої вибірки.
  • А це, швидше за все, потягне за собою необхідність додаткового індексування.
  • Необхідність вибирати контент з «запасом».


Формуємо стрічку при запису
Давайте підійдемо до проблеми трохи з іншого боку. Якщо для кожного користувача зберігати вже готову стрічку і оновлювати її кожен раз, коли його друзі будуть постити щось нове? Іншими словами, ми будемо робити копію кожного поста автора в «матеріалізовану» стрічку його передплатників. Цей підхід трохи менш очевидний, але нічого понад складного в ньому теж немає. Найважливіше в ньому — це знайти оптимальну модель зберігання цієї самої «матеріалізованій» стрічки у кожного користувача.



І так, що ж відбувається, коли користувач постить щось нове? Як і в попередньому випадку, пост відправляється в сервіс контенту. Але тепер ми додатково робимо копію поста в стрічку кожного передплатника (насправді, на цій картинці стрілочки, що йдуть в сервіс стрічки, повинні починатися не з поста автора, а з сервісу контенту). Таким чином, у кожного користувача формуються вже готові для читання персональні стрічки. Дуже важливо також і те, що при шардировании даних з сервісу стрічки, що будуть використовуватися ID передплатників, а не авторів (як у випадку з сервісом контенту). Відповідно тепер читати стрічку ми будемо з одного шарда і це дасть значне прискорення.

На перший погляд може здатися, що створення копій посту для кожного передплатника (особливо якщо їх десятки тисяч) може вийти досить нешвидким робимо. Але ж ми розробляємо не лайв-чат, тому зовсім не страшно, якщо процес клонування буде займати навіть кілька хвилин. Адже ми можемо робити все, цю роботу асинхронно, на відміну від читання. Тобто користувачеві зовсім не обов'язково чекати, поки його запис продублюється у стрічку кожного його передплатника.

Є ще один, куди більш відчутний недолік — це необхідність десь зберігати всі наші «матеріалізовані» стрічки. Тобто це необхідність у додатковому сторадже. І якщо у користувача є 15.000 фоловерів, то це означає, що весь його вміст буде постійно зберігатися в 15.000 тисячах копій. І це виглядає вже зовсім не круто.

І коротенько про переваги та недоліки.

З плюсів:
  • Стрічка формується читанням одного або кількох документів. Кількість документів буде залежати від обраної моделі зберігання стрічки, про трохи пізніше.
  • Легко виключати неактивних юзерів з процесу предсоздания стрічок.
Про мінуси:
  • Доставка копій великій кількості передплатників може відбуватися досить довго.
  • Необхідність у додатковому сховищі для «матеріалізованих» стрічок.


Моделі зберігання «матеріалізованих» стрічок Як ви здогадуєтеся, просто так миритися з проблемами ми не будемо, тим більше скролл ще тільки на середині статті :-) І тут нам на допомогу приходять хлопці з MongoDB Labs, які розробили цілих 3 моделі зберігання «матеріалізованих» стрічок. Кожна з цих моделей так чи інакше вирішує описані вище недоліки.

Забігаючи трохи вперед скажу, що перші дві моделі припускають зберігання персональної стрічки за весь період її існування. Тобто при цих двох підходах ми зберігаємо абсолютно всі записи, які коли-небудь потрапляли в стрічку. Таким чином, перші два підходу, на відміну від третього, не вирішують проблему «розбухання» даних. Але, з іншого боку, вони дозволяють дуже швидко віддавати користувачеві не тільки топ стрічки, але і всі її подальші частини, аж до самого кінця. Звичайно, користувачі рідко скролят стрічку в самий низ, але все залежить від конкретного проекту та вимог.

Групуємо за часом


Ця модель передбачає, що всі пости у стрічці за певний часовий інтервал (годину/день/etc.), групуються в одному документі. Такий документ хлопці з MongoDB Labs називають «бакетом». У нашій же пост-гелловінської стилістиці вони зображені колбочками:


Приклад з MongoDB Documents
{
"_id": {"user": "vasya", "time": 516935},
"timeline": [
{"_id": ObjectId("...dc1"), "author": "masha", "post": "How are you?"},
{"_id": ObjectId("...dd2"), "author": "petya", "post": "let's go drinking!"}
]
},
{
"_id": {"user": "petya", "time": 516934},
"timeline": [
{"_id": ObjectId("...dc1"), "author": "dimon", "post": "My name is Dimon."}
]
},
{
"_id": {"user": "vasya", "time": 516934},
"timeline": [
{"_id": ObjectId("...da7"), "author": "masha", "post": "Hi, Vasya!"}
]
}



Все що ми робимо, це округляємо поточний час (наприклад, беремо початок кожної години/дні), беремо ID фолловера, і upsert'ом записуємо кожен новий пост в свій бакет. Таким чином, всі пости за певний інтервал часу будуть згруповані для кожного передплатника в одному документі.

Якщо за минулий день люди, на яких ви підписані, написали 23 посту, то у вчорашньому бакете вашого користувача буде рівно 23 запису. Якщо ж, наприклад, за останні 10 днів нових постів не було, то і нові бакеты створюватися не будуть. Так що в певних випадках цей підхід буде вельми зручний.

Найголовнішим недоліком моделі є те, що створювані бакеты будуть непередбачуваного розміру. Наприклад, в п'ятницю всі постять п'ятничні коубі, і у вас в бакете буде кілька сотень записів. А на наступний день всі сплять, і в суботньому бакете буде 1-2 запису. Це погано тим, що ви не знаєте, скільки документів вам треба прочитати для того, щоб сформувати потрібну частину стрічки (навіть початок). А ще можна банально перевищити максимальний розмір документа в 16Мб.

Групуємо за розміром


Якщо непередбачуваність розміру бакетов критична для вашого проекту, тоді формувати бакеты потрібно по кількості записів в них.


Приклад з MongoDB Documents
{
"_id": ObjectId("...122"),
"user": "vasya",
"size": 3,
"timeline": [
{"_id": ObjectId("...dc1"), "author": "masha", "post": "How are you?"},
{"_id": ObjectId("...dd2"), "author": "petya", "post": "let's go drinking!"},
{"_id": ObjectId("...da7"), "author": "petya", "post": "Hi, Vasya!"}
]
},
{
"_id": ObjectId("...011"),
"user": "petya",
"size": 1,
"timeline": [
{"_id": ObjectId("...dc1"), "author": "dimon", "post": "My name is Dimon."}
]
}



Наведу приклад. Встановимо ліміт на бакет в 50 записів. Тоді перші 50 постів ми записуємо в перший бакет користувача. Коли настає черга 51-го посту, створюємо другий бакет для цього користувача, і пишемо туди цей і наступні 50 постів. І так далі. Таким нехитрим чином ми вирішили проблему з нестабільним і непередбачуваним розміром. Але така модель працює на запис приблизно в 2 рази повільніше, ніж попередня. Пов'язано це з тим, що при запису кожного нового поста необхідно перевіряти чи досягли ми встановленого ліміту або немає. І якщо досягли, то створювати новий бакет і писати в нього.

Так що з одного боку, цей підхід вирішує проблеми попереднього, а з іншого створює нові. Тому вибір моделі буде залежати від конкретних вимог вашої системи.

Кешуємо топ


І, нарешті, остання модель зберігання стрічки, яка повинна вирішити всі-всі накопичені проблеми. Або, принаймні, збалансувати їх.


Приклад з MongoDB Documents
{
"user": "vasya",
"timeline": [
{"_id": ObjectId("...dc1"), "author": "masha", "post": "How are you?"},
{"_id": ObjectId("...dd2"), "author": "petya", "post": "let's go drinking!"},
{"_id": ObjectId("...da7"), "author": "petya", "post": "Hi, Vasya!"}
]
},
{
"user": "petya",
"timeline": [
{"_id": ObjectId("...dc1"), "author": "dimon", "post": "My name is Dimon."}
]
}



Основна ідея цієї моделі полягає в тому, що ми кешуємо деяку кількість останніх постів, а не зберігаємо всю історію. Тобто, по суті, бакете буде представляти із себе capped-array, зберігає деяку кількість записів. У MongoDB (починаючи з версії 2.4) це робиться дуже просто використовуючи оператори $push і $slice.

Плюсів від такого підходу відразу декілька. По-перше, нам треба оновлювати всього один документ. При чому цей апдейт буде дуже швидкий, тому що в ньому відсутні будь-які перевірки, на відміну від попередньої моделі. По-друге, для отримання стрічки нам треба читати знову-таки лише один документ.

Далі. Якщо користувач тривалий час не заходить у наш сервіс, то ми можемо просто видалити його кеш. Таким чином, ми виключимо неактивних користувачів з процесу створення «матеріалізованих» стрічок, вивільняючи ресурси наших серверів. Якщо ж користувач неактивний раптом вирішить повернутися, скажімо через рік, ми легко створимо для нього новенький кеш. Заповнити його актуальними постами можна використовуючи fallback в просту доставку на читання.

Таким чином, дана модель є відмінним балансом між тим, щоб зберігати всі стрічку для кожного користувача і тим, щоб будувати цю стрічку на кожен запит.

Embedding vs Linking


І ще один важливий момент щодо зберігання стрічки в кеші: зберігати контент постів або тільки посилання?

Підхід із зберіганням повної копії постів прямо в бакете буде хороший, якщо контент постів буде невеликого і головне відомого розміру. В якості ідеального прикладу можна навести Twitter з його 140-символьними статусами.

У загальному ж випадку виграє другий підхід, коли ми зберігаємо ID посади і, можливо, якісь мета-дані (наприклад, ID автора, дату публікації та інше). Контент ж витягується тільки при необхідності. При чому зробити це маючи ID посту можна дуже легко і швидко.

Що, якщо я дуже ледачий?
У XXI столітті на кожного ледаря існує приблизно 100500 додатків на кожен випадок життя. Відповідно, для кожного розробника існує трохи менше ніж 100500 сервісів. Кльовий сервіс управління стрічкою живе тут.

На цьому у мене все. Хочу лише сказати про те, що срібної кулі для вирішення всіх проблем формування новинної стрічки очікувано не знайшлося. Але ми розглянули цілу купу рішень і підходів, кожен з яких буде добре працювати у своїй конкретній ситуації.

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

0 коментарів

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