سازگاری داده و قضیه CAP
وقتی داده چند کپی دارد، همه کپیها در یک لحظه یکی نیستند. قضیه CAP میگوید وقتی شبکه بین کپیها قطع شود، باید بین «داده همیشه درست» و «همیشه جواب دادن» یکی را انتخاب کنی. برای هر داده، ضعیفترین سطح سازگاری را انتخاب کن که کسبوکار قبول دارد.
نویسنده: bezzad
مشکل: دادهای که چند جا هست
فروشگاه اینترنتی بزرگ شده است. دیتابیس اصلی (Primary) زیر بار خواندن خسته است. تیم یک نسخه کپی فقطخواندنی (Read Replica) اضافه میکند:
- همه نوشتنها به دیتابیس اصلی میروند.
- همه خواندنها از نسخه کپی انجام میشوند.
یک روز این شکایت میرسد: «آدرسم را عوض کردم و پیام موفقیت دیدم. ولی صفحه هنوز آدرس قبلی را نشان میدهد. با چند بار بارگذاری درست شد.»
چه اتفاقی افتاد؟ قدم به قدم:
- مشتری ذخیره را زد. آدرس در دیتابیس اصلی ثبت شد و جواب «موفق» برگشت.
- صفحه بلافاصله دوباره بارگذاری شد و از نسخه کپی خواند.
- کپی شدن داده به نسخه کپی معمولاً غیرهمزمان است. یعنی دیتابیس اصلی منتظر کپی نمیماند. این کار از چند میلیثانیه تا چند ثانیه طول میکشد. به این تأخیر Replication Lag میگویند.
- پس صفحه آدرس قدیمی را دید. چند ثانیه بعد، تغییر رسید و همه چیز درست شد.
هیچ باگی در کد نیست. این ذات داده چندکپی است. همین موضوع بین Microservices هم هست: وقتی سرویس انبار با رویداد از تغییر سفارش خبردار میشود، چند ثانیه عقبتر است.
مثال زنده
یک بار بدون گزینه و یک بار با گزینه، آدرس را عوض کن:
کپی شدن هر تغییر از دیتابیس اصلی به نسخه کپی، ۳ ثانیه طول میکشد.
سطحهای سازگاری
«سازگاری» یک چیز نیست. چند سطح دارد. هر چه سطح قویتر باشد، هزینه بیشتری دارد:
- سازگاری قوی (Strong یا Linearizable). وقتی نوشتن تمام شد، همه خوانندهها مقدار جدید را میبینند. مثل اینکه فقط یک کپی وجود دارد. گرانترین سطح است، چون هر نوشتن باید منتظر بقیه کپیها بماند.
- خواندن نوشته خودم (Read-Your-Writes). هر کاربر تغییر خودش را فوراً میبیند. شاید بقیه کاربرها چند ثانیه دیرتر ببینند. برای مشکل آدرس، همین کافی است.
- خواندن یکنواخت (Monotonic Reads). اگر یک بار مقدار جدید را دیدی، دیگر مقدار قدیمی را نمیبینی. بدون آن، اگر درخواست اول به یک کپی تازه و درخواست دوم به یک کپی عقبمانده برود، داده «به عقب برمیگردد».
- سازگاری نهایی (Eventual Consistency). اگر نوشتن جدیدی نیاید، بالاخره همه کپیها یکی میشوند. ولی معلوم نیست کی. ارزانترین و سریعترین سطح است.
قضیه CAP
قضیه CAP درباره سیستمی است که دادهاش روی چند سرور است. سه ویژگی دارد:
- سازگاری (Consistency). هر خواندن، آخرین نوشتن را میبیند. اینجا منظور همان سازگاری قوی است، نه حرف C در ACID.
- در دسترس بودن (Availability). هر سرور سالم، به هر درخواست جواب میدهد.
- تحمل قطعی شبکه (Partition Tolerance). سیستم حتی وقتی ارتباط بین سرورها قطع است، کار میکند.
قضیه میگوید: وقتی شبکه بین سرورها قطع شود، نمیتوانی هم سازگاری کامل داشته باشی و هم در دسترس بودن کامل. باید یکی را انتخاب کنی.
چرا؟ قدم به قدم:
- فروشگاه دو مرکز داده دارد: تهران و تبریز. هر دو یک کپی از موجودی دارند.
- ارتباط بین آنها قطع میشود.
- در تهران، آخرین کالا فروخته میشود. تبریز از این خبر ندارد.
- مشتری دیگری در تبریز همان کالا را میخواهد. تبریز دو راه دارد:
- رد کند تا ارتباط برگردد. داده غلط نمیدهد، ولی در دسترس نیست. به این CP میگویند.
- قبول کند با دادهای که شاید قدیمی است. در دسترس است، ولی شاید یک کالا دو بار فروخته شود. به این AP میگویند.
سه سوءتفاهم رایج
- «از سه تا، دو تا را انتخاب کن» دقیق نیست. قطعی شبکه در سیستم توزیعشده انتخابی نیست، اتفاق میافتد. پس انتخاب واقعی فقط این است: هنگام قطعی، سازگاری یا در دسترس بودن؟ سیستم «CA» در عمل یعنی سیستمی که روی یک سرور است.
- این انتخاب برای کل سیستم نیست. میشود برای هر داده و هر کار جدا تصمیم گرفت. سبد خرید میتواند AP باشد و پرداخت CP.
- قطعی شبکه نادر است، ولی تأخیر همیشه هست. برای همین یک مدل کاملتر به اسم PACELC هم داریم: اگر قطعی (P) هست، بین A و C انتخاب کن. وگرنه (Else)، بین تأخیر کم (L) و سازگاری (C) انتخاب کن. یعنی حتی در روزهای عادی، سازگاری قویتر یعنی جواب کندتر.
در کسبوکار چه انتخاب کنیم؟
| داده یا کار | انتخاب معقول | چرا؟ |
|---|---|---|
| موجودی حساب قبل از برداشت | سازگاری قوی | برداشت بیشتر از موجودی، ضرر واقعی است. |
| رزرو آخرین صندلی یا آخرین کالا | سازگاری قوی، یا فروش و جبران | بستگی دارد: رد کردن مشتری بدتر است یا عذرخواهی؟ |
| آدرس و پروفایل کاربر | خواندن نوشته خودم | کاربر باید تغییر خودش را ببیند. بقیه عجله ندارند. |
| سبد خرید | در دسترس بودن | نباید هیچ وقت بگوییم «الان نمیشود به سبد اضافه کرد». |
| تعداد بازدید و لایک | سازگاری نهایی | چند ثانیه تأخیر برای کسی مهم نیست. |
| صفحه گزارش مدیر | سازگاری نهایی | داده چند دقیقه قبل هم کافی است. |
سازگاری نهایی بین سرویسها
در Microservices، سرویسها با رویداد هماهنگ میشوند. پس بین آنها همیشه سازگاری نهایی داریم. سه نکته برای زندگی با آن:
- رابط کاربری صادق. بعد از ثبت سفارش، بنویس «در حال پردازش»، نه «تأیید شد». تا وقتی که همه سرویسها کارشان را انجام دهند.
- رویداد تکراری. هر رویداد ممکن است دو بار برسد. پس گیرنده باید Idempotent باشد.
- رویداد با ترتیب غلط. رویداد «آدرس شیراز شد» ممکن است بعد از رویداد جدیدتر «آدرس تبریز شد» برسد. به ساعت سرورها هم نمیشود اعتماد کرد، چون ساعتها دقیقاً با هم یکی نیستند. راه درست: یک شماره نسخه که فقط در یک جا (سرویس صاحب داده) زیاد میشود. گیرنده فقط نسخه جدیدتر را قبول میکند.
کد
خواندن نوشته خودم با EF Core
دو DbContext داریم: یکی به دیتابیس اصلی و یکی به نسخه کپی. بعد از هر نوشتن، یک کوکی کوتاهعمر میگذاریم. تا وقتی این کوکی هست، از دیتابیس اصلی میخوانیم:
builder.Services.AddDbContext<ShopDbContext>(o => o.UseNpgsql(primaryConnection));
builder.Services.AddDbContext<ShopReadDbContext>(o => o.UseNpgsql(replicaConnection));
app.MapPut("/customers/{id:guid}/address", async (
Guid id, AddressDto dto, ShopDbContext db, HttpContext http, CancellationToken ct) =>
{
var customer = await db.Customers.FindAsync([id], ct);
if (customer is null) return Results.NotFound();
customer.ChangeAddress(dto.City, dto.Street);
await db.SaveChangesAsync(ct);
// For the next 10 seconds, this user reads from the primary.
http.Response.Cookies.Append("recent-write", "1",
new CookieOptions { HttpOnly = true, MaxAge = TimeSpan.FromSeconds(10) });
return Results.NoContent();
});
app.MapGet("/customers/{id:guid}", async (
Guid id, HttpContext http, ShopDbContext primary, ShopReadDbContext replica, CancellationToken ct) =>
{
IQueryable<Customer> customers = http.Request.Cookies.ContainsKey("recent-write")
? primary.Customers
: replica.Customers;
var customer = await customers.AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
return customer is null ? Results.NotFound() : Results.Ok(customer);
});
این کار ساده است و بیشتر بار خواندن را روی نسخه کپی نگه میدارد. عدد ۱۰ ثانیه باید از تأخیر معمول کپی بیشتر باشد. پس تأخیر کپی را مانیتور کن و برایش هشدار بگذار.
قبول کردن فقط نسخه جدیدتر
سرویس سفارش یک کپی محلی از آدرس مشتری دارد. با رویداد بهروز میشود:
public sealed record CustomerAddressChanged(Guid CustomerId, string City, long Version);
public sealed class CustomerAddressChangedHandler(OrderDbContext db)
{
public Task HandleAsync(CustomerAddressChanged e, CancellationToken ct) =>
// Old or duplicate events match no row, so they change nothing.
db.CustomerCopies
.Where(c => c.Id == e.CustomerId && c.Version < e.Version)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.City, e.City)
.SetProperty(c => c.Version, e.Version), ct);
}
این یک دستور بهروزرسانی شرطی است. اگر رویداد قدیمیتر یا تکراری باشد، هیچ ردیفی با شرط جور نمیشود و هیچ چیز عوض نمیشود. پس این کد هم ترتیب غلط را حل میکند و هم پیام تکراری را.
قانونهای مهم
- برای هر داده جدا تصمیم بگیر. یک سطح سازگاری برای کل سیستم، یا گران است یا خطرناک.
- تصمیمهای مهم را روی داده تازه بگیر. چک موجودی قبل از برداشت، چک دسترسی و هر خواندنی که بعدش مینویسی، باید از دیتابیس اصلی باشد.
- کاربر باید تغییر خودش را ببیند. این کمترین انتظار کاربر است.
- ترتیب را با شماره نسخه بفهم، نه با ساعت. ساعت سرورهای مختلف دقیقاً یکی نیست.
- تأخیر کپی را اندازه بگیر. بدون عدد، نمیدانی سازگاری نهایی یعنی یک ثانیه یا یک ساعت.
- رابط کاربری را صادق طراحی کن. «در حال پردازش» بهتر از «تأیید شد» است که بعداً لغو شود.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| همه خواندنها از نسخه کپی | کاربر تغییر خودش را نمیبیند. | خواندن نوشته خودم. |
| چک موجودی حساب از نسخه کپی | برداشت بیشتر از موجودی. | تصمیمهای مهم از دیتابیس اصلی. |
| مرتب کردن رویدادها با ساعت سرورها | ترتیب غلط و گزارش اشتباه. | شماره نسخه یا ترتیب داخل یک Partition در Kafka. |
| فکر کردن به «CAP یعنی دو تا از سه تا» | طراحی بر اساس فرض غلط. | قطعی شبکه همیشه ممکن است. انتخاب بین C و A است. |
| سازگاری قوی برای همه چیز | سیستم کند و حساس به قطعی. | ضعیفترین سطحی که کسبوکار قبول دارد. |
| نشان دادن «تأیید شد» قبل از هماهنگ شدن سرویسها | مشتری فکر میکند کار تمام است. | وضعیت «در حال پردازش». |
سازگاری نهایی مناسب است
- داده نمایشی مثل تعداد بازدید و پیشنهاد محصول.
- کپی داده بین Microservices.
- گزارشها و جستجو.
سازگاری نهایی خطرناک است
- پول، موجودی حساب و پرداخت.
- شمارندههایی که نباید از حد بگذرند، مثل آخرین کالا.
- دسترسیها، مثلاً بعد از گرفتن دسترسی یک کاربر.
خلاصه در شش خط
- وقتی داده چند کپی دارد، کپیها با تأخیر یکی میشوند.
- سطحهای سازگاری از قوی تا نهایی هستند. هر چه قویتر، کندتر و گرانتر.
- قضیه CAP میگوید هنگام قطعی شبکه، باید بین سازگاری و در دسترس بودن یکی را انتخاب کنی.
- حتی بدون قطعی، سازگاری قویتر یعنی تأخیر بیشتر (PACELC).
- برای هر داده جدا تصمیم بگیر و بگذار هر کاربر تغییر خودش را فوراً ببیند.
- بین سرویسها، با شماره نسخه و گیرنده Idempotent با سازگاری نهایی زندگی کن.