کاربرد Lock/قفل در برنامه نویسی وب و دیتابیس (2)

استفاده از قفل بستگی به جزییات هر موردی داره. و برای قفل کردن هم چند روش هست که هرکدوم خصوصیات و کاربرد خودشون رو دارن.
فکر کنم از پروژهء خودم مثال بزنم بهتر باشه.
من یک بار پروژهء سیستم رجیستر و لاگین قدیمی خودم رو با یک نرم افزار اسکنر امنیتی بنام acunetix اسکن میکردم. خب این نرم افزار میامد و فرمها رو بصورت خودکار پر و سابمیت میکرد (اون موقع هنوز برای فرم ثبت نام کپچا نذاشته بودم).

بعد رفتم دیتابیس رو نگاه کردم دیدم دوتا از اکانتهایی که acunetix رجیستر کرده یک ایمیل یکسان دارن، درحالیکه برنامهء من نباید اجازه میداد دو اکانت با یک ایمیل یکسان ایجاد بشه.

خب یخورده فکر کردم و به این نتیجه رسیدم که دلیل این قضیه همون قفل نکردن جدول بوده.

من موقع پردازش درخواست ثبت نام اول یک کوئری میدم که چک میکنه ایمیل موردنظر در دیتابیس ثبت شده یا نه، و بعد اگر ایمیل ثبت نشده بود، کوئری اینزرت اجرا میشه و اکانت با ایمیل موردنظر رو در دیتابیس درج میکنه.

خب حالا اگر دو درخواست بصورت تقریبا همزمان وارد سرور بشن که هردو متقاضی رجیستر کردن کاربری با ایمیل mohammad@example.com هستن، ممکنه کوئری چک کردن یکتا بودن ایمیل هردوتاشون قبل از اینکه کوئری اینزرت هیچکدام اجرا شده باشه اجرا بشه. بنابراین هردو درخواست پذیرفته میشن، چون ایمیل مورد نظر در دیتابیس وجود نداشته، اما طبیعتا بعد از اجرای کوئری های اینزرت این دو درخواست ما دو رکورد اکانت با یک ایمیل یکسان خواهیم داشت.

برای جلوگیری از این قضیه میتونیم جدول رو قفل کنیم.
راه سادش قفل کردن فیزیکی هست به این شکل:

lock tables `accounts` write

select 1 from `accounts` where `email`=...

insert into `accounts` ...

unlock tables

البته من شبه کد و کلیتش رو نشون دادم و جزییاتش رو که خودتون میدونید. مثلا اگر کوئری اول رکوردی برگردونه به معنای اینه که ایمیل قبلا در دیتابیس ثبت شده و دیگه کوئری دوم (اینزرت) اجرا نمیشه.

هر زمان فقط یک درخواست میتونه قفل write روی جدول داشته باشه. بنابراین هیچوقت دوتا درخواست ما همزمان نمیتونن از دیتابیس بخونن و یکتا بودن ایمیل رو چک کنن. درخواستهای دیگه اینقدر منتظر میمونن (پشت کوئری lock tables `accounts` write گیر میکنن) تا کار درخواستی که قفل رو داره تموم بشه و قفل رو آزاد کنه. بعد دوباره فقط یک درخواست قفل رو میگیره و بقیه توی صف انتظار میمونن و به همین شکل الی آخر.

فکر میکنم روشن باشه که کوئری اینزرت هم باید قبل از آزاد شدن قفل اجرا بشه. چون اگر اینطور نباشه بازم ممکنه دو یا چند درخواست قبل از اینکه کوئری اینزرت اونا اجرا بشه قفل رو به نوبت بگیرن و کوئری چک یکتا بودن ایمیل اونها اجرا بشه.

و اما یک روش دیگر قفل کردن اینه که ما کل جدول رو قفل نکنیم. بلکه مثلا فقط رکورد خاصی یا برای عملیات/بخش خاصی جدول رو قفل کنیم.
مثلا الان در مدتی که جدول accounts قفله، درخواستهای لاگین هم باید در صف انتظار باقی بمونن، درحالیکه عملیات لاگین هیچ ربطی به ثبت نام نداره و رکورد جدیدی در جدل درج نمیکنه و بنابراین با عملیات رجیستر امکان تداخل نداره. بنابراین شاید ما بخوایم برای اینکه سایتمون در ترافیک های سنگین پرفورمنس بهتری داشته باشه، جدول رو فقط برای درخواستهای ثبت نام قفل کنیم و درخواستهای لاگین بتونن همچنان بصورت همزمان از جدول بخونن و به کار خودشون برسن.

من برای این هدف از یک چیزی استفاده کردم که بهش Advisory lock گفته میشه.
در Advisory lock ما جدول رو بصورت فیزیکی/توسط MySQL قفل نمیکنیم، بلکه در سطح منطق برنامهء خودمون این کار رو میکنیم.

فکر کنم با مثال بهتر بشه توضیح داد:

select get_lock('register', -1)

select 1 from `accounts` where `email`=...

insert into `accounts` ...

select release_lock('register')

همونطور که میبینی فقط دستورهای قفل عوض شدن.
بجای دستور lock tables از تابع get_lock مای اس کیو ال استفاده کردیم. و برای آزاد کردن قفل هم از تابع مخصوص این کار.
عبارت register که برای اسم قفل بکار بردیم انتخاب خود ماست و میتونه هرچیزی باشه. چون ما در کد ثبت نام خودمون برای عمل ثبت نام از این نوع قفل استفاده کردیم بنابراین اسم قفل رو register گذاشتیم.

فرق این نوع قفل با قفل قبلی اینه که جدول در سطح MySQL قفل نمیشه و کدهایی که قبل از اجرای کوئری خودشون select get_lock(‘register’, -1)‎ رو اجرا نمیکنن بلاک نمیشن و میتونن بصورت همزمان از دیتابیس بخونن یا حتی بنویسن. بنابراین درخواستهای لاگین که این دستور در کد اجرا شده برای اونها وجود نداره با محدودیتی در دسترسی به جدول accounts مواجه نمیشن.

درواقع در اینجا خود دستور select get_lock(‘register’, -1)‎ هست که بلاک میشه، و ارتباطی با خود جدولها نداره. میشه گفت اون اسم قفل هست که قفل میشه و برای گرفتنش نیاز هست درخواستی که قبلا اون رو گرفته اون رو آزاد کنه.

امیدوارم توضیحات روشن بوده باشه.
اگر چیزی رو متوجه نشدید یا توضیحات و جزییات بیشتری خواستید بگید.

راستی نکتهء مهم عملیاتی و امنیتی اینه که این نوع قفل و اسم قفلها در سطح کل سرور MySQL و بصورت گلوبال هستن. یعنی این نام قفل برای تمام برنامه ها و کل سایتهای یک سرور قفل میشه. بنابراین برای جلوگیری از تداخل غیرعمدی (یا عمدی!) برنامه ها و سایتهای دیگر روی سرور که ممکنه از نام قفل یکسانی برای عملیات خودشون استفاده کنن، باید یک رشتهء یکتایی رو به اسم قفل اضافه کنیم تا نام قفل یکتا باشه و امکان تداخل اون با برنامه ها و سایتهای دیگر وجود نداشته باشه.
بطور مثال بنده اینطور عمل کردم:

$lockname='reg8log--register--'.$site_key;
$reg8log_db->query("select get_lock('$lockname', -1)");

اصل کار در اینجا متغییر site_key هست که به انتهای اسم قفل اضافه شده. site_key یک رشتهء رندوم طولانی هست که موقع نصب برنامه بصورت خودکار تولید و در دیتابیس برنامه ذخیره شده.
تاجاییکه من دیدم، در MySQL تابعی وجود نداره که اسم قفلهای گرفته شده رو بده (که انتظار منطقی هم همین بود)، و بنابراین فرضا مالک یک سایت دیگر که روی سرور مشترکی با ما هست نمیتونه اسم قفل ما رو بفهمه تا با گرفتن اون قفل و آزاد نکردنش باعث حملهء DOS به سایت ما بشه.
البته باید رشتهء رندوم تعداد حالتهای بقدر کافی زیادی داشته باشه و با یک تابع رندوم امن تولید شده باشه تا قابل حدس یا Brute-force نباشه.
من از یک رشتهء رندوم 43 کاراکتری متشکل از حروف بزرگ و کوچک و اعداد استفاده کردم، ولی از نظر تئوریک یک رشتهء 22 کاراکتری هم باید کاملا کافی باشه (چون تعداد حالتهاش کمتر از 2 به توان 128 نیست).
ضمنا این قضیهء امکان حملهء DOS با استفاده از قفل کردن اسم قفل برنامه های دیگران چیزی بود که به ذهن خودم رسید و جایی چیزی درموردش نخوندم. بهرحال بنظرم این امکان فقط روی هاستهای اشتراکی وجود داره.

———————

البته اینهایی که گفتم تنها روش و سناریوی قفل ها نبودنا. بازم بیشتر از این جزییات و سناریو و روش میتونه وجود داشته باشه.
مثلا قفل در سطح رکورد هم خیلی جاها کاربرد داره. یعنی اینکه بخوایم دسترسی به یک رکورد خاص رو قفل کنیم، و نه دسترسی به کل جدول رو.
انجین MyISAM از قفل فیزیکی در سطح رکورد پیشتیبانی نمیکنه، ولی در بعضی موارد میشه با استفاده از Advisory lock قفل در سطح رکورد رو شبیه سازی کرد.

ضمنا این اصطلاح «فیزیکی» که بکار میبرم رو خودم ساختم. شاید اصطلاح رسمی و صحیح تری داشته باشه.

6 دیدگاه در “کاربرد Lock/قفل در برنامه نویسی وب و دیتابیس (2)

  1. بازپینگ: علم خوره

  2. سلام.
    خب حالا اگر دو درخواست بصورت تقریبا همزمان وارد سرور بشن که هرکدوم متقاضی رجیستر کردن کاربری با ایمیل های یکتا هستن،
    چه اتفاقی میافته!
    یکی از درخواست ها به دلیل lock کردن ثبت نمیشه.
    اگه غلط میگم تایید کنید.
    درصورت درست بودن
    فک میکنم از صف درون دیتابیس استفاده شه مشکل حل بشه.

    • خیر پردازش درخواست دوم بصورت موقتی پشت lock میمونه (اجرای برنامه در اون نقطه دستور گرفتن قفل، بصورت موقتی به حالت تعلیق درمیاد) و وقتی درخواست اول کارش تموم شد و قفل رو آزاد کرد، درخواست دوم قفل رو میگیره و کارش رو انجام میده. این زمان انتظار بطور معمول زمان بسیار کوتاهی است، مثلا در حد چند صدم ثانیه، مگر اینکه مشکل غیرعادی در جایی باشه و مثلا ترافیک دیتابیس بسیار سنگین باشه و درخواستهای زیادی بطور همزمان منتظر قفل باشن که این زمان بخواد محسوس و یا مشکل ساز بشه.

  3. سلام

    یک سوال مهم داشتم اگر لطف کنید جواب بدید ممنون میشم.

    این قضیه قفل کردن برای اجرای مثلا دو تا کوئری، کوئری اول برای insert و کوئری دوم برای select آخرین id اضافه شده در اون جدول روش مناسبی (مناسب تر) هستش یا میشه از lastInsertId() مثلا در pdo هم استفاده کردش؟

    منظورم اینه که ضریب خطا کدوم روش در کوئری های همزمان کمتر هستش؟؟

    با تشکر

  4. سلام دوباره

    من اینجوری نوشتمش :


    $pdo->query("SELECT GET_LOCK('register',-1)");
    $pdo->beginTransaction();
    $sql = "insert into tbl1(num) values (?)";
    $stmt = $pdo->prepare($sql);
    if($stmt->execute(array(4))){
    $stmt = $pdo->query("SELECT LAST_INSERT_ID()");
    $last_id = $stmt->fetchColumn(0);
    $pdo->commit();
    $pdo->query("SELECT RELEASE_LOCK('register')");
    echo $last_id;
    }

    قفل جلو اجرا شدن کوئری مشابه و در نتیجه اضافه شدن رکورد جدید به جدول میگیره اما id خود به خود اضافه میشه.

    من دریافت آخرین id بعد از عمل insert خیلی برام مهمه و نمیخوام بخاطر تداخل زمانی چند تا درخواست مشابه id اشتباه برام برگرده، بخاطر همین میخوام از قفل استفاده کنم، اما نتیجه این کار درست نبود. راه حلی دارید؟

    با تشکر

پاسخ دهید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

*

شما می‌توانید از این دستورات HTML استفاده کنید: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>