پیش از این در مقاله مدیریت همزمانی (Concurrency Management) در Entity Framework Core در خصوص کلیات بحث مدیریت همزانی صحبت شد.
حال میخواهیم در خصوص یک بخش تخصصی از مدیریت همزمانی Optimistic Concurrency بصورت دقیقتر صحبت کنیم.
در ادامه، این موضوع را در قالب ۵ مقاله تخصصی و کاربردی برای یک پروژه واقعی داتنتی بررسی میکنیم.
در دنیای دیتابیس، Deadlock زمانی رخ میدهد که دو تراکنش منتظر آزاد شدن منابع یکدیگر هستند. روشهای خوشبینانه با حذف قفلهای طولانیمدت، ریسک Deadlock را به شدت کاهش میدهند.
زیرساخت فنی در داتنت
برای پیادهسازی این ۴ روش، ابتدا باید دیتابیس متوجه شود که داده تغییر کرده است. در Entity Framework Core، ما از Concurrency Token استفاده میکنیم. رایجترین راه، استفاده از یک ستون RowVersion از نوع byte[] است:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
[Timestamp] // این فیلد در SQL Server به صورت rowversion مدیریت میشود
public byte[] RowVersion { get; set; }
}
زمانی که میخواهید دادهای را آپدیت کنید، EF Core مقدار RowVersion اصلی را در دستور WHERE قرار میدهد. اگر نسخه دیتابیس با نسخه شما متفاوت باشد، خطای DbUpdateConcurrencyException رخ میدهد. این نقطه شروع تمام ۴ استراتژی ماست.
روش Client Wins که به آن "Last In Wins" هم میگویند، سادهترین (و گاهی خطرناکترین) روش است. در این سناریو، ما فرض میکنیم دادههای کلاینت فعلی درستتر هستند و باید روی دادههای قبلی بازنویسی شوند.
نحوه پیادهسازی در داتنت
وقتی خطا رخ میدهد، ما مقادیر دیتابیس را با مقادیر کلاینت جایگزین کرده و دوباره تلاش میکنیم:
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
// مقادیر فعلی دیتابیس را میگیریم
var databaseValues = entry.GetDatabaseValues();
if (databaseValues == null)
{
throw new Exception("این رکورد توسط کاربر دیگری حذف شده است.");
}
// مقادیر اصلی را با مقادیر جدید دیتابیس جایگزین میکنیم تا EF فکر کند داده را تازه خوانده
entry.OriginalValues.SetValues(databaseValues);
// حالا دوباره ذخیره میکنیم؛ این بار مقدار کلاینت برنده میشود
await _context.SaveChangesAsync();
}
نکته: این روش برای فیلدهایی مثل "توضیحات پروفایل" مناسب است، اما برای "موجودی حساب" فاجعهبار است!
در روش Store Wins، اگر تداخلی پیش بیاید، تغییرات کلاینت نادیده گرفته شده و دادههای فعلی دیتابیس حفظ میشوند. این روش زمانی کاربرد دارد که دادههای دیتابیس به دلیل حساسیت بالا (مثل وضعیت یک سفارش حساس) نباید به راحتی بازنویسی شوند.
نحوه پیادهسازی در داتنت
در این حالت، ما فقط نیاز داریم به کاربر اطلاع دهیم که دادهها تغییر کردهاند و تغییرات او اعمال نشد:
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
// تازه کردن دادههای موجود در حافظه با دادههای واقعی دیتابیس
await entry.ReloadAsync();
// اینجا معمولاً به کاربر پیامی نمایش میدهیم:
// "اطلاعات توسط شخص دیگری ویرایش شده است. لطفا دوباره بررسی کنید."
throw new Exception("تغییرات شما ذخیره نشد چون دادهها توسط فرد دیگری آپدیت شده بودند.");
}
هوشمندانهترین و پیچیدهترین روش، Merge Save است. در این روش، اگر کاربر A فیلد "نام" را عوض کرده باشد و کاربر B فیلد "تلفن" را، سیستم هر دو را با هم ادغام میکند.
نحوه پیادهسازی
ما باید فیلد به فیلد مقایسه انجام دهیم:
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var clientValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
var originalValues = entry.OriginalValues;
foreach (var property in clientValues.Properties)
{
var clientValue = clientValues[property];
var databaseValue = databaseValues[property];
var originalValue = originalValues[property];
// اگر کلاینت این فیلد خاص را تغییر داده باشد، آن را نگه میداریم
// در غیر این صورت، از مقدار دیتابیس استفاده میکنیم
if (Equals(clientValue, originalValue))
{
clientValues[property] = databaseValue;
}
}
// آپدیت مقدار RowVersion برای تایید نهایی
entry.OriginalValues.SetValues(databaseValues);
await _context.SaveChangesAsync();
}
در این روش، هدف این است که «کار هیچکس ضایع نشود». سیستم بهجای اینکه کل تغییرات کاربر دوم را دور بریزد، بررسی میکند که او دقیقاً کدام «فیلدها» را تغییر داده است.
اگر این دو کاربر روی فیلدهای متفاوتی کار کرده باشند، سیستم تغییرات هر دو را با هم ترکیب (Merge) کرده و در دیتابیس ذخیره میکند.
برای درک بهتر، بیایید این سناریو را مرحلهبهمرحله جلو ببریم:
سناریوی دنیای واقعی: ویرایش پروفایل مشتری
فرض کنید اطلاعات یک مشتری در دیتابیس به این صورت است:
نام: علی
تلفن: ۱۲۳
آدرس: تهران
۱. شروع همزمان:
کاربر A و کاربر B همزمان صفحه ویرایش این مشتری را باز میکنند. هر دو نسخه یکسانی از دادهها را میبینند.
۲. تغییر توسط کاربر A:
کاربر A فقط تلفن را به «۴۵۶» تغییر میدهد و دکمه ذخیره را میزند. چون او اولین نفر است، دیتابیس بدون مشکل آپدیت میشود.
وضعیت دیتابیس: نام: علی، تلفن: ۴۵۶، آدرس: تهران
۳. تغییر توسط کاربر B (ایجاد تداخل):
کاربر B که هنوز صفحه قبلی را باز دارد (و فکر میکند تلفن ۱۲۳ است)، فقط آدرس را به «شیراز» تغییر میدهد و دکمه ذخیره را میزند.
در این لحظه DbUpdateConcurrencyException رخ میدهد چون دیتابیس متوجه میشود نسخه دادهای که کاربر B دارد، قدیمی است.
۴. جادوی Merge Save:
سیستم در بخش catch کد شما، سه نسخه از داده را با هم مقایسه میکند:
Original Values: (نام: علی، تلفن: ۱۲۳، آدرس: تهران) -> چیزی که کاربر B در ابتدا دیده بود.
Current (Client) Values: (نام: علی، تلفن: ۱۲۳، آدرس: شیراز) -> چیزی که کاربر B الآن میخواهد ذخیره کند.
Database Values: (نام: علی، تلفن: ۴۵۶، آدرس: تهران) -> چیزی که کاربر A قبلاً ذخیره کرده است.
منطق Merge:
آیا کاربر B «نام» را عوض کرده؟ خیر (علی = علی). پس همان مقدار جدید دیتابیس (علی) را نگه دار.
آیا کاربر B «تلفن» را عوض کرده؟ خیر (۱۲۳ = ۱۲۳). پس مقدار جدید دیتابیس (۴۵۶) را جایگزین کن.
آیا کاربر B «آدرس» را عوض کرده؟ بله (تهران تبدیل شده به شیراز). پس مقدار کلاینت (شیراز) را نگه دار.
۵. نتیجه نهایی در دیتابیس:
نام: علی، تلفن: ۴۵۶، آدرس: شیراز
یک چالش مهم: اگر هر دو یک فیلد را تغییر دهند چه؟
این تنها جایی است که Merge Save به تنهایی کافی نیست. اگر کاربر A تلفن را به «۴۵۶» تغییر داده باشد و کاربر B همزمان آن را به «۷۸۹» تغییر دهد، سیستم نمیتواند حدس بزند کدام درست است. در این حالت معمولاً سیستم به یکی از دو روش زیر عمل میکند:
اولویت با آخرین نفر: (تلفن میشود ۷۸۹).
توقف و سوال از کاربر: نمایش یک پنجره که میگوید: «کاربر دیگری تلفن را به ۴۵۶ تغییر داده، آیا میخواهید مقدار ۷۸۹ را روی آن بازنویسی کنید؟»
در پروژههای بزرگ (مثل سیستمهای ERP یا CRM)، کاربران معمولاً بخشهای مختلف یک فرم بزرگ را پر میکنند. با این روش، آنها اصلاً متوجه نمیشوند که تداخلی رخ داده و بدون ایجاد مزاحمت برای یکدیگر، کارشان را انجام میدهند.
روش Retry Save در واقع یک الگوی ترکیبی است. در این روش، به جای تسلیم شدن یا بازنویسی کورکورانه، ما منطق بیزنس را دوباره روی دادههای جدید اجرا میکنیم. مثلاً اگر کاربر میخواهد ۱۰ واحد از انبار کم کند، ما موجودی جدید را میخوانیم و دوباره عمل تفریق را انجام میدهیم.
پیادهسازی با استفاده از حلقه و Polly
بهترین راه برای این کار در پروژههای بزرگ، استفاده از کتابخانه Polly یا یک حلقه ساده است:
int retryCount = 0;
bool saved = false;
while (!saved && retryCount < 3)
{
try
{
await _context.SaveChangesAsync();
saved = true;
}
catch (DbUpdateConcurrencyException ex)
{
retryCount++;
var entry = ex.Entries.Single();
var databaseValues = await entry.GetDatabaseValuesAsync();
// منطق بیزنس را دوباره اعمال کن:
// مثلا اگر موجودی انبار است، دوباره چک کن که منفی نشود
// ... کدهای محاسباتی ...
entry.OriginalValues.SetValues(databaseValues);
}
}
بیائید با یک مثال ملموس در سیستم فروشگاهی (موجودی انبار) پیش برویم تا ببینی در آن بخش «کدهای محاسباتی» دقیقاً چه اتفاقی میافتد.
سناریو: فروشگاه آنلاین (موجودی گوشی آیفون)
۱. وضعیت اولیه در دیتابیس: تعداد موجودی در انبار: ۱۰ عدد. ۲. کاربر A: میخواهد ۶ عدد بخرد. (او عدد ۱۰ را در صفحه خود میبیند). ۳. کاربر B: همزمان میخواهد ۵ عدد بخرد. (او هم عدد ۱۰ را میبیند).
گردش عملیات در کد شما:
مرحله ۱: کاربر A برنده میشود
کاربر A دکمه خرید را میزند. سیستم ۱۰ را به ۴ تغییر میدهد و ذخیره میکند. موفقیتآمیز!
موجودی انبار در دیتابیس شد: ۴ عدد.
مرحله ۲: تداخل برای کاربر B
کاربر B دکمه خرید را میزند. اما کد او هنوز فکر میکند موجودی ۱۰ است و میخواهد آن را به ۵ (۱۰ منهای ۵) تغییر دهد. در لحظه _context.SaveChangesAsync()، سیستم متوجه میشود که RowVersion تغییر کرده و خطای DbUpdateConcurrencyException را پرتاب میکند.
مرحله ۳: ورود به بلاک catch و اعمال مجدد منطق (Retry)
حالا کد شما وارد عمل میشود:
گرفتن مقدار جدید: کد شما مقدار واقعی دیتابیس (عدد ۴) را میخواند.
اجرای مجدد منطق بیزنس: سیستم دوباره چک میکند: آیا کاربر B که ۵ عدد میخواست، هنوز هم میتواند بخرد؟
محاسبه جدید: ۴ (موجودی جدید) - ۵ (درخواست کاربر B) = -۱.
تصمیمگیری: چون موجودی منفی میشود، سیستم نباید انبار را بروزرسانی کند. بلکه باید به کاربر پیام بدهد: «متأسفیم، موجودی انبار تمام شد».
پیادهسازی کد اصلاح شده برای این سناریو:
در اینجا میبینی که «منطق بیزنس» چطور دوباره اجرا میشود:
پاسخ به سوالات:
اگر منفی شد انبار بروز میشود؟ خیر! در بخش منطق بیزنس، شما چک میکنید که اگر نتیجه منفی شد، به جای ذخیره کردن، یک Exception بیزنس (مثل InsufficientStockException) پرتاب کنید تا فرآیند متوقف شود.
به چه مقداری بروز میشود؟ اگر موجودی کافی باشد، به مقدارِ «مقدارِ جدیدِ دیتابیس منهایِ درخواستِ کلاینت» بروز میشود. در مثال ما، اگر کاربر B فقط ۲ عدد میخواست: ۴ (جدید) - ۲ = ۲. پس انبار به عدد ۲ بروزرسانی میشد.
خلاصه کلام:
در روش Retry Save، شما فرض میکنید که شکست خوردن در ذخیرهسازی لزوماً به معنای انصراف کاربر نیست؛ بلکه شاید بتوان با دادههای جدید، دوباره همان عملیات را (اگر شرایط مهیا بود) تکرار کرد بدون اینکه کاربر متوجه شود یا نیاز باشد صفحه را رفرش کند.
برنامهنویسها معمولاً از اصطلاح «Stateless» یا «بدون وضعیت» برای وب استفاده میکنند؛ یعنی سرور قلباً نمیداند همین الآن چند نفر دارند یک صفحه را نگاه میکنند. اما وقتی بحث ذخیرهسازی پیش میآید، از یک «نشانه» یا «تِکِت» برای مچگیری استفاده میکند.
در داتنت و EF Core، برنامه از طریق Concurrency Token (معمولاً یک ستون به نام RowVersion) متوجه این موضوع میشود.
این فرآیند دقیقاً چطور کار میکند؟ (گامبهگام)
۱. مرحله مطالعه (Read): اشتراکگذاری تِکِت
وقتی کاربر A و کاربر B صفحه ویرایش یک کالا را باز میکنند، دیتابیس علاوه بر اطلاعات کالا (مثلاً نام و قیمت)، یک عدد یا کد خاص (نسخه) را هم به هر دو میدهد.
دیتابیس: «بفرمایید، این اطلاعات کالا است و نسخه فعلی آن تِکِت شماره ۱۰۰ است.»
حالا هر دو کاربر در مرورگرشان تِکِت شماره ۱۰۰ را دارند.
۲. مرحله تغییر (Modify): در سکوت
کاربران مشغول تایپ کردن هستند. در این مدت سرور اصلاً روحی هم خبر ندارد که آنها دارند چه میکنند. هیچ ارتباطی بین مرورگر و سرور در این لحظه برقرار نیست.
۳. مرحله ذخیره (Save): لحظه مچگیری
اینجاست که جادو اتفاق میافتد. وقتی کاربر A دکمه ذخیره را میزند، EF Core یک دستور SQL به دیتابیس میفرستد که معنیاش این است:
«فیلد قیمت را آپدیت کن، به شرطی که تِکِت فعلی در دیتابیس هنوز ۱۰۰ باشد.»
چون کاربر A اولین نفر است، شرط برقرار میشود. دیتابیس قیمت را عوض میکند و تِکِت را به ۱۰۱ ارتقا میدهد.
۴. شکست کاربر B
حالا کاربر B دکمه ذخیره را میزند. کد او هم دستور مشابهی میفرستد:
«فیلد قیمت را آپدیت کن، به شرطی که تِکِت در دیتابیس هنوز ۱۰۰ باشد.»
اما حالا تِکِت دیتابیس ۱۰۱ شده است! دیتابیس میگوید: «من رکوردی با تِکِت ۱۰۰ پیدا نکردم (چون تغییر کرده)». نتیجه عملیات میشود: Zero rows affected (صفر ردیف تغییر کرد).
۵. شلیک خطا در داتنت
وقتی EF Core میبیند که دستور Update فرستاده ولی هیچ ردیفی در دیتابیس تغییر نکرده، متوجه میشود که «یک نفر دیگر زودتر تِکِت را عوض کرده است». در این لحظه است که خطای معروف DbUpdateConcurrencyException را پرتاب میکند.
خلاصه فنی:
برنامه از قبل نمیداند چند نفر صفحه را باز کردهاند. برنامه در لحظه ذخیره کردن با چک کردن یک ستونِ مخفی (مثل byte[] RowVersion در SQL Server)، متوجه میشود که آیا دادهای که کاربر در دست دارد «بیزات» (Old) است یا «تازه» (Fresh).
در واقع:
کلاینت: «من میخواهم نسخه ۱۰۰ را ویرایش کنم.»
دیتابیس: «دیر آمدی! الآن نسخه دیتابیس ۱۰۱ است.» -> تداخل شناسایی شد.
در یک پروژه واقعی داتنت، شما معمولاً از ترکیبی از اینها استفاده میکنید:
Merge Save برای فرمهای اداری معمولی.
Retry Save برای عملیات محاسباتی و مالی.
Store Wins برای وضعیتهای حساس (Status Codes).
0 نظر
هنوز نظری برای این مقاله ثبت نشده است.