برای درک معماری MySQL بگذارید با یک مثال بسیار ساده شروع کنیم. یک رستوران را در ذهن بیاورید. در این رستوران:
یک) مشتریها وارد رستوران میشوند، میز خود را انتخاب میکنند، از منو سفارش خود را انتخاب میکنند و پاسخ دریافت مینمایند.
دو) گارسون سفارش را دریافت میکند، به بخش مدیریت یا آشپرخانه رستوران میبرد، آشپز چک میکند که آیا قبلا مشابه چنین سفارشی را پخته است یا خیر؟ اگر خیر، برنامهریزی میکند که چطور و با چه موادی این سفارش را آماده کند.
سه) در این رستوران انبار یا سردخانهای وجود دارد که مواد اولیه در آن نگهداری میشوند.
معماری MySQL نیز سه لایهای است و در آن، دقیقا مشابه آنچه که در مثال رستوران گفتیم، بخشهای مجزایی وجود دارد:
معماری MySQL متشکل از سه لایه به شرح زیر است:
هرکسی که وارد یک برنامه میشود (مثل phpMyAdmin یا یک وبسایت) و یک کوئری ارسال میکند، نقش مشتری (کلاینت) را دارد.
در لایه کلاینت مدیریت اتصال، احراز هویت و امنیت توسط ابزارهای شبکه انجام میشود. البته لایه کلاینت فقط مختص MySQL نیست، بلکه هر سیستم کلاینت-سروری نیز همین کار را میکند.
سرور، مغز MySQL است، پس این لایه شامل تجزیهی کوئری (Query Parsing)، تجزیه و تحلیل، بهینهسازی، کش و کلیه توابع توکار (built-in) مانند توابع مربوط به تاریخ، زمان، ریاضیات و غیره است. هر قابلیتی که بین موتورهای ذخیرهسازی مشترک باشد، در این سطح قرار میگیرد. مثلا رویههای ذخیرهشده (stored procedures)، تریگرها و ویوها، همه اینجا تعریف میشوند.
برای درک بهتر عملکرد این لایه، آشپزخانه و مدیریت رستوران را به خاطر بیاورید. تمام تجزیه و تحلیلها و اجرای عملیات پخت و پز در آشپزخانه انجام میشد.
اینجا جایی است که داده در آن واقعا ذخیره و از آنجا بازیابی میشود. موتورهای ذخیرهسازی عهدهدار ذخیره و بازیابی تمام دادههای ذخیرهشده در MySQL هستند.
MyISAM و InnoDB دو نمونه متداول از موتورهای ذخیرهسازی هستند. لازم است بدانید سرور MySQL توسط APIها با موتور ذخیرهسازی ارتباط برقرار میکند، نه بهطور مستقیم.
فرض کنید یک کاربر در یک وبسایت روی دکمه «ورود» کلیک میکند:
لایه اول (کلاینت): مرورگر کاربر به سرور MySQL متصل میشود، رمز عبور کاربر را ارسال میکند و تایید هویت انجام میشود.
لایه دوم (سرور): MySQL جمله SELECT * FROM users WHERE username = 'RezaAhmadi74' را میگیرد، آن را تجزیه میکند، در این مسیر بهترین روش جستجو را انتخاب میکند (مثلاً از ایندکس استفاده میکند) و بررسی میکند که آیا قبلاً هم این درخواست ارسال شده یا نه؟ (کش را چک میکند).
لایه سوم (موتور): سرور از طریق API به InnoDB دستور میدهد که ردیف RezaAhmadi74 را پیدا کند. InnoDB هم از میان فایلهایش روی هارد دیسک، داده را برمیدارد و برمیگرداند.
جواب به لایه دوم برمیگردد، شاید نتیجه در آنجا کش شود و در نهایت به مرورگر کاربر منتقل میشود.
همانطور که ملاحظه میکنید این لایهبندی بسیار هوشمندانه است و کمک میکند که شما بتوانید بدون اینکه لایه اول و دوم متوجه شوند، موتور ذخیرهسازی را تغییر دهید.
همانطور که ملاحظه میکنید این مراحل بسیار شبیه به مراحلی بود که در مثال رستوران در ابتدای مقاله به آن اشاره کردیم.
در پردازش سرور، هر اتصال کلاینت Thread (رشته) مخصوص به خود را دریافت میکند، یعنی هر کلاینتی که به MySQL وصل میشود سرور، یک Thread اختصاصی به او میدهد و کوئریهای هر اتصال در همان Thread اجرا میشوند. فرضا اگر ۲۰۰ نفر وصل شوند، ۲۰۰ تا Thread داخل سرور وجود دارد و همگی موازی با هم کار میکنند (روی هستههای مختلف CPU).
سرور این Threadها را کش میکند، یعنی لازم نیست برای هر ارتباط جدیدی حتما یک Thread جدید ایجاد کند یا Threadهای قبلی را نابود کند. این فرایندِ کش کردن، سرعت را به شدت بالا میبرد.
از MySQL 5.5 به بعد قابلیت Thread Pool به MySQL اضافه شده است. با این قابلیت یک مجموعه کوچک از Threadها میتواند به تعداد زیادی اتصال، سرویسدهی کند. این یعنی میتوانیم بجای 200 تا Thread برای 200 تا مشتری، فقط 20 عدد Thread داشته باشیم که این منجر به بهینهسازی مصرف CPU میشود و سرعت دیتابیس را بشدت بالا میبرد.
MySQL برای اینکه بتواند درخت تجزیه (parse tree) ایجاد کند، کوئری را تجزیه میکند و سپس روی آن انواع بهینهسازیها را انجام میدهد. بهینهسازیهایی اعم از بازنویسی کوئری، تعیین ترتیب خواندن جدولها، انتخاب ایندکسهای مناسب و غیره. به بیان دیگر، کار بهینهساز در MySQL این است که بهترین راه اجرای کوئری را پیدا کند.
نکته جالب این است که شما میتوانید با اضافه کردن hintهای خاص در کوئری خود به بهینهساز راهنمایی دهید و در فرآیندهای تصمیمگیریهای بهینهساز دخیل باشید. همچنین میتوانید از سرور بخواهید برایتان بهینهسازیهایی را که روی کوئری انجام شده است، به شما نشان دهد (دستور EXPLAIN).
بهینهساز اهمیت چندانی نمیدهد که یک جدول خاص از چه موتور ذخیرهسازی استفاده میکند، اما موتور ذخیرهسازی بر نحوه بهینهسازی کوئری تأثیر دارد. به همین دلیل بهینهساز از موتور ذخیرهسازی در مورد قابلیتهایش، هزینه عملیات و دادههای جدول سوال میکند و سپس بر اساس این اطلاعات تصمیم میگیرد چطور کار را انجام دهد.
پیش از تجزیه (parse) کوئری، سرور MySQL کش کوئری را بررسی میکند. اگر یک کوئری دقیقاً مشابه کوئری موجود در کش باشد، سرور دیگر نیازی به تجزیه، بهینهسازی یا اجرای آن کوئری ندارد و فقط مجموعه نتیجه ذخیرهشده را برمیگرداند.
توصیه میشود این مقاله را مطالعه کنید: سرعت کوئریهای MySQL را ۱۰ برابر بالا ببرید
پروتکلی که MySQL برای ارتباط بین کلاینت و سرور استفاده میکند، نیمهدوطرفه است. یعنی در هر لحظه، سرور MySQL یا میتواند پاسخ ارسال کند یا درخواست دریافت کند، اما نمیتواند هر دو را همزمان با هم انجام دهد.
کلاینت، کل کوئری را به شکل یک بسته داده ارسال میکند. (اگر کوئری خیلی بزرگ باشد باید مطمئن شویم که تنظیمات max_allowed_packet به اندازه کافی بزرگ است وگرنه سرور نمیتواند آن بسته را قبول کند.) به محض اینکه کاربر درخواست را ارسال کند، دیگر توپ در زمین او قرار ندارد و باید صبر کند تا سرور پاسخ ارسال کند.
همانطور که گفتیم کلاینت فقط میتواند یک بسته ارسال کند اما سرور میتواند پاسخ را بهصورت چند بسته جداگانه ارسال نماید و کاربر هم باید بهصورت یکجا مجموعه پاسخها را دریافت کند (کاربر نمیتواند وسط دریافت پاسخ بگوید بس است!).
در واقع ما به اشتباه فکر میکنیم کلاینت ردیفها را از سرور میکشد (pull)، در حالیکه این سرور است که ردیفها را به سمت کلاینت هل میدهد (push). پس حتی اگر کوئری یک میلیون داده برگرداند، کاربر چارهای جز دریافت همه ندارد و در چنین شرایطی تنها راه کنترل حجم داده، محدود کردن خود کوئری است، نه قطع کردن ارتباط وسط کار.
اینجاست که اهمیت LIMIT بهعنوان محدود کننده کوئری مشخص میشود. بدون LIMIT ممکن است شبکه هنگ کند، حافظه کلاینت پر شود یا کاربر تا ابدالدهر منتظر بماند!
یادآوری: حتما با مفهوم کش کوئری آشنا هستید. کش کوئری مثل یک دفترچه یادداشت است که جلوی میز سرور قرار دارد. قبل از این که سرور MySQL کوئری را تجزیه (pars) کند، اول کش کوئری را بررسی مینماید (نگاهی به آن دفترچه یادداشت میاندازد). چنانچه کوئری قبلا اجرا شده باشد و نتیجهاش در کش باشد، پایگاه داده همان نتیجه را فورا برمیگرداند و دیگر نیاز نیست بقیه مراحل را اجرا کند؛ مثل یک معادله ریاضی که معلم نتیجهاش را یکبار محاسبه و در دفتر خود یادداشت کرده است و نیاز نیست هربار که یک دانشآموز پاسخ آن معادله را سوال میکند، دوباره محاسباتش را تکرار کند.
MySQL هنگام راهاندازی (startup) سرور، یک مقدار از حافظه RAM را به کش کوئری اختصاص داده و مقداردهی اولیه میکند؛ یعنی مقدار متغیر query_cache_size را میخواند و همان مقدار حافظه را بهعنوان فضایی برای کش کوئری در نظر میگیرد.
خودتان هم میتوانید این متغیر را تغییر دهید مثلا اگر بنویسید query_cache_size = 256Mهنگام استارت، ۲۵۶ مگابایت از RAM سیستم یکجا به کش کوئری اختصاص مییابد.
خطر: اگر در حالی که سرور در حال اجراست (runtime)، مقدار query_cache_size را تغییر دهید (حتی اگر آن را روی مقدار فعلیاش تنظیم کنید)، MySQL بلافاصله تمام کوئریهای ذخیرهشده در کش را حذف میکند، کش را به اندازه ای که مشخص کردهاید، تغییر میدهد و حافظهی کش را دوباره مقداردهی اولیه میکند. این عملیات ممکن است زمانبر باشد زیرا MySQL همهی کوئریهای کششده را یکییکی پاک میکند، نه یکجا. اما چرا خطرناک است؟ چون در تمام این مدت دیتابیس شما از کار میافتد و کاربران خطای timeout دریافت میکنند. البته اگر از MySQL 8.0 استفاده میکنید، نباید نگران این داستان باشید. چون این ویژگی در این نسخه کاملا حذف شده است.
MySQL یک دستور SQL را در سه مرحله زیر به یک برنامه اجرا یا execution plan تبدیل میکند:
مرحله ۱) تجزیه
مرحله ۲) پیشپردازش
مرحله ۳) بهینهسازی
در ادامه این مراحل را برایتان تشریح میکنیم:
تجزیهگر یا Parser کارش این است که کوئری را به توکنها بشکند و از روی آنها درخت تجزیه (parse tree) درست کند. همچنین تجزیهگر بررسی میکند که کوئریها درست و معتبر باشند و ترتیبشان هم صحیح باشد.
پیشپردازنده پس از تجزیهگر وارد عمل میشود و کارش این است که مواردی را بررسی کند که تجزیهگر قادر به تشخیصشان نیست. مثلا بررسی کند که آیا جداول و ستونهایی که به آنها ارجاع شده، واقعا وجود دارند؟ آیا aliasها بهدرستی به ستونها اشاره میکنند یا مبهماند؟ آیا کاربر دسترسیهای لازم برای اجرای کوئری را دارد؟ و غیره
این دو مرحله خیلی سریع اجرا میشوند مگر اینکه در پایگاه داده شما هزاران جدول و تنظیمات پیچیده وجود داشته باشد.
در دو مرحله قبلی درخت تجزیه آماده میشود و حالا باید بهینهساز آن را به یک برنامه اجرای کوئری تبدیل کند.
شما ممکن است از چندین روش مختلف برای اجرای یک کوئری استفاده کنید ولی همه روشها، یک نتیجه را بدهند. وظیفه بهینهساز این است که بهترین روش اجرای کوئری را پیدا کند. MySQL از یک بهینهساز مبتنی بر هزینه استفاده میکند. یعنی تلاش میکند چندین روش اجرا را در نظر گرفته، هزینه هرکدام را محاسبه و ارزانترین روش را انتخاب کند.
منظور از هزینه چیست؟
واحد هزینه در اصل یک خواندن تصادفی از یک صفحه داده ۴ کیلوبایتی بود، اما اکنون پیچیدهتر شده و عواملی مانند هزینه تخمینی اجرای یک عبارت WHERE را نیز شامل میشود.
روش مشاهده هزینه:
SHOW STATUS LIKE 'Last_query_cost';
اگر خروجی فوق مثلا عدد ۱۰ باشد به این معنی است که بهینهساز تخمین زده برای اجرای این کوئری، به اندازه خواندن حدود ۱۰ صفحه تصادفی از دیسک نیاز داریم.
عوامل موثر در تخمین هزینه
تعداد صفحات هر جدول یا ایندکس، کاردینالیتی (تعداد مقادیر متمایز) ایندکسها، طول ردیفها و کلیدها، و توزیع کلیدها در این هزینه موثر هستند.
نکته مهم: بهینهساز فرض میکند هر خواندن = یک I/O دیسک واقعی. اثر کش را در نظر نمیگیرد.
هفت دلیل اصلی وجود دارد که باعث میشود بهینهساز همیشه بهترین برنامه را انتخاب نکند:
۱. MySQL آمارهای اشتباه دریافت میکند: سرور برای آمار به موتور ذخیرهسازی (مثل InnoDB) اتکا میکند اما این آمارها میتوانند کاملا دقیق یا کاملا اشتباه باشند! و این مشکل هم به معماری موتور ذخیرهسازی برمیگردد.
۲. هزینه تخمینی دقیقاً معادل هزینهی واقعی اجرای کوئری نیست: یک برنامه که صفحات بیشتری میخواند ممکن است در برخی موارد ارزانتر باشد، مثلا صفحات پشت سر هم از صفحات تصادفی ارزانترند. همچنین بهینهساز نمیفهمد کدام صفحات از قبل کش شدهاند. بعلاوه اینکه MySQL نمیداند کدام صفحات در حافظه هستند و کدام صفحات روی دیسک، پس نمیتواند بگوید کوئری دقیقا چقدر I/O ایجاد خواهد کرد.
۳. تعریف MySQL از «بهینه» ممکن است با تعریف شما متفاوت باشد: منظور ما از «بهینه»، معمولا «سریعتر» است. ما بهدنبال سریعترین زمان اجرا هستیم اما MySQL به دنبال ارزانترین روش است و این دو الزاما یکی نیستند.
۴. MySQL کوئریهای دیگری را که همزمان در حال اجرا هستند در نظر نمیگیرد: دیتابیس همیشه فقط یک کوئری را اجرا نمیکند. ممکن است صدها کوئری همزمان با هم در حال اجرا باشند و عملکرد یکدیگر را تحت تاثیر قرار دهند. بهینهساز این مسئله را در نظر نمیگیرد.
۵. MySQL گاهی از قوانین پیروی میکند، نه هزینه: بهطور مثال اگر عبارت MATCH() AGAINST() را ببیند، حتما از ایندکس FULLTEXT استفاده میکند، حتی اگر یک ایندکس معمولی + WHERE سریعتر عمل کند.
۶. هزینه توابع ذخیرهشده را محاسبه نمیکند: اگر کوئری شما از یک تابع (stored function یا UDF) استفاده کند، بهینهساز نمیداند اجرای آن تابع چقدر زمان میبرد و بهصورت پیشفرض هزینهاش را صفر در نظر میگیرد. در واقع هزینه عملیاتی که تحت کنترل خودش نیستند را نادیده میگیرد.
۷. همه روشهای ممکن را بررسی نمیکند: تعداد روشهای اجرای یک کوئری میتواند خیلی خیلی زیاد باشد. بهینهساز برای صرفهجویی در زمان، همه روشها را بررسی نمیکند و از اینرو ممکن است روش بهینه را از دست بدهد.
برنامه اجرا درست مانند یک دستورالعمل گامبهگام یا یک نمودار درختیست که دقیقا به MySQL میگوید چطور کوئری را اجرا کند.
نکته مهم: بعضی از پایگاههای داده (مثل SQL Server یا Oracle) کوئری را به بایتکد تبدیل میکنند (مثل همان کاری که جاوا با کدها انجام میدهد). اما MySQL چنین کاری نمیکند. در عوض، یک نمودار درختی از دستورالعملها میسازد و موتور اجرا دقیقاً از همان نمودار تبعیت میکند.
برنامه اجرا آنقدر اطلاعات دربردارد که میتواند کوئری اصلی را کاملا بازسازی کند. اگر دستور EXPLAIN EXTENDED را روی یک کوئری اجرا کنید و بعد SHOW WARNINGS را فراخوانی کنید، کوئری بازسازیشده را خواهید دید. این خروجی از نظر معنایی با کوئری اصلی یکسان است اما این دو لزوما متن مشابهی ندارند.
EXPLAIN EXTENDED SELECT * FROM users WHERE id = 5;
SHOW WARNINGS;
کار موتور اجرا چیست؟ موتور اجرا باید تمام دستورالعملهایی را که در برنامهی اجرا (Execution Plan) هستند، یکییکی اجرا کند، تا وقتی که هیچ ردیفی برای بررسی باقی نمانده باشد.
قبلتر هم اشاره کردیم موتور اجرا مستقیما به داده دسترسی ندارد و هر دفعه که نیاز به خواندن یا نوشتن داده دارد، از طریق فراخوانی API با موتور ذخیرهسازی (مثل InnoDB) ارتباط برقرار میکند.
در جدول زیر دو وظیفه اصلی موتور اجرای کوئری را ملاحظه کنید:
| وظیفه | شرح |
|---|---|
| توزیعکننده (Dispatcher) | هر دستوری را که در برنامهی اجرا هست، به بخش مربوطه میفرستد |
| حلقهزدن (Iteration) | بارها و بارها دستورات را اجرا میکند تا تمام ردیفها پردازش شوند |
MySQL هر پایگاه داده (یا همان schema) را به عنوان یک پوشه (subdirectory) درون پوشه اصلی دادههایش ذخیره میکند.
وقتی شما یک جدول ایجاد میکنید، MySQL یک فایل با پسوند .frm تولید میکند و مشخصات جدول (اسم ستونها، نوعدادهها، محدودیتها و غیره) در آن فایل ذخیره میشوند. مثلا اگر جدولی به نام users بسازید، فایل users.frm در پوشه همان دیتابیس ساخته میشود. (از دستور SHOW TABLE STATUS استفاده کنید تا مشخصات مربوط به جداول برایتان نمایش داده شوند.)
نکته: حساسیت به حروف بزرگ/کوچک بستکی به سیستم عاملتان دارد. مک و لینوکس به بزرگی/کوچکی حروف حساسند ولی ویندوز اینطور نیست.
آخرین کاری که سرور انجام میدهد و در واقع آخرین مرحله از فرآیند اجرای یک کوئری، فرستادن پاسخ به کلاینت است.
حتی کوئریهایی مثل INSERT یا UPDATE که فاقد نتیجه هستند، باز هم یک پاسخ شامل اطلاعاتی از کوئری را میفرستند: مثلا «۳ ردیف تحت تأثیر قرار گرفت».
اگر کوئری قابل کش شدن باشد، در این مرحله سرور نتیجه را در کش ذخیره میکند.
مسئله مهم اینست که سرور نتایج را بهصورت افزایشی (incremental) تولید و ارسال میکند. یعنی بلافاصله پس از آنکه MySQL آخرین جدول را پردازش و اولین ردیف را تولید کرد، آن را برای کلاینت میفرستد و لازم نیست صبر کند تا همه ردیفها آماده شوند. این موضوع منجر به صرفهجویی در حافظه میشود یعنی سرور مجبور نیست همه ردیفها را در حافظه نگه دارد. از طرفی باعث میشود کاربر بتواند زودتر شروع به دیدن نتایج کند و تجربه کاربری را ارتقا میدهد.
در این مقاله سعی داشتیم نگاه کامل و جامعی به معماری MySQL و نحوه عملکرد آن داشته باشیم. شما به عنوان یک توسعهدهنده باید با ساختار و عملکرد این دیتابیس محبوب، آشنایی داشته باشید. ضروری است که بدانید لایههای مختلف معماری مایاسکیوال کدامند و با تمام مراحل فرآیند اجرای یک کوئری آشنا باشید. همچنین باید بر مفاهیم بهینهسازی و کش کوئریها تسلط کامل داشته باشید و آگاه باشید که موتورهای اجرا و ذخیرهسازی چگونه عمل میکنند.
این درک عمیق و ساختاری به شما کمک میکند تا بهینهسازی پرفورمنس MySQL را به شکلی اصولی و حرفهای انجام دهید و از مزایای بسیار زیاد این بهینهسازی بهره ببرید.
منابع مورد استفاده در این مقاله: کتاب High Performance MySQL book نوشته Baron Schwartz, Peter Zaitsev, and Vadim Tkachenko و وبسایت Medium
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.