برنتی / می‌نویسم که فکر کنم

ZooKeeper را هنوز باید بشناسیم؟ هماهنگی در سیستم‌های توزیع‌شده

1405.07.03·سیستم دیزاین

اگر چند سال پیش این سؤال را می‌پرسیدید که «مهندس ارشد باید ZooKeeper بلد باشد؟»، جواب ساده بود: بله، چون کافکا، HBase، Hadoop و Solr همه به آن تکیه داشتند و دیر یا زود با آن سروکار پیدا می‌کردید. امروز جواب کمی پیچیده‌تر است. کافکا از نسخه‌ی ۴ کاملاً از ZooKeeper جدا شده و مدیریت متادیتا را خودش با پروتکلی به اسم KRaft انجام می‌دهد. Kubernetes هم از اول سراغ etcd رفت.

با این حال به نظرم شناختن ZooKeeper هنوز ارزش دارد، ولی به دلیلی متفاوت. لازم نیست حتماً یک کلاستر ZooKeeper راه بیندازید و نگهداری‌اش کنید. چیزی که باید بشناسید مسئله‌ای است که ZooKeeper حل می‌کند و الگوهایی که برای حلش ساخته شده‌اند. همین الگوها را در etcd، Consul و KRaft هم می‌بینید، و سؤال‌های سیستم دیزاین درباره‌ی انتخاب رهبر یا قفل توزیع‌شده دقیقاً از همین‌جا می‌آیند.

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

فرض کنید سرویسی دارید که هر شب ساعت ۲ باید گزارش مالی بسازد. برای اینکه از دسترس خارج نشود، سه نسخه از آن روی سه سرور اجراست. حالا ساعت ۲ می‌شود. کدام نسخه گزارش را بسازد؟

راه‌حل‌های ساده همه یک جا می‌لنگند:

  • یک سرور را دستی «اصلی» تعیین کنیم. اگر همان سرور بیفتد، گزارش ساخته نمی‌شود.
  • هر سه یک ردیف در دیتابیس قفل کنند. اگر سروری که قفل را گرفته وسط کار بمیرد، قفل کی آزاد می‌شود؟ اگر مهلت بگذاریم، از کجا بفهمیم سرور واقعاً مرده یا فقط کند شده؟
  • سرورها با هم حرف بزنند و رأی بگیرند. این دقیقاً مسئله‌ی اجماع (consensus) است و پیاده‌سازی درستش آن‌قدر سخت است که نباید در کد هر سرویس تکرار شود.

ZooKeeper از همین نقطه شروع می‌کند: اجماع را یک بار، درست و در یک سرویس جدا پیاده کن، و به بقیه‌ی سیستم چند ابزار ساده بده که با آن‌ها هماهنگ شوند. یاهو ZooKeeper را برای همین ساخت و بعد به بنیاد Apache سپرد.

مدل داده: درختی شبیه فایل‌سیستم

ZooKeeper داده را در درختی از گره‌ها نگه می‌دارد که به هر کدام znode می‌گویند. مسیرها شبیه مسیر فایل‌اند:

/
├── app
│   ├── config          -> {"featureX": true}
│   ├── leader
│   │   ├── n_0000000007   (ephemeral, sequential)
│   │   └── n_0000000008   (ephemeral, sequential)
│   └── workers
│       ├── worker-a       (ephemeral)
│       └── worker-b       (ephemeral)
└── kafka-legacy

هر znode می‌تواند مقدار کمی داده داشته باشد (سقف پیش‌فرض حدود ۱ مگابایت است، ولی در عمل باید چند کیلوبایت باشد). چیزی که ZooKeeper را از یک key-value ساده جدا می‌کند، نوع گره‌ها و سازوکار watch است.

سه ویژگی که همه‌چیز رویشان ساخته می‌شود

۱. گره موقت (ephemeral). این گره به session کلاینتی که آن را ساخته گره خورده است. کلاینت با ZooKeeper یک session باز می‌کند و مرتب heartbeat می‌فرستد. اگر heartbeatها قطع شوند و مهلت session تمام شود، ZooKeeper همه‌ی گره‌های موقت آن کلاینت را خودش پاک می‌کند. یعنی «این سرور زنده است» را می‌شود با «این گره وجود دارد» نشان داد.

۲. گره ترتیبی (sequential). اگر موقع ساختن گره این پرچم را بدهید، ZooKeeper یک شماره‌ی افزایشی ده‌رقمی به انتهای اسمش اضافه می‌کند. چند کلاینت که هم‌زمان گره می‌سازند، شماره‌های متفاوت و مرتب می‌گیرند و ترتیبشان را خود ZooKeeper تعیین می‌کند، نه ساعت سرورها.

۳. watch. کلاینت می‌تواند روی یک گره یا فهرست فرزندانش watch بگذارد. وقتی آن گره عوض شد، ZooKeeper خبرش می‌کند. watchهای کلاسیک یک‌بارمصرف‌اند: بعد از هر اعلان باید دوباره ثبتشان کنید. از نسخه‌ی ۳.۶ watch دائمی (addWatch) هم اضافه شده است.

هر znode یک شماره‌ی نسخه هم دارد. اگر در setData نسخه‌ی مورد انتظار را بدهید و کس دیگری زودتر گره را عوض کرده باشد، عملیات شکست می‌خورد. این همان compare-and-set است و برای به‌روزرسانی امن تنظیمات کافی است.

با zkCli.sh می‌شود همه‌ی این‌ها را دستی امتحان کرد:

# گره دائمی با داده
create /app/config '{"featureX": true}'

# گره موقت؛ با بستن این ترمینال پاک می‌شود
create -e /app/workers/worker-a ''

# گره موقت و ترتیبی
create -e -s /app/leader/n_ ''
# Created /app/leader/n_0000000007

# خواندن داده به همراه متادیتا (نسخه، zxid و ...)
get -s /app/config

# watch روی فهرست فرزندان
ls -w /app/workers

زیر کاپوت: ensemble، رهبر و quorum

ZooKeeper معمولاً روی ۳ یا ۵ سرور اجرا می‌شود که به مجموعه‌شان ensemble می‌گویند. یکی از سرورها رهبر (leader) است و بقیه follower. همه‌ی نوشتن‌ها از رهبر می‌گذرند و رهبر با پروتکلی به اسم ZAB (ZooKeeper Atomic Broadcast) آن‌ها را به بقیه می‌رساند. یک نوشتن فقط وقتی تأیید می‌شود که اکثریت سرورها (quorum) آن را روی دیسک ثبت کرده باشند.

از همین قاعده چند نتیجه‌ی عملی بیرون می‌آید:

تعداد سرورquorumچند خرابی را تحمل می‌کند
۱۱هیچ
۳۲۱
۴۳۱
۵۳۲
۷۴۳

سرور چهارم چیزی به تحمل خطا اضافه نمی‌کند و فقط quorum را بزرگ‌تر می‌کند؛ برای همین تعداد سرورها را فرد می‌گیرند. سرور بیشتر هم لزوماً بهتر نیست: خواندن را سریع‌تر می‌کند چون هر سرور می‌تواند جواب خواندن بدهد، ولی نوشتن را کندتر می‌کند چون باید منتظر سرورهای بیشتری ماند. اگر فقط ظرفیت خواندن کم دارید، ZooKeeper گره‌هایی به اسم observer دارد که داده را می‌گیرند ولی در رأی‌گیری شرکت نمی‌کنند.

تضمین‌ها را دقیق بدانید

این بخش همان جایی است که بیشتر آدم‌ها اشتباه می‌کنند. ZooKeeper تضمین می‌کند که:

  • نوشتن‌ها linearizable هستند. همه‌ی سرورها نوشتن‌ها را با یک ترتیب واحد می‌بینند. هر تغییر یک شناسه‌ی افزایشی به اسم zxid می‌گیرد.
  • درخواست‌های یک کلاینت به همان ترتیبی اجرا می‌شوند که فرستاده شده‌اند.
  • خواندن‌ها linearizable نیستند. کلاینت به یکی از سرورها وصل است و آن سرور ممکن است کمی از رهبر عقب باشد. پس ممکن است مقدار قدیمی بخوانید. اگر لازم است آخرین مقدار را ببینید، قبل از خواندن sync را صدا بزنید.

خلاصه‌اش این است که ZooKeeper «همیشه تازه‌ترین داده را می‌دهد» نیست. «همه ترتیب یکسانی از تغییرات را می‌بینند» هست. برای هماهنگی همین کافی است.

الگوها: از سه ابزار ساده تا انتخاب رهبر

ZooKeeper خودش API قفل یا انتخاب رهبر ندارد. این‌ها «دستورپخت»هایی (recipes) هستند که روی همان سه ویژگی ساخته می‌شوند. فهمیدن این دستورپخت‌ها مهم‌ترین چیزی است که از ZooKeeper یاد می‌گیرید.

انتخاب رهبر

برگردیم به گزارش ساعت ۲. هر سه نسخه‌ی سرویس این کار را می‌کنند:

  1. زیر /app/leader یک گره موقت و ترتیبی می‌سازند.
  2. فهرست فرزندان را می‌خوانند. هر کس کوچک‌ترین شماره را دارد، رهبر است.
  3. بقیه روی گره درست قبل از خودشان watch می‌گذارند و منتظر می‌مانند.

اگر رهبر بمیرد، session‌اش منقضی و گره‌اش پاک می‌شود. فقط نسخه‌ای که پشت سرش بود خبردار می‌شود، دوباره فهرست را می‌خواند و اگر حالا کوچک‌ترین است، رهبر می‌شود.

نکته‌ی ظریف همین watch روی گره قبلی است. اگر همه روی کل پوشه watch بگذارند، با هر تغییر همه بیدار می‌شوند و هم‌زمان به ZooKeeper هجوم می‌آورند. به این مشکل herd effect می‌گویند و در کلاستری با صدها کلاینت واقعاً ZooKeeper را از پا درمی‌آورد.

قفل توزیع‌شده

قفل انحصاری تقریباً همان الگوست: کسی که کوچک‌ترین گره را دارد قفل را در دست دارد و وقتی کارش تمام شد گره‌اش را پاک می‌کند. اگر وسط کار بمیرد، موقت بودن گره تضمین می‌کند قفل تا ابد گیر نمی‌ماند.

عضویت گروه و کشف سرویس

هر worker هنگام بالا آمدن یک گره موقت زیر /app/workers می‌سازد. هر کس بخواهد بداند چه workerهایی زنده‌اند، فرزندان این پوشه را می‌خواند و watch می‌گذارد. کافکای قدیمی دقیقاً همین‌طور می‌فهمید کدام brokerها زنده‌اند.

تنظیمات متمرکز

تنظیمات در یک znode دائمی می‌نشیند و سرویس‌ها روی آن watch می‌گذارند. تغییر تنظیمات بدون ری‌استارت به همه می‌رسد، و با شماره‌ی نسخه جلوی بازنویسی هم‌زمان را می‌گیرید.

در عمل: از Curator استفاده کنید

پیاده کردن این دستورپخت‌ها با API خام ZooKeeper پر از جزئیات ریز است: قطع و وصل شدن اتصال، ثبت دوباره‌ی watch، حالتی که گره را ساخته‌اید ولی جواب ساختنش به شما نرسیده. کتابخانه‌ی Apache Curator در جاوا همه‌ی این‌ها را آماده دارد و تقریباً همه از آن استفاده می‌کنند:

CuratorFramework client = CuratorFrameworkFactory.newClient(
    "zk1:2181,zk2:2181,zk3:2181",
    new ExponentialBackoffRetry(1000, 3)
);
client.start();

// انتخاب رهبر
LeaderLatch latch = new LeaderLatch(client, "/app/leader", "report-service-1");
latch.start();
latch.await(); // تا وقتی رهبر نشده‌ایم منتظر می‌ماند

// قفل توزیع‌شده
InterProcessMutex lock = new InterProcessMutex(client, "/app/locks/nightly-report");
if (lock.acquire(10, TimeUnit.SECONDS)) {
    try {
        buildReport();
    } finally {
        lock.release();
    }
}

در Go و Python هم کتابخانه‌های مشابه هست (مثلاً kazoo در Python) که همین دستورپخت‌ها را دارند. توصیه‌ی من این است که هیچ‌وقت خودتان قفل یا انتخاب رهبر را از صفر روی ZooKeeper ننویسید.

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

این مهم‌ترین بخش این نوشته است. فرض کنید نسخه‌ی A رهبر است و وسط ساختن گزارش، JVM آن ۴۰ ثانیه در Garbage Collection گیر می‌کند. مهلت session سی ثانیه است. ZooKeeper گره A را پاک می‌کند و B رهبر می‌شود و شروع به ساختن گزارش می‌کند. بعد A از GC بیرون می‌آید و بی‌خبر از همه‌چیز، کارش را ادامه می‌دهد و گزارش را روی فایل B می‌نویسد.

در این لحظه دو رهبر دارید، و ZooKeeper هیچ اشتباهی نکرده است. مشکل این است که A هنوز نفهمیده رهبری را از دست داده. هیچ سرویس هماهنگی نمی‌تواند این را کاملاً حل کند، چون فرایندی که متوقف شده اصلاً کدی اجرا نمی‌کند که بفهمد.

راه‌حل این است که منبعی که از آن محافظت می‌کنید (دیتابیس، فایل، سرویس پایین‌دستی) خودش نوشتن‌های قدیمی را رد کند. به این کار fencing می‌گویند:

  1. هر بار که کسی رهبر می‌شود یا قفل می‌گیرد، یک عدد افزایشی (fencing token) می‌گیرد. در ZooKeeper می‌شود از zxid یا شماره‌ی نسخه‌ی گره استفاده کرد.
  2. رهبر این عدد را همراه هر نوشتن می‌فرستد.
  3. منبع بزرگ‌ترین عددی را که دیده نگه می‌دارد و هر نوشتنی با عدد کوچک‌تر را رد می‌کند.
UPDATE reports
SET body = $1, fencing_token = $2
WHERE id = $3 AND fencing_token < $2;
-- اگر هیچ ردیفی تغییر نکرد، یعنی رهبر جدیدی آمده و ما کنار گذاشته شده‌ایم

اگر در مصاحبه‌ی سیستم دیزاین کسی بگوید «با ZooKeeper قفل می‌گیریم و مشکل حل است»، سؤال بعدی تقریباً همیشه همین است.

اشتباه‌های رایج

  • ZooKeeper را دیتابیس نکنید. همه‌ی داده در حافظه‌ی همه‌ی سرورها نگه داشته می‌شود و هر نوشتن باید از quorum بگذرد. برای چند هزار نوشتن در ثانیه ساخته شده، نه بیشتر. داده‌ی کاربر، لاگ یا فایل جایش این‌جا نیست.
  • مهلت session را بی‌حساب انتخاب نکنید. مهلت کوتاه یعنی هر مکث کوتاه شبکه یا GC باعث عوض شدن رهبر می‌شود. مهلت بلند یعنی وقتی سروری واقعاً مرد، دیرتر جایگزین می‌شود. این یک مصالحه است و باید آگاهانه انتخابش کنید.
  • فراموش نکنید watch یک‌بارمصرف است. بین رسیدن اعلان و ثبت دوباره‌ی watch ممکن است تغییر دیگری رخ دهد. پس بعد از هر اعلان وضعیت را دوباره بخوانید و فقط به خود اعلان تکیه نکنید.
  • ensemble را روی یک رک یا یک zone نگذارید. سه سرور ZooKeeper روی یک ماشین فیزیکی، یعنی عملاً یک سرور.
  • دیسک را جدی بگیرید. ZooKeeper قبل از تأیید هر نوشتن آن را روی دیسک fsync می‌کند. اگر دیسک کند باشد یا با سرویس‌های دیگر شریک باشد، تأخیر کل کلاستر بالا می‌رود.

ZooKeeper، etcd یا KRaft؟

ZooKeeperetcdKRaft (کافکا)
الگوریتم اجماعZABRaftRaft (نسخه‌ی کافکا)
مدل دادهدرخت znodekey-value مسطح با prefixلاگ متادیتای داخلی کافکا
زنده بودن کلاینتsession و گره موقتleaseندارد، مخصوص خود کافکاست
اعلان تغییراتwatch (یک‌بارمصرف یا دائمی)watch به‌صورت streamندارد
APIپروتکل باینری اختصاصیgRPC و HTTPندارد
جای معمولHBase، Hadoop، Solr، کافکای قدیمیKubernetes و سیستم‌های Goفقط داخل کافکا

KRaft جایگزین عمومی ZooKeeper نیست. فقط باعث شده کافکا دیگر به آن نیازی نداشته باشد. اگر امروز پروژه‌ی تازه‌ای شروع می‌کنید و به هماهنگی نیاز دارید، etcd معمولاً انتخاب ساده‌تری است، مخصوصاً اگر روی Kubernetes هستید و etcd عملاً کنارتان است. ZooKeeper وقتی منطقی است که اکوسیستمتان از قبل به آن وابسته باشد، یا تیمتان تجربه‌ی نگهداری‌اش را داشته باشد.

پیش از همه‌ی این‌ها یک سؤال را بپرسید: اصلاً هماهنگی لازم دارید؟ خیلی وقت‌ها کاری که به نظر «فقط یک نفر باید انجامش دهد» می‌آید، با idempotent کردن آن حل می‌شود. اگر گزارش شبانه دو بار ساخته شود و نتیجه یکی باشد، شاید اصلاً رهبر لازم نداشته باشید.

مهندس ارشد دقیقاً چه چیزی باید بداند

لازم نیست جزئیات پیکربندی zoo.cfg را از بر باشید. ولی انتظار می‌رود بتوانید:

  • توضیح بدهید چرا اجماع به quorum نیاز دارد و چرا تعداد سرورها فرد است.
  • انتخاب رهبر را با گره‌های موقت و ترتیبی شرح بدهید و بگویید herd effect چیست.
  • فرق تضمین نوشتن و خواندن در ZooKeeper را بدانید و بدانید sync کجا لازم است.
  • ماجرای دو رهبر هم‌زمان را توضیح بدهید و fencing token را پیشنهاد کنید.
  • تشخیص بدهید کجا ZooKeeper یا etcd لازم است و کجا یک قفل ساده در Redis یا یک عملیات idempotent کافی است.
  • بدانید چرا کافکا از ZooKeeper جدا شد: مدیریت یک سیستم توزیع‌شده‌ی دوم کنار کافکا هزینه داشت و در کلاسترهای با تعداد partition زیاد، بالا آمدن controller جدید به خاطر بارگذاری متادیتا از ZooKeeper کند بود.

اگر این‌ها را بدانید، مهم نیست فردا با ZooKeeper کار کنید یا etcd یا Consul. مفاهیم همان‌هاست و فقط اسم API عوض می‌شود.

§ فهرست « سیستم دیزاین »
  1. 1405.07.03دیتابیس ستونی و OLAP: چرا گزارش سالانه را نباید از دیتابیس اپلیکیشن گرفتاز یک گزارش سالانه که دیتابیس فروشگاه را از کار می‌اندازد شروع می‌کنم و به تفاوت بار کاری OLTP و OLAP می‌رسم: دیتابیس ستونی مثل ClickHouse داده را چطور روی دیسک می‌چیند، چرا کوئری‌های تحلیلی روی آن چند ده برابر سریع‌تر است و چه چیزهایی را در ازایش از دست می‌دهید.
  2. 1405.07.03View در دیتابیس: وقتی سه تیم یک عدد را سه‌جور حساب می‌کننداز یک مسئله‌ی واقعی در طراحی سیستم شروع می‌کنم که در آن چند سرویس و تیم یک کوئری مشترک را هر کدام به شکل خودشان می‌نویسند و به view می‌رسم: view چیست، چه چیزی را حل می‌کند، materialized view کجا به کار می‌آید و در پروژه‌ی واقعی چطور باید ساخت و نگه‌داری‌اش کرد.
  3. 1405.07.03Bloom Filter: بررسی عضویت در مجموعه‌های بزرگ با حافظه‌ی کمBloom filter با چند بیت برای هر عضو جواب می‌دهد که یک مقدار «قطعاً در مجموعه نیست» یا «شاید هست». در این نوشته سازوکار، ریاضی انتخاب اندازه، یک پیاده‌سازی تست‌شده، محدودیت‌ها و جاهایی که در دیتابیس‌ها و CDNها به کار رفته را مرور می‌کنم و می‌گویم کجا در طراحی یک سیستم واقعی دردسر درست می‌کند.
  4. 1405.07.03ZooKeeper را هنوز باید بشناسیم؟ هماهنگی در سیستم‌های توزیع‌شدهکافکا دیگر به ZooKeeper نیاز ندارد، ولی مسئله‌ای که ZooKeeper حل می‌کرد سر جایش است. در این نوشته می‌گویم ZooKeeper چطور کار می‌کند، کجا هنوز به کار می‌آید و مهندس ارشد دقیقاً چه چیزی از آن باید بداند.
  5. 1405.07.03کافکا، RabbitMQ یا صف Redis؟ مقایسه با یک مثال سادههر سه ابزار پیام را از یک سرویس به سرویس دیگر می‌رسانند، ولی برای کارهای متفاوتی ساخته شده‌اند. در این نوشته با یک فروشگاه اینترنتی ساده نشان می‌دهم هر کدام کجا به کار می‌آید و کجا نه.
  6. 1405.07.03طراحی سرویس کوتاه‌کننده‌ی لینک: از یک سرور ساده تا مقیاس میلیاردیطراحی URL Shortener پرتکرارترین سؤال مصاحبه‌های طراحی سیستم است. در این نوشته قدم‌به‌قدم از ساده‌ترین نسخه با یک سرور و یک دیتابیس شروع می‌کنیم و تا معماری توزیع‌شده با کش، تولید شناسه‌ی توزیع‌شده، شاردینگ و آنالیتیکس جلو می‌رویم و مزایا و معایب هر مرحله را می‌سنجیم.