پست

طراحی ویرایشگر اسناد مشارکتی مانند Google Docs

بررسی گام‌به‌گام طراحی ویرایشگر مشارکتی بلادرنگ شامل ویرایش هم‌زمان با Operational Transformation، مقایسه با CRDT، پخش تغییرات و حضور کاربران با WebSocket، مقیاس‌دهی با Consistent Hashing و فشرده‌سازی عملیات

طراحی ویرایشگر اسناد مشارکتی مانند Google Docs

درک مسئله

Google Docs یک ویرایشگر اسناد مشارکتی مبتنی بر مرورگر است. کاربران می‌توانند اسناد متن غنی بسازند و با دیگران بلادرنگ همکاری کنند.

در این تحلیل سیستمی طراحی می‌کنیم که از عملکرد اصلی Google Docs پشتیبانی می‌کند و در حوزه WebSocket ها و سیستم‌های ویرایش مشارکتی قرار می‌گیرد. مثل مصاحبه واقعی با نیازمندی‌ها شروع می‌کنیم و بعد با پیروی از چارچوب تحویل (Delivery Framework) طراحی را کامل می‌کنیم.

نیازمندی‌های عملکردی

نیازمندی‌های اصلی:

  • کاربران بتوانند سند جدید بسازند.
  • چند کاربر بتوانند هم‌زمان یک سند را ویرایش کنند.
  • کاربران بتوانند تغییرات یکدیگر را بلادرنگ ببینند.
  • کاربران بتوانند موقعیت Cursor و حضور کاربران دیگر را ببینند.

موارد خارج از محدوده:

  • ساختار پیچیده سند (فرض می‌کنیم یک ویرایشگر متن ساده است).
  • مجوزها و سطوح همکاری (مثلاً چه کسانی به سند دسترسی دارند).
  • تاریخچه سند و نسخه‌بندی

نیازمندی‌های غیرعملکردی

نیازمندی‌های اصلی:

  • اسناد باید در نهایت سازگار (Eventually Consistent) باشند، یعنی همه کاربران باید در نهایت وضعیت یکسانی از سند ببینند.
  • به‌روزرسانی‌ها باید Latency (تأخیر) کم داشته باشند (کمتر از 100 میلی‌ثانیه).
  • سیستم باید برای میلیون‌ها کاربر هم‌زمان روی میلیاردها سند مقیاس بگیرد.
  • هر سند بیش از 100 ویرایشگر هم‌زمان ندارد.
  • اسناد باید بادوام (Durable) و در دسترس باشند، حتی در صورت راه‌اندازی مجدد سرور.

بعضی نیازمندی‌های غیرعملکردی کار شما را آسان‌تر می‌کنند! در این مورد، محدود کردن تعداد ویرایشگرهای هم‌زمان هر سند یک محدودیت بزرگ است. یعنی می‌توانیم از نگرانی درباره Throughput (نرخ گذردهی داده) عظیم روی یک سند دوری کنیم و به‌جای آن روی عملکرد اصلی تمرکز کنیم. برای آنچه ارزش دارد، Google Docs هم همین انتخاب را کرده است: بعد از تعداد مشخصی کاربر هم‌زمان، همه افراد جدید فقط به‌عنوان خواننده می‌پیوندند (که درباره انتخاب‌های معماری آن‌ها چیزهایی می‌گوید).

آماده‌سازی

برنامه‌ریزی رویکرد

قبل از شروع طراحی سیستم باید کوتاه درباره رویکرد فکر کنیم. برای این مسئله با طراحی سیستمی شروع می‌کنیم که ساده از نیازمندی‌های عملکردی پشتیبانی کند، بدون نگرانی زیاد درباره مقیاس یا نیازمندی‌های غیرعملکردی. بعد در بررسی‌های عمیق، این نگرانی‌ها را یکی‌یکی برمی‌گردانیم. برای این کار با فهرست کردن «اسم‌های» سیستم شروع می‌کنیم، API می‌سازیم و بعد شروع به کشیدن جعبه‌ها می‌کنیم.

تعریف موجودیت‌های اصلی

اول چند موجودیت اصلی را تعریف می‌کنیم. این‌ها به تعریف اصطلاحات بین شما و مصاحبه‌کننده کمک می‌کنند، داده اصلی طراحی را روشن می‌کنند و سرنخی از API ها و نحوه جور شدن سیستم می‌دهند. ساده‌ترین راه این است که به نیازمندی‌های عملکردی نگاه کنید و فکر کنید کدام اسم‌ها در برآورده کردن آن‌ها نقش دارند. پرسیدن سؤال‌های عمیق‌تر از خودتان درباره رفتار سیستم («چه می‌شود وقتی…») به کشف موجودیت‌های مهم طراحی کمک می‌کند.

برای این مسئله فقط به چند موجودیت نیاز داریم:

  • Editor (ویرایشگر): کاربری که سند را ویرایش می‌کند.
  • Document (سند): مجموعه‌ای از متن که توسط ویرایشگرها مدیریت می‌شود.
  • Edit (ویرایش): تغییری که توسط ویرایشگر در سند ایجاد می‌شود.
  • Cursor (نشانگر): موقعیت Cursor ویرایشگر در سند (و حضور آن کاربر در سند).

تعریف API

در مرحله بعد می‌توانیم به API هایی برویم که باید برآورده کنیم و نزدیک به نیازمندی‌های عملکردی ما هستند. برای این مسئله احتمالاً می‌خواهیم بعضی API های REST خود سند را مدیریت کنند. همچنین می‌دانیم به ارتباط دوطرفه زیادی بین هر ویرایشگر و سندی که به‌صورت جمعی ویرایش می‌کنند نیاز داریم. در این مورد منطقی است فرض کنیم برای تجربه ویرایشگر به نوعی رویکرد مبتنی بر WebSocket نیاز داریم، پس تعدادی از پیام‌هایی را که روی سیم می‌فرستیم تعریف می‌کنیم.

هر مصاحبه‌کننده ممکن است تأکید کمتری روی این مرحله داشته باشد. بعضی ممکن است بخواهند واقعاً پیچیدگی‌های پیام‌ها را بفهمند (که به سؤال طراحی سطح پایین نزدیک می‌شود)، در حالی که بعضی دیگر خوشحال‌اند که بدانند چند پیام به‌طور مبهم تعریف شده‌اند و می‌توانید ادامه بدهید. ما اینجا فرض دومی را می‌گیریم!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
POST /docs
{
  title: string
} -> {
  docId
}

WS /docs/{docId}
  SEND {
    type: "insert"
    ....
  }

  SEND {
    type: "updateCursor"
    position: ...
  }

  SEND {
    type: "delete"
    ...
  }

  RECV {
    type: "update"
    ...
  }

وقتی API شما شامل WebSocket ها می‌شود، درباره پیام‌هایی که روی WebSocket می‌فرستید در برابر endpoint هایی که با HTTP به آن‌ها می‌خورید صحبت می‌کنید. نمادگذاری کاملاً به خودتان بستگی دارد، اما داشتن راهی برای توصیف پروتکل یا Schema پیام‌ها برای منتقل کردن آنچه در جریان است مفید است.

طراحی سطح بالا

با طرحی از API به طراحی سطح بالا می‌رویم. از نظر تاکتیکی می‌تواند مفید باشد به مصاحبه‌کننده بگویید مطمئنید جزئیات بیشتری از API هست، اما بعداً که مسئله را حل می‌کنید به آن برمی‌گردید. اگرچه عالی است که بتوانید پاسخ را صیقل بدهید، شما زیر فشار زمان هستید! پذیرفتن مسائلی که ممکن است بعداً به آن‌ها برسید و جا گذاشتن برای تنظیم طرح هنگام یادگیری بیشتر، راه عالی برای حرکت سازنده در مصاحبه است.

1) ساخت سند جدید

اولین قدم ما در ویرایشگر اسناد این است که به کاربران اجازه دهیم سند جدید بسازند. این یک API ساده است که یک عنوان می‌گیرد و شناسه سند را برمی‌گرداند. Metadata جالب زیادی هست که ممکن است بخواهیم از سند جدا کنیم (مثل مجوزها، تاریخچه، برچسب‌ها و غیره)، پس منطقی است فرض کنیم خود سند و Metadata جدا می‌شوند. برای این API روی بخش اول (Metadata) کار می‌کنیم.

از نظر مصاحبه، الگوی یک سرویس CRUD ساده مقیاس‌پذیر افقی که پشت API Gateway است آن‌قدر رایج است که باید بتوانید خیلی سریع آن را بکشید! زمان زیادی را صرف چیدن صحنه نکنید.

مصاحبه‌کننده احتمالاً می‌پرسد اینجا از چه پایگاه داده‌ای استفاده می‌کنیم. برای این مسئله فعلاً پایگاه داده ساده Postgres را فرض می‌کنیم، چون کوئری انعطاف‌پذیر می‌دهد و اگر نیاز به مقیاس شد، همیشه می‌توانیم بعداً پارتیشن‌بندی و Replicate کنیم.

2) ویرایش هم‌زمان یک سند توسط چند کاربر

برای نیازمندی بعدی، باید نوشتن در خود سند را حل کنیم که مشکلات سازگاری و مقیاس دارد. اینجا همه‌چیز جالب می‌شود. در ویرایشگر مشارکتی، چند کاربر هم‌زمان با تناوب بالا یک سند را ویرایش می‌کنند، که دستور پخت مشکلات سازگاری و تعارض (Conflict) است!

نگرانی‌های مقیاس را برای بررسی عمیق بعداً در مصاحبه کنار می‌گذاریم، پس به مصاحبه‌کننده یادداشت می‌کنیم که به آن برمی‌گردیم تا بتوانیم روی سازگاری تمرکز کنیم.

چرا ویرایش مشارکتی ساده نیست؟

اول توضیح می‌دهم چرا رسیدن به سازگاری در ویرایشگر مشارکتی با شروع از راه‌حل ناقص و ساختن راه‌حل درست‌تر آسان نیست. من اینجا اصول اولیه را مرور می‌کنم تا توضیح بدهم چطور می‌توانیم مشکلات سازگاری را حل کنیم. اما مثل قبل، اینجا جایی است که داشتن دانش پیش‌زمینه خوب است تا بتوانید به سؤال‌های مصاحبه‌کننده پاسخ بدهید، ولی احتمالاً از زمان مصاحبه برای آموزش این مفاهیم به مصاحبه‌کننده استفاده نمی‌کنید. اگر قبلاً آن‌ها را می‌فهمید، جلو بپرید!

رویکرد بد: ارسال Snapshot کامل سند

فرض کنید با هر ویرایش، کاربران کل سندشان را برای Document Service می‌فرستند. سپس Document Service سند را در یک ذخیره‌ساز Blob مثل S3 ذخیره می‌کند.

چرا ناقص است؟ به‌اندازه کافی ساده است، درست؟ نه. اول اینکه این طراحی فوق‌العاده ناکارآمد است: کل سند را برای هر ویرایش روی سیم منتقل می‌کنیم. برای تایپ سریع این می‌تواند به معنای 100 کیلوبایت در هر ضربه کلید باشد، وای. اما مشکل جدی‌تری هم هست.

فرض کنید کاربر A و کاربر B هم‌زمان یک سند را ویرایش می‌کنند. سند با یک پاراگراف متن شروع می‌شود: Hello!

  • کاربر A عبارت , world را اضافه می‌کند تا Hello, world! بسازد و می‌فرستد.
  • کاربر B علامت ! را حذف می‌کند تا Hello بسازد و می‌فرستد.

هر دو ویرایش‌هایشان را هم‌زمان می‌فرستند. سند واقعی که در Blob Storage ذخیره می‌شود کاملاً به این بستگی دارد کدام درخواست اول برسد. اگر ویرایش کاربر A اول برسد، سند Hello, world! می‌شود. اگر ویرایش کاربر B اول برسد، سند Hello می‌شود. تجربه کاربری وحشتناک است، چون همه ویرایش‌های هم‌زمان را از دست می‌دهیم!

رویکرد گرم‌تر: ارسال خود ویرایش‌ها

با فهمیدن اینکه روی سند «ویرایش» انجام می‌دهیم، می‌توانیم این مشکلات را حل کنیم. به‌جای منتقل کردن کل سند روی سیم، می‌توانیم فقط ویرایش‌ها را منتقل کنیم.

  • کاربر A عبارت , world را اضافه می‌کند و INSERT(5, ", world") می‌فرستد.
  • کاربر B علامت ! را حذف می‌کند و DELETE(5) می‌فرستد.

مشکل اول را حل کردیم! دیگر کل سند را روی سیم منتقل نمی‌کنیم، پس لازم نیست با هر ضربه کلید 100 کیلوبایت بفرستیم. اما مشکل جدیدی داریم.

حذف کاربر B فرض می‌کند نویسه در موقعیت 5 همان علامت تعجب است. اگر ویرایش کاربر A بعد از حذف کاربر B برسد، در نهایت Hello, world خواهیم داشت، که خوب است. اما اگر ویرایش کاربر B بعد از ویرایش کاربر A برسد، در نهایت Hello, world! ولی با یک نویسه اشتباه حذف‌شده خواهیم داشت: به‌جای علامت تعجب، یک نویسه دیگر (از جمله ,) حذف شده است!

رویکردهای ویرایش مشارکتی

نکته مهمی که اینجا گم شده این است که هر ویرایش زمینه‌مند (Contextual) است: بر اساس وضعیت مشخصی از سند ساخته شده است. پرداختن به جریانی از ویرایش‌ها مجموعه‌ای از مشکلات سازگاری می‌سازد! دو گزینه برای حل این داریم:

رویکرد خوب: Operational Transformation (OT)

یک راه نخ‌کردن سوزن، تفسیر مجدد یا تبدیل (Transform) هر ویرایش قبل از اعمال آن است. ایده این است که ویرایش هر کاربر را می‌توان با توجه به ویرایش‌هایی که قبل از آن آمده‌اند تنظیم کرد.

همه ویرایش‌های سند را روی یک سرور جمع می‌کنیم. از آنجا می‌توانیم دسته‌ای هر ویرایش را بر اساس ویرایش‌هایی که قبل از آن انجام شده‌اند تبدیل کنیم. اگرچه توضیح جامع OT خارج از محدوده این طراحی است (می‌توانید در صفحه Wikipedia بیشتر بخوانید)، یک مثال ساده حل‌شده را بررسی می‌کنیم.

DELETE(5) کاربر B می‌خواهد نویسه موقعیت 5 را حذف کند که برای او در زمان ساخت ویرایش ! بود. این مشکل وقتی رخ می‌دهد که ویرایش INSERT(5, ", world") کاربر A اول برسد. اگر ویرایش کاربر B را تبدیل نکنیم، به‌جای Hello, world به نتیجه نادرست می‌رسیم.

رویکرد OT این است که عملیات را قبل از اعمال (و بعداً قبل از ارسال دوباره به کلاینت‌ها) تبدیل کند. در این حالت، اگر ویرایش کاربر A قبل از حذف کاربر B برسد، می‌توانیم DELETE(5) کاربر B را به DELETE(12) تبدیل کنیم، چون , world شامل 7 نویسه است (5 + 7 = 12) تا وقتی ویرایش کاربر A اعمال شد، علامت تعجب حذف شود، نه نویسه‌ای دیگر.

OT کم‌حافظه و سریع است، اما با یک مبادله بزرگ همراه است.

چالش‌ها: OT به یک سرور مرکزی نیاز دارد که ترتیب نهایی عملیات را تعیین کند. این به ما اجازه می‌دهد تا تعداد کمی همکار مقیاس بگیریم، اما نه تعداد زیاد. نیازمندی‌های غیرعملکردی ما اینجا به ما کمک می‌کنند. OT همچنین برای پیاده‌سازی درست دشوار است و به‌راحتی اشتباه می‌شود.

رویکرد خوب: CRDT

جایگزین تفسیر مجدد هر ویرایش، جابه‌جاپذیر (Commutative) کردن هر ویرایش است، یعنی بتوان آن را به هر ترتیبی اعمال کرد. این رویکردی است که Conflict-free Replicated Data Type ها (CRDT) در پیش می‌گیرند.

CRDT ها اجازه می‌دهند ویرایش‌ها را به‌صورت مجموعه‌ای از عملیات نشان بدهیم که می‌توانند به هر ترتیبی اعمال شوند و باز هم همان خروجی را بدهند. برای عملیات متن ساده، CRDT ها این را با دو ترفند انجام می‌دهند:

  • CRDT ها موقعیت‌ها را به‌جای عدد صحیح با اعداد حقیقی نشان می‌دهند. این موقعیت‌ها حتی با درج‌ها هرگز تغییر نمی‌کنند. اگر لازم باشد «جای» بیشتری بسازیم، اعداد حقیقی اجازه می‌دهند این کار را بی‌نهایت انجام بدهیم (مثلاً 1.5 را بین 1 و 2 بگذاریم).
  • CRDT ها برای متن حذف‌شده «سنگ قبر» (Tombstone) نگه می‌دارند. متن در واقع هرگز از سند حذف نمی‌شود، اما به یاد داریم که قبل از نمایش سند به کاربر، حذف شده است.

توجه: نمی‌توانیم برای توضیح CRDT ها (موضوع تحقیقاتی عمیق!) خیلی عمیق شویم، اما می‌توانید در صفحه Wikipedia بیشتر بخوانید. ولی برای اینکه کمی شهود بدهیم، یک مثال ساده حل‌شده را بررسی می‌کنیم.

فرض کنید دو کاربر داریم که روی Hello! کار می‌کنند و هر نویسه زیرش یک شماره موقعیت دارد:

1
2
H  e  l  l  o  !
0  1  2  3  4  5

هر دو کاربر می‌خواهند چیزی بعد از Hello درج کنند. کاربر A می‌خواهد , را درج کند و کاربر B می‌خواهد ` there` را. این در OT دشوار است، چون باید یک ویرایش را بر اساس دیگری تبدیل کنیم. اما با CRDT ها فقط باید مطمئن شویم هر نویسه جدید شماره موقعیت یکتای خودش را بین 4 و 5 می‌گیرد.

وقتی کاربر A می‌خواهد , را درج کند، یک عدد دلخواه بین 4 و 5 را به‌عنوان موقعیت کاما انتخاب می‌کند، مثلاً 4.3:

1
2
H  e  l  l  o  ,     !
0  1  2  3  4  4.3   5

کاربر B هم همین کار را می‌کند (هم‌زمان) و برای هر نویسه‌ای که می‌خواهد درج کند، اعداد دلخواهی بین 4 و 5 می‌گیرد:

1
2
H  e  l  l  o  ␣    t    h    e    r    e   !
0  1  2  3  4  4.1  4.2  4.4  4.5  4.7  4.8 5

جادو این است که این شماره‌های موقعیت یک ترتیب کلی می‌سازند. مهم نیست ویرایش‌ها به چه ترتیبی برسند، هر کلاینت نویسه‌ها را به همان روش مرتب می‌کند و همان متن نهایی را می‌بیند. در این مورد، رشته حاصل Hello t,here! است که نتیجه ویرایش هر دو کاربر در یک موقعیت است. ترفندهای زیادی برای جلوگیری از تداخل‌هایی مثل این هست، اما تضمین اصلی CRDT این است که مهم نیست ویرایش‌ها به چه ترتیبی بیایند، هر کلاینت به یک سند همگرا می‌شود.

CRDT ها از این جهت زیبا هستند که نیاز به سرور مرکزی را از بین می‌برند. تا وقتی همه به‌روزرسانی‌ها در نهایت به همه کلاینت‌ها برسند، کلاینت‌ها به یک سند همگرا می‌شوند. این آن را برای اپلیکیشن‌های Peer-to-Peer راه‌حل خوبی می‌کند. CRDT ها همچنین به‌راحتی با کاربردهای آفلاین جور می‌شوند: فقط می‌توانید همه ویرایش‌ها را محلی نگه دارید و وقتی ویرایشگر آنلاین شد به مجموعه به‌روزرسانی اضافه کنید.

یک پیاده‌سازی تجاری عالی از CRDT ها Yjs است که برای اپلیکیشن‌های مشارکتی سبک توصیه‌اش می‌کنم.

چالش‌ها: مزایای CRDT با برخی معایب بزرگ همراه است. مصرف حافظه بیشتر است، چون باید هر ویرایش را به خاطر بسپاریم، از جمله سنگ قبر برای متنی که مدت‌هاست حذف شده است. یعنی سند ما فقط بزرگ می‌شود و هرگز کوچک نمی‌شود. CRDT ها از نظر محاسباتی هم کارایی کمتری دارند، هرچند راه‌حل‌هایی هست! و در نهایت، مدیریت تعارض‌ها می‌تواند ناهنجار باشد. برای بیشتر کاربردهای بلادرنگ این مشکل بزرگی نیست، چون فقط داشتن نشانگر Cursor کافی است تا من و شما از ویرایش یک بخش سند دوری کنیم، اما چون کلاینت‌ها ممکن است نیاز به Buffer یا آفلاین شدن داشته باشند، می‌تواند مشکل بزرگی شود.

انتخاب برای این مسئله

برای این مسئله از رویکرد Operational Transformation (OT) استفاده می‌کنیم. این رویکردی است که Google Docs اتخاذ می‌کند و از نیاز حافظه کم، سازگاری بیشتر با متن به‌طور خاص و استفاده از یک سرور متمرکز سود می‌برد. اگر لازم بود تعداد بیشتری همکار یا سیستم Peer-to-Peer پشتیبانی کنیم (شاید با WebRTC!)، می‌توانستیم از CRDT استفاده کنیم. نمونه‌های زیادی از CRDT های صنعتی هست که بعضی گوشه‌ها را صاف می‌کنند (Figma یکی از موارد قابل توجه است) و روی هر دو رویکرد تحقیق و توسعه جریان دارد.

با در نظر گرفتن این موضوع، می‌توانیم طراحی را برای حل مشکل ویرایش مشارکتی به‌روز کنیم. طراحی به‌روزشده ویرایش‌ها را به‌صورت عملیات به سرور مرکزی می‌فرستد که قبل از ثبت در پایگاه داده توسط سرور تبدیل می‌شوند.

برای Document Operations DB جدید، می‌خواهیم چیزی استفاده کنیم که بتوانیم خیلی سریع به‌صورت Append بنویسیم (فعلاً). Cassandra باید جواب بدهد. بر اساس documentId پارتیشن‌بندی می‌کنیم و بر اساس مهر زمانی (که توسط Document Service ما تنظیم می‌شود) مرتب می‌کنیم. وقتی ویرایش با موفقیت در پایگاه داده ثبت شد، می‌توانیم آن را برای کاربر تأیید (Ack) کنیم. نیازمندی دوام را برآورده کردیم!

3) مشاهده بلادرنگ تغییرات یکدیگر

خوب، حالا که مسیر نوشتن داریم، درباره مهم‌ترین جزء باقی‌مانده صحبت می‌کنیم: خواندن! باید دو مسیر را مدیریت کنیم:

  • اول، وقتی سندی تازه ساخته شده یا مدتی دیده نشده، باید بتوانیم سند را از پایگاه داده بارگذاری کنیم و برای ویرایشگرهای متصل بفرستیم.
  • دوم، وقتی ویرایشگر دیگری ویرایشی انجام می‌دهد، باید باخبر شویم تا سندمان به‌روز بماند.

هنگام بارگذاری سند: وقتی کاربر برای اولین بار وصل می‌شود، باید آخرین نسخه سند را بگیرد. در این حالت، وقتی اتصال WebSocket برای سند ابتدا برقرار می‌شود، می‌توانیم همه عملیات قبلی اعمال‌شده را روی اتصال Push کنیم. چون همه به یک سرور وصل می‌شوند، می‌توانیم فرض کنیم همه اتصال‌ها از یک نقطه شروع می‌کنند.

هنگام به‌روزرسانی: وقتی ویرایشی با موفقیت توسط همکار دیگری انجام می‌شود، هر ویرایشگر متصل باقی‌مانده باید به‌روزرسانی‌ها را بگیرد. چون همه ویرایشگرهای ما به یک سرور وصل‌اند، این ساده است: بعد از ثبت عملیات در پایگاه داده، می‌توانیم آن را برای همه کلاینت‌هایی که فعلاً به آن سند وصل‌اند بفرستیم.

مرحله بعدی ممکن است شما را غافلگیر کند: سمت کلاینت هم باید Operational Transformation انجام بدهیم. کوتاه توضیح می‌دهم چرا. وقتی کاربران سندشان را ویرایش می‌کنند، انتظار دارند تغییرات را فوراً ببینند. در عمل، تغییرات آن‌ها همیشه اول روی سند محلی‌شان اعمال می‌شود و بعد به سرور می‌رود. چه می‌شود اگر ویرایش کاربر دیگری را بعد از تغییر محلی خودمان اعمال کنیم، اما قبل از اینکه ویرایش ما روی سرور ثبت شود؟

OT راهی برای مدیریت ویرایش‌های خارج از ترتیب می‌دهد. یادتان باشد OT دنباله‌ای از ویرایش‌ها را با ترتیب دلخواه می‌گیرد و آن‌ها را بازنویسی می‌کند تا پیوسته همان سند نهایی را بدهند. می‌توانیم همین کار را اینجا بکنیم! پس اگر کاربر A ویرایش Ea را به سرور بفرستد و کاربر B ویرایش Eb را بفرستد (که بعد از Ea می‌رسد)، ترتیب عملیات از دید هر سایت این است:

1
2
3
Server: Ea, Eb
User A: Ea, Eb
User B: Eb, Ea

فارغ از ترتیب، با اعمال OT روی عملیات می‌توانیم تضمین کنیم هر کاربر همان سند را می‌بیند!

4) مشاهده Cursor و حضور کاربران دیگر

یکی از مهم‌ترین ویژگی‌های ویرایشگر مشارکتی «آگاهی» (Awareness) است، یعنی توانایی دیدن جاهایی که همکارانتان ویرایش می‌کنند. این به جلوگیری از موقعیت‌هایی کمک می‌کند که هم‌زمان محتوای یکسانی را ویرایش می‌کنید (که همیشه آشفته است!) یا کار را تکرار می‌کنید.

آگاهی ذاتاً زودگذر (Ephemeral) است: فقط به این اهمیت می‌دهیم که Cursor همکارانمان الان کجاست، نه جایی که یک ساعت پیش بود. همچنین اهمیتی نمی‌دهیم Cursor کاربر وقتی فعلاً وصل نیست کجاست، چون معنی ندارد. این داده با اتصال زودگذر است.

این ویژگی‌ها کمک می‌کنند تصمیم بگیریم لازم نیست موقعیت Cursor یا حضور کاربران دیگر را در خود داده سند نگه داریم. به‌جای آن، می‌توانیم از کاربران بخواهیم تغییرات Cursor را به Document Service گزارش کنند که می‌تواند آن را در حافظه نگه دارد و روی همان WebSocket برای همه کاربران دیگر Broadcast کند. وقتی کاربر جدیدی وصل می‌شود، Document Service می‌تواند ویژگی‌های بقیه کاربران را از حافظه‌اش بخواند و برای کاربر متصل بفرستد. در نهایت، Document Service می‌تواند قطع اتصال‌های سوکت را ردیابی کند و آن کاربران را از فهرست حذف کند (و برای کاربران باقی‌مانده Broadcast بفرستد).

و با این، ویرایشگر مشارکتی کارایی داریم که تا چند هزار کاربر جواب می‌دهد!

بررسی‌های عمیق

با برآورده شدن نیازمندی‌های عملکردی اصلی، وقت آن است که نیازمندی‌ها و مسائل غیرعملکردی را که معرفی کردیم پوشش بدهیم.

1) مقیاس‌دهی به میلیون‌ها اتصال WebSocket

یکی از ضعیف‌ترین پیش‌فرض‌هایی که هنگام ساخت سیستم گرفتیم این است که می‌توانیم با یک سرور Document Service به همه سرویس بدهیم. این به میلیون‌ها کاربر هم‌زمان نمی‌رسد و یک نقطه شکست برای دسترس‌پذیری می‌سازد.

باید تعداد سرورهای Document Service را به تعداد اتصال‌های هم‌زمان مقیاس بدهیم. وقتی به Document Service وصل می‌شوم، (الف) باید با همه کاربران دیگری که به همان سند وصل‌اند هم‌مکان باشم، یا (ب) بدانم همه کاربران دیگر کجا وصل‌اند. (برای مسئله مشابه به تحلیل WhatsApp مراجعه کنید.)

الگو: به‌روزرسانی بلادرنگ (Real-time Updates). این الگوی نیاز به Push کردن به‌روزرسانی برای کلاینت‌ها در یک سرویس باطن مقیاس‌پذیر، نمونه اساسی الگوی به‌روزرسانی‌های بلادرنگ است.

وقتی WebSocket ها را در طراحی معرفی می‌کنید، احتمالاً به این دلیل است که دوطرفه‌اند و می‌توانید پیام برای کلاینت‌ها بفرستید. با معماری سنتی کلاینت-سرور، بیشتر درباره فلش‌های چپ‌به‌راست صحبت می‌کنید: کلاینت‌ها چطور به سرورها وصل می‌شوند تا پیام بفرستند و پاسخ بگیرند. جهشی که بسیاری از داوطلبان نمی‌کنند فکر کردن به فلش‌های راست‌به‌چپ روی نمودارهایشان است: سرویس‌های داخلی شما چطور با کلاینت‌ها صحبت می‌کنند؟

چون WebSocket ها حالت‌دار (Stateful) و دردسرسازند، مدیریتشان در «لبه» طراحی می‌تواند مفید باشد. با پایان دادن WebSocket ها در لبه و در معرض گذاشتن یک API «داخلی» برای بقیه سیستم‌ها، سیستم‌های دیگر می‌توانند بدون حالت بمانند و لازم نیست نگران جزئیات اتصال WebSocket ها باشند.

راه‌حل اینجا این است که Document Service را افقی مقیاس بدهیم و از یک حلقه Consistent Hashing برای توزیع اتصال‌ها بین سرورها استفاده کنیم. هر سرور در سیستم به حلقه هش می‌پیوندد و هر سرور مسئول محدوده‌ای از مقدارهای هش است. یعنی همیشه می‌دانیم کدام سرور مسئول سند است و همه اتصال‌ها به آنجا می‌روند. از Apache ZooKeeper برای نگه داشتن پیکربندی حلقه هش و هماهنگی بین سرورها استفاده می‌کنیم.

وقتی کلاینت باید وصل شود:

  1. ابتدا می‌تواند یک اتصال HTTP به هر کدام از سرورهای سند (احتمالاً از طریق Round Robin) همراه شناسه سند درخواستی باز کند.
  2. آن سرور پیکربندی حلقه هش را برای شناسه سند داده‌شده بررسی می‌کند و اگر مسئول آن محدوده هش نباشد، با Redirect به آدرس سرور درست پاسخ می‌دهد. به این ترتیب کلاینت‌ها در نهایت بدون واسطه مستقیم به سرور درست وصل می‌شوند.
  3. وقتی سرور درست پیدا شد، اتصال HTTP به اتصال WebSocket ارتقا می‌یابد. سرور سوکت یک نگاشت از شناسه سند به WebSocket های مربوط را برای استفاده بعدی نگه می‌دارد.
  4. همه عملیات ذخیره‌شده (اگر قبلاً بارگذاری نشده‌اند) را از Document Operations DB بارگذاری می‌کنیم و آماده پردازش عملیات هستیم.
  5. چون همه کاربران یک سند به یک سرور وصل‌اند، هنگام به‌روزرسانی می‌توانیم آن‌ها را به‌سادگی برای همه کلاینت‌های متصل بفرستیم.

زیبایی Consistent Hashing (هشینگ پایدار) این است که وقتی سرور اضافه یا حذف می‌کنیم، فقط بخش کوچکی از اتصال‌ها باید دوباره توزیع شود. سرورها دوره‌ای ZooKeeper را برای تغییرات حلقه بررسی می‌کنند و در صورت نیاز می‌توانند انتقال اتصال را شروع کنند.

نقطه ضعف این رویکرد این است که در این رویدادهای مقیاس‌دهی، باید وضعیت زیادی را جابه‌جا کنیم. نه‌تنها باید اتصال‌های WebSocket را برای کاربران جابه‌جاشده منتقل کنیم (با قطع اتصال و مجبور کردن آن‌ها به اتصال مجدد به سرور درست)، بلکه باید مطمئن شویم عملیات سند به سرور درست منتقل می‌شود.

در مصاحبه ممکن است از ما خواسته شود بیشتر توضیح بدهیم:

  • پیچیدگی مقیاس: هنگام اضافه کردن سرورها باید حلقه‌های هش قدیم و جدید را ردیابی کنید؛ ممکن است لازم باشد درخواست‌ها در طول انتقال به چند سرور فرستاده شوند؛ خرابی سرور به توزیع مجدد سریع اتصال‌ها و به‌روزرسانی حلقه هش نیاز دارد.
  • چالش‌های مدیریت وضعیت اتصال: برای خرابی سرور و مشکلات اتصال به مانیتورینگ قوی نیاز دارید؛ برای در دسترس نبودن سرور، کلاینت باید منطق اتصال مجدد (Reconnect) پیاده کند.
  • نیازهای برنامه‌ریزی ظرفیت: باید مطمئن شوید سرورها منابع کافی برای بار اتصال‌شان دارند؛ باید توزیع اتصال را مانیتور کنید تا از نقطه‌های داغ (Hot Spot) سرور جلوگیری شود.

2) کنترل حجم ذخیره‌سازی

با میلیاردها سند، باید درباره مدیریت ذخیره‌سازی فکر کنیم. با انتخاب OT در برابر CRDT می‌توانیم حجم ذخیره‌سازی لازم را با یک ضریب کم کنیم. با این حال، اگر هر سند 50 کیلوبایت باشد، دنبال 50 ترابایت ذخیره‌سازی هستیم! بدتر، اگر سندی میلیون‌ها عملیات داشته باشد، هر کدام از این عملیات‌ها باید برای هر کلاینتی که به سند می‌پیوندد منتقل و اعمال شود!

همچنین یادتان باشد همه اسناد فعال باید در Document Service ما در حافظه نگه داشته شوند. باید آن فضا را کوچک نگه داریم تا از گلوگاه حافظه جلوگیری کنیم.

یکی از طبیعی‌ترین راه‌حل‌ها اینجا این است که دوره‌ای از عملیات Snapshot بگیریم یا آن‌ها را فشرده (Compact) کنیم. ایده این است که لازم نیست همه عملیات در Document Operations DB ما ذخیره شوند (مگر اینکه بخواهیم نوعی نسخه‌بندی را مدیریت کنیم)، و می‌توانیم بسیاری از عملیات را در یک عملیات جمع کنیم تا در پردازش و فضا صرفه‌جویی کنیم. گزینه‌های زیادی برای این کار هست!

رویکرد خوب: سرویس فشرده‌سازی جدا

یک رویکرد، معرفی Compaction Service جدید است که دوره‌ای عملیات را از Document Operations DB می‌خواند و یک Snapshot در پایگاه داده می‌نویسد و این عملیات را جمع می‌کند. برنامه دقیق پارامترهای بالقوه زیادی دارد: ممکن است دنبال اسناد بزرگی بگردیم که اخیراً فشرده نشده‌اند و احتمالاً اخیراً نوشته نشده‌اند.

چون این لزوماً عملیات بعدی را تغییر می‌دهد، باید مطمئن شویم آنچه می‌نویسیم روی سند زنده اثر نمی‌گذارد. این تراکنش توزیع‌شده دشواری است و چون از Cassandra برای Document Operations DB استفاده می‌کنیم، فقط تراکنش‌های سطح ردیف داریم.

برای تضمین اتمی بودن در سطح سند، یک documentVersionId جدید را در Document Metadata DB معرفی می‌کنیم. قبل از بارگذاری سند، این documentVersionId را از Metadata DB می‌گیریم تا بدانیم کدام عملیات سند را باید بازیابی کنیم. هر وقت بخواهیم عملیات را تغییر بدهیم، می‌توانیم عملیات جدید را بنویسیم و documentVersionId را عوض (Flip) کنیم. مطمئن می‌شویم همه تغییرات از Document Service می‌گذرد تا شرایط رقابتی (دیگر) نداشته باشیم.

Compaction Service می‌تواند:

  1. عملیات سند را برای یک سند مشخص از پایگاه داده بخواند.
  2. این عملیات را تا حد امکان به تعداد کمتری عملیات فشرده کند (احتمالاً یک عملیات درج واحد!).
  3. عملیات حاصل را با یک documentVersionId جدید بنویسد.
  4. به Document Service بگوید documentVersionId را عوض کند.

اگر Document Service سند بارگذاری‌شده‌ای داشته باشد، دستور Flip را رد می‌کند تا فشرده‌سازی‌ای را که ممکن است عملیات موجود را خراب کند به تعویق بیندازد.

رویکرد عالی: Snapshot و فشرده‌سازی آنلاین

رویکرد متفاوتی که می‌توانیم بگیریم این است که Document Service خودمان دوره‌ای عملیات را Snapshot یا Compact کند. چون Document Service مالکیت انحصاری یک سند را دارد (وقتی خوشه را مقیاس نمی‌دهیم)، می‌توانیم با خیال راحت عملیات را بدون نگرانی از هماهنگی و سازگاری جمع کنیم. در واقع، زمان طبیعی برای این کار وقتی است که سند بیکار است و اتصالی ندارد، چیزی که Document Service فوراً از آن باخبر می‌شود. همچنین در آن زمان همه عملیات موجود سند را از قبل در حافظه داریم که کار را ساده‌تر می‌کند.

وقتی آخرین کلاینت قطع شد، می‌توانیم:

  1. همه عملیات موجود را برداریم و برای فشرده‌سازی در یک فرایند جدا (با CPU کم، خوب است) بارگذاری کنیم.
  2. عملیات حاصل را با documentVersionId جدید در پایگاه داده بنویسیم.
  3. documentVersionId را در Document Metadata DB عوض (Flip) کنیم.

چالش‌ها: انتقال فشرده‌سازی به Document Service خطر افزایش Latency برای عملیات سند را دارد (به‌ویژه در دم P99). باید مطمئن شویم CPU های ما را بی‌جهت تحت فشار نمی‌گذارند. یک گزینه برای این کار اجرای آن‌ها در یک فرایند جدا با اولویت CPU کمتر است.

بررسی‌های عمیق اضافی

Google Docs هیولایی است که صدها تا هزاران مهندس روی آن کار می‌کنند، پس نمی‌توانیم همه‌چیز را اینجا پوشش بدهیم. چند بررسی عمیق اضافی که ممکن است در نظر بگیرید:

  • حالت فقط‌خواندنی: Google Docs حالت «فقط‌خواندنی» دارد که در آن کاربران می‌توانند اسناد را بدون تداخل با دیگران ببینند. این حالت به‌طور قابل توجهی مقیاس‌پذیرتر هم هست و میلیون‌ها خواننده می‌توانند همان سند را ببینند. چطور می‌توانیم این را پیاده کنیم؟
  • نسخه‌بندی: Google Docs به کاربران اجازه می‌دهد به نسخه‌های قبلی یک سند برگردند. چطور می‌توانیم رویکرد Snapshot و Compact خود را برای پشتیبانی از این گسترش دهیم؟
  • حافظه: مصرف حافظه Document Service می‌تواند گلوگاه باشد. چطور می‌توانیم آن را بیشتر بهینه کنیم؟
  • حالت آفلاین: اگر بخواهیم به کلاینت‌هایمان اجازه بدهیم آفلاین کار کنند، چطور می‌توانیم طراحی را گسترش بدهیم؟ چه ملاحظات دیگری را باید در طراحی لحاظ کنیم؟

انتظارات در هر سطح

سطح Mid-level

من شخصاً احتمالاً این سؤال را از داوطلب Mid-level نمی‌پرسم. این به معنای دریافت نکردن آن نیست، اما من نمی‌پرسم. با این حال، دنبال داوطلبی هستم که بتواند طراحی سطح بالا بسازد و بعد (همراه کمی کمک) به بعضی مسائلی که در مسئله هم‌زمانی بالا مثل این با آن‌ها روبه‌رو می‌شوید فکر کند. می‌خواهم شواهدی ببینم که می‌توانید در لحظه فکر کنید، راه‌حل‌های سرانگشتی (Spitball) بدهید و جعبه‌ابزار کوچک (اما در حال رشدی) از فناوری‌ها و رویکردها داشته باشید که برای این مسئله به کار می‌برید.

سطح Senior

برای داوطلبان Senior انتظار دارم فوراً شروع به حل بعضی چالش‌های سازگاری و دوام در این مسئله کنند. ممکن است با راه‌حل بسیار ابتدایی و ناکارآمد شروع کنند، اما انتظار دارم بتوانند فعالانه گلوگاه‌ها را شناسایی کنند و حلشان کنند. اگرچه انتظار ندارم داوطلب لزوماً تفاوت OT و CRDT را بداند، انتظار دارم بتواند درباره مبادلات و محدودیت‌های پایگاه داده و اینکه چطور ممکن است روی طراحی ما اثر بگذارند صحبت کند. ممکن است برای یک بررسی عمیق اضافه وقت داشته باشیم.

سطح Staff به بالا

برای مهندسان Staff انتظار درجه‌ای از تسلط بر مسئله دارم. انتظار دارم با CRDT ها آشنایی کامل داشته باشند (اگر نه، آن را جای دیگری می‌سازند) و از نزدیک با مقیاس‌دهی سرویس‌های سوکت، مبادلات سازگاری، مشکلات Serialization، تراکنش‌ها و موارد دیگر آشنا باشند. اگر خیلی در بهینه‌سازی عمیق نباشیم، انتظار دارم از بررسی‌های عمیق عبور کنند و احتمالاً به بررسی عمیق دیگری هم بپردازند.