📦 چطور یک فایل چندگیگابایتی را Upload میکنیم و اگر اینترنت قطع شد، از همانجا ادامه میدهیم؟فرض کن داری یک فایل 8GB آپلود میکنی.
۵۰٪ آپلود شده...
Upload: 4.0 GB / 8.0 GB
بعد اینترنت قطع میشود. 😐
اگر Upload بهصورت یک درخواست ساده انجام شده باشد، ممکن است مجبور شوی دوباره از ابتدا شروع کنی.
یعنی:
8 GB
↓
Connection Lost
↓
💀
↓
Upload again
اما سیستمهای Resumable Upload دقیقاً برای حل همین مشکل طراحی شدهاند.
ایدهی اصلی خیلی ساده است:
فایل بزرگ را طوری منتقل کن که بتوانیم بدانیم تا کجای آن با موفقیت دریافت شده و بعداً از همان نقطه ادامه دهیم.
📌 بهجای یک Upload بزرگ، انتقال را قابل ادامه میکنیم
در یک Upload معمولی ممکن است کل فایل در یک درخواست ارسال شود:
Client
|
|------ 8GB ------> Server
اگر ارتباط وسط انتقال قطع شود، درخواست شکست میخورد و ادامهدادن آن دشوار است.
یک مثال واقعی از پروتکل tus
فرض کنیم Upload تا byte شمارهی
70 پیش رفته است. Client وضعیت Upload را میپرسد:HEAD /files/abc123
Server پاسخ میدهد:
Upload-Offset: 70
یعنی:
تا byte 70
قبلاً دریافت شده.
حالا Client ادامهی فایل را میفرستد:
PATCH /files/abc123
Upload-Offset: 70
Content-Type: application/offset+octet-stream
[remaining bytes]
ءServer بعد از دریافت آن بخش پاسخ میدهد:
Upload-Offset: 100
یعنی Upload حالا تا byte شمارهی 100 پیش رفته است.
این رفتار در specification رسمی tus تعریف شده و صرفاً یک الگوی پیشنهادی نیست.
در Resumable Upload، انتقال در چند درخواست انجام میشود:
Client
|
|--- Part 1 ---> Server
|
|--- Part 2 ---> Server
|
|--- Part 3 ---> Server
|
|--- Part 4 ---> Server
|
...
در مستندات رسمی Google Cloud نیز Resumable Upload دقیقاً بهعنوان روشی برای ادامهدادن انتقال بعد از اختلال ارتباط معرفی شده است؛ هر درخواست میتواند بخشی از Object را منتقل کند.
📌ءPause هم در اصل یعنی چه؟
یک نکتهی مهم:
ءPause و Resume الزاماً دو قابلیت کاملاً جدا نیستند.
وقتی Upload قابل Resume باشد، Client میتواند ارسال داده را متوقف کند و بعداً با همان Upload Session ادامه دهد؛ به شرط اینکه Session هنوز معتبر باشد.
مثلاً:
10:00
Upload → 2GB
10:05
Pause ⏸️
10:30
Resume ▶️
Continue → 2GB
در Google Cloud Storage، یک Resumable Upload با یک session URI انجام میشود و همان session برای ادامهی انتقال استفاده میشود. مستندات Google میگوید این session میتواند تا یک هفته فعال بماند.
پس یک نکتهی مهم داریم:
ءResume به وجود یک state/session قابل ادامه وابسته است.
آیا همیشه باید فایل را به Chunkهای کوچک تقسیم کنیم؟
نه.
این یکی از جاهایی است که خیلی از توضیحات ساده، بیش از حد کلیگویی میکنند. Resumable Upload الزاماً به معنی این نیست که:
8GB
↓
8000 × 1MB
حتماً باید چنین کاری انجام دهیم.
ءGoogle Cloud صراحتاً اشاره میکند که در بعضی شرایط بهتر است انتقال در یک chunk بزرگ انجام شود و chunkهای کوچکتر هزینه و latency بیشتری ایجاد میکنند. Chunking زمانی میتواند مفید باشد که مثلاً محدودیت اندازهی درخواست وجود داشته باشد یا بخواهیم مقدار دادهای که در صورت شکست دوباره باید ارسال شود را محدود کنیم.
پس:
ءChunking یک تکنیک برای پیادهسازی و کنترل Upload است؛ Resumability مفهوم بزرگتری است.
ءIntegrity Check هم مهم است
فرض کن فایل 8GB است.
ما فقط نمیخواهیم بگوییم:
8GB received ✅
باید مطمئن شویم:
چیزی که دریافت کردهایم همان چیزی است که Client قصد ارسالش را داشته.
برای همین، سیستمهای Storage میتوانند از checksum / hash برای بررسی integrity استفاده کنند.
ءGoogle Cloud در مستندات Resumable Upload توصیه میکند برای Object نهایی integrity check انجام شود؛ از جمله استفاده از
Content-MD5 برای بررسی اینکه Object نهایی با فایل اصلی مطابقت دارد.این موضوع مخصوصاً برای فایلهای بزرگ اهمیت بیشتری پیدا میکند، چون انتقال آنها زمان بیشتری طول میکشد.
#کلیشه
«این متن با AI نوشته شده، پس ارزش خوندن نداره.»
از کجا معلوم؟
شاید نویسنده، حرف خوبی برای گفتن داشته باشه، ولی نویسنده خوبی نباشه.
شاید فقط ایده رو داده باشه به AI
تا بهتر بیانش کنه.
ناتوانی در نوشتن، لزوما ناتوانی در فکر کردن نیست.
اگه همون حرف رو یک نویسندهی ضعیف ولی یک مهندس قوی گفته باشه و AI فقط بهتر بیانش کرده باشه، چی؟
حالا سوال:
ما کیفیت فکر رو قضاوت میکنیم
یا کیفیت جملهبندی رو؟
«این متن با AI نوشته شده، پس ارزش خوندن نداره.»
از کجا معلوم؟
شاید نویسنده، حرف خوبی برای گفتن داشته باشه، ولی نویسنده خوبی نباشه.
شاید فقط ایده رو داده باشه به AI
تا بهتر بیانش کنه.
ناتوانی در نوشتن، لزوما ناتوانی در فکر کردن نیست.
اگه همون حرف رو یک نویسندهی ضعیف ولی یک مهندس قوی گفته باشه و AI فقط بهتر بیانش کرده باشه، چی؟
حالا سوال:
ما کیفیت فکر رو قضاوت میکنیم
یا کیفیت جملهبندی رو؟
🗄 10 قابلیت کمترشناختهشده SQL که هر Developer باید بداند(پارت 2️⃣)
🪟 2. Window Functions
گاهی به محاسبهای روی Rowهای مرتبط نیاز دارید، اما همچنان میخواهید هر Row بهصورت جداگانه در نتیجه باقی بماند.
یک
GROUP BY، Rowها را به یک Row برای هر Group تبدیل میکند. اما یک Window Function روی مجموعهای از Rowها — یعنی همان Window — محاسبه انجام میدهد، در حالی که هر Row را دستنخورده حفظ میکند.SELECT
number,
carrier,
created_at,
ROW_NUMBER() OVER (
PARTITION BY carrier
ORDER BY created_at DESC
) AS shipment_sequence,
RANK() OVER (
PARTITION BY carrier
ORDER BY created_at DESC
) AS shipment_rank
FROM shipments.shipments;
SELECT
number,
status,
created_at,
LAG(status) OVER (
ORDER BY created_at
) AS previous_status,
LEAD(carrier) OVER (
ORDER BY created_at
) AS next_carrier
FROM shipments.shipments;
ءQuery اول، Shipmentهای هر Carrier را بر اساس تاریخ رتبهبندی میکند.
ROW_NUMBER() یک Sequence یکتا درون هر Carrier ایجاد میکند؛ یعنی همان PARTITION BY carrier. اما RANK() نیز همین کار را انجام میدهد، با این تفاوت که Rowهای دارای مقدار برابر، رتبهی مشترک دریافت میکنند.ءQuery دوم از
LAG و LEAD استفاده میکند تا بدون نیاز به Self-Join، به Row قبلی و بعدی در ترتیب مشخصشده نگاه کند؛ در اینجا، Status قبلی و Carrier بعدی.ء
Window Functionها روشی هستند که با استفاده از آنها میتوانید Running Total، Ranking، Moving Average و مقایسهی RowبهRow بسازید.آنها بخشی از Standard SQL هستند و در PostgreSQL، SQL Server، Oracle و MySQL 8+ کار میکنند.
🔗 3. LATERAL Joins
یک
JOIN معمولی، دو Table را بر اساس یک شرط با یکدیگر Match میکند. اما نمیتواند برای هر Row از Table اول، یک Query جداگانه اجرا کند.یک
LATERAL JOIN میتواند این کار را انجام دهد. این قابلیت به یک Subquery در سمت راست اجازه میدهد به Columnهای Table سمت چپ Reference داشته باشد و برای هر Row، یکبار اجرا شود؛ بنابراین برای مسئلههای Top-N-Per-Group بسیار مناسب است.-- For each carrier, grab their single most recent shipment
SELECT
c.carrier,
s.number,
s.status,
s.created_at
FROM (
SELECT DISTINCT carrier
FROM shipments.shipments
) c
CROSS JOIN LATERAL (
SELECT
number,
status,
created_at
FROM shipments.shipments
WHERE carrier = c.carrier
ORDER BY created_at DESC
LIMIT 1
) s;
برای هر Carrier منحصربهفرد، Subquery مربوط به
LATERAL جدیدترین Shipment آن Carrier را انتخاب میکند؛ یعنی ORDER BY created_at DESC LIMIT 1.نکتهی اصلی این بخش عبارت
WHERE carrier = c.carrier است. Query داخلی میتواند carrier مربوط به Row بیرونی را ببیند؛ قابلیتی که یک Subquery معمولی نمیتواند انجام دهد.این روش، تمیزترین راه برای بیان مسئلهی «جدیدترین Row برای هر Group» یا «۳ مورد برتر برای هر Category» است، بدون اینکه به
Window Function نیاز داشته باشید.نکته: در SQL Server، همین قابلیت با
CROSS APPLY نوشته میشود و برای نسخهی مشابه Left Join از OUTER APPLY استفاده میشود. PostgreSQL از CROSS JOIN LATERAL و LEFT JOIN LATERAL استفاده میکند.یه چیز عجیب دربارهی Productivity:
گاهی اضافه کردن آدم به یک پروژه،
پروژه رو سریعتر نمیکنه.
ممکنه حتی کندترش کنه.
چون آدم جدید یعنی:
ءContext جدید
جلسهی بیشتر
ءCommunication بیشتر
ءCoordination بیشتر
و گاهی Dependency بیشتر.
برای همین:
10 نفر روی یک مسئله، لزوماً از 3 نفر سریعتر نیستن.
گاهی مشکل کمبود آدم نیست.
مشکل اینه که:
مسئله بیش از حد آدم میخواد تا حل بشه.
و اونجا شاید باید Architecture یا Process رو سادهتر کرد، نه Team رو بزرگتر.
گاهی اضافه کردن آدم به یک پروژه،
پروژه رو سریعتر نمیکنه.
ممکنه حتی کندترش کنه.
چون آدم جدید یعنی:
ءContext جدید
جلسهی بیشتر
ءCommunication بیشتر
ءCoordination بیشتر
و گاهی Dependency بیشتر.
برای همین:
10 نفر روی یک مسئله، لزوماً از 3 نفر سریعتر نیستن.
گاهی مشکل کمبود آدم نیست.
مشکل اینه که:
مسئله بیش از حد آدم میخواد تا حل بشه.
و اونجا شاید باید Architecture یا Process رو سادهتر کرد، نه Team رو بزرگتر.
🚕 اوبر چگونه نزدیکترین راننده را در مقیاس بزرگ پیدا میکند؟
فرض کن در یک شهر بزرگ، هزاران یا حتی میلیونها راننده و مسافر بهصورت همزمان در حال حرکت هستند.
یک مسافر درخواست سفر میدهد و سیستم باید خیلی سریع جواب بدهد:
کدام راننده را به این مسافر اختصاص بدهیم؟
در نگاه اول، جواب ساده است:
تمام رانندهها را بگیر
فاصلهی هرکدام تا مسافر را حساب کن
نزدیکترین را انتخاب کن
اما این راهحل در مقیاس بزرگ، خیلی زود تبدیل به یک مشکل جدی میشود. 😅
❌ چرا بررسی همهی رانندهها جواب خوبی نیست؟
فرض کن در یک شهر، ۱۰۰ هزار رانندهی آنلاین داریم.
اگر برای هر درخواست سفر، فاصلهی تمام ۱۰۰ هزار راننده را بررسی کنیم، هزینهی هر درخواست تقریباً به تعداد کل رانندهها وابسته میشود.
حالا اگر هزاران درخواست در هر لحظه وارد شوند، سیستم باید دائماً:
موقعیت رانندهها را دریافت کند؛ فاصلهی آنها را محاسبه کند؛ رانندههای نامناسب را حذف کند؛
و در نهایت بهترین گزینه را انتخاب کند.
مشکل فقط تعداد رانندهها نیست.
موقعیت رانندهها هم ثابت نیست. هر چند لحظه ممکن است راننده:
چند خیابان جلوتر رفته باشد؛ سفر جدیدی قبول کرده باشد؛ آفلاین شده باشد؛
یا دیگر برای دریافت سفر در دسترس نباشد.
پس ما با یک Query سادهی Database طرف نیستیم.
ما با ترکیبی از این مسائل روبهرو هستیم:
Geospatial Search
+
Real-Time Location Updates
+
Distributed Systems
+
Matching Optimization
🗺 ایدهی اول: نقشه را به Cell تقسیم کنیم
بهجای اینکه تمام رانندهها را در یک لیست بزرگ نگه داریم، نقشه را به بخشهای کوچکتر تقسیم میکنیم.
برای مثال:
+---------+---------+---------+
| Cell A | Cell B | Cell C |
+---------+---------+---------+
حالا هر راننده در یک Cell قرار میگیرد.
وقتی مسافر در Cell E درخواست سفر میدهد، لازم نیست تمام رانندههای شهر بررسی شوند.
ابتدا این بخشها را بررسی میکنیم:
Cell E
Cell D
Cell F
و Cellهای نزدیک دیگر
این کار تعداد Candidateها را بسیار کمتر میکند.
اما یک سؤال مهم وجود دارد:
این Cellها را چطور بسازیم؟
⬡ ءH3؛ سیستم مکانی Uber
ءUber برای کارهای جغرافیایی خودش، سیستم H3 را توسعه داد و Open Source کرد. H3 مخفف این عبارت است:
Hexagonal Hierarchical Geospatial Index
یعنی یک سیستم Index مکانیِ سلسلهمراتبی که جهان را به Cellهای ششضلعی تقسیم میکند.
بهجای اینکه فقط با Latitude و Longitude کار کنیم، مختصات را به یک شناسهی مکانی تبدیل میکنیم.
مثلاً بهصورت مفهومی:
Latitude: 35.7219
Longitude: 51.3347
↓
H3 Cell ID
↓
8a2a1072b59ffff
شناسهی بالا صرفاً یک نمونه از فرمت H3 است، نه شناسهی واقعی یک راننده.
در کد رسمی H3 نیز میتوان مختصات جغرافیایی را به یک Cell تبدیل کرد:
latLngToCell(latitude, longitude, resolution)
🔎 هنگام درخواست سفر چه اتفاقی میافتد؟
فرض کن مسافر در Cell E قرار دارد.
سیستم میتواند ابتدا رانندههای همین Cell را بررسی کند:
Search(Cell E)
اگر رانندهی مناسب پیدا نشد، جستوجو را به Cellهای اطراف گسترش میدهد:
Search(Cell E)
Search(Neighbors of E)
Search(Neighbors of Neighbors)
به این ترتیب، سیستم بهجای بررسی تمام شهر، یک ناحیهی محدود را بررسی میکند.
اما اینجا یک نکتهی مهم وجود دارد:
نزدیکترین راننده الزاماً بهترین راننده نیست
فرض کن دو راننده داریم:
Driver A:
فاصلهی مستقیم: 800 متر
اما پشت رودخانه است
Driver B:
فاصلهی مستقیم: 1.2 کیلومتر
اما از مسیر مستقیم و خلوت میتواند برسد
از نظر فاصلهی هندسی: A بهتر است
اما از نظر زمان رسیدن: B ممکن است بهتر باشد
خود Uber نیز توضیح داده که در ابتدا Matching را با این سؤال انجام میداد:
چه کسی از همه نزدیکتر است؟
اما بعد مشخص شد که «نزدیکترین» همیشه به معنی «سریعترین برای رسیدن» نیست.
عواملی مثل:
ترافیک؛
پلها و بزرگراهها؛
رودخانهها؛
خیابانهای یکطرفه؛
مسیر واقعی رانندگی؛
و زمان رسیدن
میتوانند نتیجه را تغییر دهند.
بنابراین فرآیند واقعی چیزی شبیه این است:
1. پیدا کردن رانندههای مکانیِ نزدیک
2. حذف رانندههای نامعتبر
3. تخمین زمان رسیدن
4. بررسی محدودیتها و شرایط Matching
5. انتخاب Assignment مناسب
🗄 10 قابلیت کمترشناختهشده SQL که هر Developer باید بداند(پارت 3️⃣)
📊 4. ءGROUPING SETS، ROLLUP و CUBE
یک Report اغلب به چندین سطح از Summary بهصورت همزمان نیاز دارد: مجموعها بر اساس Carrier و Status، Subtotalهای هر Carrier و یک Grand Total.
روش ساده این است که چند Query را با استفاده از
UNION ALL به یکدیگر متصل کنیم.ء
GROUPING SETS، ROLLUP و CUBE تمام این سطوح را در یک Query تولید میکنند.SELECT
carrier,
status,
COUNT(*) AS shipment_count,
SUM(si.quantity) AS total_quantity
FROM shipments.shipments s
LEFT JOIN shipments.shipment_items si
ON s.id = si.shipment_id
GROUP BY GROUPING SETS (
(carrier, status), -- by carrier & status
(carrier), -- subtotal by carrier
(status), -- subtotal by status
() -- grand total
);
SELECT
carrier,
status,
DATE_TRUNC('month', created_at) AS month,
COUNT(*) AS shipment_count
FROM shipments.shipments
GROUP BY ROLLUP (
carrier,
status,
DATE_TRUNC('month', created_at)
);
ءQuery اول دقیقاً Groupingهایی را فهرست میکند که به آنها نیاز دارید: بر اساس Carrier و Status، فقط بر اساس Carrier، فقط بر اساس Status و
() خالی برای Grand Total.ء
ROLLUP در Query دوم، شکل کوتاهشدهای برای Subtotalهای سلسلهمراتبی است: Carrier، سپس Carrier و Status، بعد Carrier و Status و Month، و در نهایت Total.ء
CUBE تمام ترکیبهای ممکن از Columnها را تولید میکند.یک Query جایگزین چهار Query میشود و Database سطوح مختلف را در یک Pass محاسبه میکند، بهجای اینکه Table را چندین بار Scan کند.
این قابلیتها بخشی از Standard SQL هستند و در PostgreSQL، SQL Server و Oracle کار میکنند.
🧮 5. ءFILTER Clause در Aggregateها
اغلب لازم است فقط Rowهایی را Count یا Sum کنید که یک شرط مشخص را دارند و نتیجهی آنها را بهصورت جداگانه و کنار هم نمایش دهید.
عبارت
FILTER یک شرط را روی یک Aggregate مشخص اعمال میکند؛ بنابراین هر Aggregate یک Subset متفاوت را Count میکند — همه در یک Row و در یک Pass روی دادهها.SELECT
carrier,
COUNT(*) AS total_shipments,
COUNT(*) FILTER (
WHERE status = 'delivered'
) AS delivered_count,
COUNT(*) FILTER (
WHERE status = 'in_transit'
) AS in_transit_count,
COUNT(*) FILTER (
WHERE status = 'pending'
) AS pending_count,
SUM(si.quantity) FILTER (
WHERE status = 'delivered'
) AS delivered_quantity,
SUM(si.quantity) FILTER (
WHERE status = 'pending'
) AS pending_quantity
FROM shipments.shipments s
LEFT JOIN shipments.shipment_items si
ON s.id = si.shipment_id
GROUP BY carrier;
هر
COUNT(*) FILTER (WHERE ...) فقط Rowهای مطابق با شرط را Count میکند؛ بنابراین برای هر Carrier، تعداد Shipmentهای Delivered، In-Transit و Pending را در Columnهای جداگانه دریافت میکنید.عبارت زیر:
COUNT(*) FILTER (WHERE status = 'delivered')
خواناتر از روش قدیمی استفاده از
CASE است:SUM(
CASE
WHEN status = 'delivered' THEN 1
ELSE 0
END
)
هدف و منظور عبارت
FILTER بسیار واضحتر است.📌 نکته:FILTERتوسط PostgreSQL پشتیبانی میشود. SQL Server و MySQL این قابلیت را ندارند؛ در آن Databaseها باید ازCASEداخل Aggregate استفاده کنید، مانند:
COUNT(
CASE
WHEN status = 'delivered' THEN 1
END
)
یه اشتباه رایج توی طراحی سیستم:
همهچیز باید Real-Time باشه.
کاربر چیزی تغییر میده،همهجا باید همون لحظه تغییر کنه.
ولی واقعاً لازمه؟
فرض کن کاربر Profile خودش رو تغییر داده.
آیا Notification Service باید همان میلیثانیه تغییر رو ببینه؟
آیا Analytics باید همان لحظه Update بشه؟
آیا Search Index باید دقیقاً همان لحظه تغییر کنه؟
شاید نه.
گاهی: Eventual Consistency نه یک مشکل، بلکه یک تصمیم مهندسیه.
اگر ۲ ثانیه تأخیر قابلقبوله، لازم نیست برای ۲ ثانیه تأخیر، کل سیستم رو به هم وابسته کنیم.
هر وابستگی Sync یعنی:
ءLatency بیشتر
ءFailure بیشتری
ءCoordination بیشتر
گاهی سیستم بهتر، سیستمی نیست که همهچیز رو سریعتر Sync میکنه.
سیستمیه که میدونه کجا لازم نیست Sync باشه.
همهچیز باید Real-Time باشه.
کاربر چیزی تغییر میده،همهجا باید همون لحظه تغییر کنه.
ولی واقعاً لازمه؟
فرض کن کاربر Profile خودش رو تغییر داده.
آیا Notification Service باید همان میلیثانیه تغییر رو ببینه؟
آیا Analytics باید همان لحظه Update بشه؟
آیا Search Index باید دقیقاً همان لحظه تغییر کنه؟
شاید نه.
گاهی: Eventual Consistency نه یک مشکل، بلکه یک تصمیم مهندسیه.
اگر ۲ ثانیه تأخیر قابلقبوله، لازم نیست برای ۲ ثانیه تأخیر، کل سیستم رو به هم وابسته کنیم.
هر وابستگی Sync یعنی:
ءLatency بیشتر
ءFailure بیشتری
ءCoordination بیشتر
گاهی سیستم بهتر، سیستمی نیست که همهچیز رو سریعتر Sync میکنه.
سیستمیه که میدونه کجا لازم نیست Sync باشه.
🔖هشتگها:
#SystemDesign #Architecture #SoftwareEngineering #DotNet
📩 سیستم پیامک در مقیاس بزرگ؛ چرا همهی پیامکها نباید در یک صف باشند؟ (Part1️⃣)
فرض کن ساعت ۵ عصر است.
یک سیستم بانکی باید همزمان این پیامها را ارسال کند:
🔐 پیامک OTP برای ورود کاربران
💳 پیامک برداشت و تراکنش مالی
📦 پیامک وضعیت سفارش
📢 پیامک تبلیغاتی
📰 پیامک اطلاعرسانی عمومی
حالا تصور کن یک کمپین تبلیغاتی شروع شده و ۷ میلیون پیامک وارد سیستم شده است.
اگر همهی پیامکها را داخل یک صف قرار دهیم، چه اتفاقی میافتد؟
[Marketing 1]
[Marketing 2]
...
[Marketing 7,000,000]
[OTP Login]
کاربر منتظر ورود به حسابش است، اما پیامک OTP او پشت میلیونها پیامک تبلیغاتی گیر کرده.
از دید سیستم، صف هنوز در حال کار است.
اما از دید کاربر:
سیستم خراب است! 😐
اینجا باید از Priority Queue استفاده کنیم.
🎯 ایدهی اصلی Priority Queue
در صف معمولی، پیامها بر اساس زمان ورود پردازش میشوند:
First In → First Out
اما در Priority Queue، هر پیام علاوه بر اطلاعات خودش، یک سطح اهمیت هم دارد:
{
"messageId": "msg-123",
"type": "OTP",
"priority": "Critical"
}سیستم بهجای اینکه فقط بپرسد:
کدام پیام زودتر وارد شده؟
میپرسد:
کدام پیام مهمتر است و باید زودتر پردازش شود؟
برای مثال:
Priority 0 → Critical
Priority 1 → High
Priority 2 → Normal
Priority 3 → Low
نکتهی مهم:
ءPriority Queue فقط ترتیب پردازش را مشخص میکند؛ ارسال واقعی همچنان به ظرفیت Provider، محدودیت شبکه و وضعیت مقصد وابسته است.
🧩 یک صف یا چند صف؟
برای پیادهسازی Priority Queue دو روش اصلی وجود دارد.
❗️روش اول: یک صف با Priority
در این روش همهی پیامها در یک Queue هستند، اما هر پیام Priority دارد:
Queue
├── Message A - Priority 3
├── Message B - Priority 1
├── Message C - Priority 2
└── Message D - Priority 0
ءConsumer باید پیامها را بر اساس Priority دریافت کند.
✅مزیت:
مدیریت سادهتر
یک مسیر پردازش
مناسب برای سیستمهای کوچکتر
❌مشکل:
کنترل ظرفیت هر سطح دشوارتر میشود.
ممکن است پیامهای مهم وارد Consumerهای اشغالشده توسط پیامهای کماهمیت شوند.
رفتار واقعی به نوع Broker و تنظیمات Consumer وابسته است.
در RabbitMQ، Priority Queue وجود دارد؛ اما مستندات رسمی آن تأکید میکنند که اگر Consumer مقدار زیادی پیام را با
prefetch دریافت کرده باشد، پیام Priority بالا ممکن است مجبور شود پشت پیامهای کماهمیتی که قبلاً به Consumer تحویل داده شدهاند منتظر بماند.پس این تنظیم مهم است:
Consumer Prefetch
اگر Prefetch بیش از حد بزرگ باشد، Broker تعداد زیادی پیام را زودتر به Consumer تحویل میدهد و فرصت اولویتبندی کاهش پیدا میکند.
❗️روش دوم: چند صف جداگانه
در سیستمهای مهمتر، معمولاً Priorityها را به Queueهای جدا تقسیم میکنیم:
sms-critical
sms-high
sms-normal
sms-low
سپس Dispatcher تصمیم میگیرد از کدام صف پیام بردارد.
این مدل کنترل بیشتری میدهد و میتوان برای هر صف Consumer یا ظرفیت جداگانه داشت.
⚠️ مشکل خطرناک: Starvation
فرض کن سیستم همیشه پیامکهای Critical دریافت میکند.
Dispatcher هم همیشه این منطق را اجرا میکند:
تا وقتی Critical خالی نشده:
فقط Critical را پردازش کن
در این شرایط، پیامکهای Low ممکن است هیچوقت پردازش نشوند.
به این مشکل میگوییم:
Starvation
یعنی یک گروه از کارها دائماً منابع دریافت نمیکنند.
مثلاً:
Critical Queue:
همیشه پر است
Low Queue:
هیچوقت نوبتش نمیرسد
این رفتار برای OTP شاید مناسب باشد، اما برای پیامکهای عادی میتواند باعث شود:
پیامها بیش از حد قدیمی شوند؛
کمپینها بیارزش شوند؛
هزینهی نگهداری Queue افزایش پیدا کند؛
و Backlog دائماً رشد کند.
🛠 راهحل: Weighted Fair Scheduling
بهجای اینکه همیشه فقط صف Critical را مصرف کنیم، برای هر صف سهمی تعیین میکنیم.
مثلاً:
Critical → 70%
High → 20%
Normal → 8%
Low → 2%
یعنی Dispatcher تلاش میکند بیشتر ظرفیت را به پیامهای مهم بدهد، اما صفهای دیگر را کاملاً گرسنه نگذارد.
یک مدل ساده:
در هر 100 پیام:
70 پیام از Critical
20 پیام از High
8 پیام از Normal
2 پیام از Low
⏳ ءAging؛ اولویت پیام قدیمی را افزایش بده
یک راه دیگر برای جلوگیری از Starvation، تکنیک Aging است.
ایده:
هرچه یک پیام بیشتر منتظر بماند، اولویت مؤثر آن بیشتر شود.
مثلاً:
Marketing Message:
Priority = Low
بعد از چند دقیقه:
Effective Priority = Normal
و اگر باز هم پردازش نشد:
Effective Priority = High
این فرمول فقط برای توضیح مفهوم است؛ در سیستم واقعی باید مراقب باشیم Aging باعث نشود پیامهای قدیمی تبلیغاتی ناگهان OTPهای جدید را کنار بزنند.
برای همین معمولاً Priorityهای امنیتی و تراکنشی قوانین سختگیرانهتری دارند.
⚖️ ءOptimistic Locking vs Pessimistic Locking
فرض کن دو کاربر همزمان موجودی یک کیف پول را تغییر میدهند.
موجودی اولیه:
Balance = 1000
کاربر اول میخواهد 700 تومان برداشت کند.
کاربر دوم همزمان میخواهد 500 تومان برداشت کند.
اگر هر دو درخواست مقدار
1000 را بخوانند:User A reads: 1000
User B reads: 1000
User A writes: 300
User B writes: 500
نتیجه:
Final Balance = 500
اما در واقع باید فقط یکی از برداشتها موفق شود؛ چون موجودی برای هر دو کافی نیست.
این مشکل یکی از نمونههای معروف Race Condition و Lost Update است.
اینجا دو رویکرد مهم داریم:
Optimistic Locking
Pessimistic Locking
🟢 ءOptimistic Locking چیست؟
در Optimistic Locking فرض میکنیم برخورد همزمانی معمولاً کم است.
پس هنگام خواندن داده، آن را قفل نمیکنیم.
بهجای قفلکردن، هنگام ذخیره بررسی میکنیم:
آیا دادهای که من خواندم هنوز همان نسخهی قبلی است؟
اگر در این فاصله شخص دیگری داده را تغییر داده باشد، عملیات شکست میخورد و باید:
دوباره داده را بخوانیم؛
تصمیم را از نو محاسبه کنیم؛
یا خطای Conflict به کاربر بدهیم.
مثال با Version
فرض کن رکورد حساب اینطور است:
AccountId = 10
Balance = 1000
Version = 5
کاربر A رکورد را میخواند:
Balance = 1000
Version = 5
کاربر B هم همین نسخه را میخواند:
Balance = 1000
Version = 5
کاربر A ذخیره میکند:
UPDATE Accounts
SET Balance = 300,
Version = 6
WHERE AccountId = 10
AND Version = 5;
این Query موفق میشود.
حالا کاربر B میخواهد ذخیره کند:
UPDATE Accounts
SET Balance = 500,
Version = 6
WHERE AccountId = 10
AND Version = 5;
اما دیگر رکوردی با
Version = 5 وجود ندارد.پس:
Affected Rows = 0
یعنی:
داده در فاصلهی خواندن تا ذخیره تغییر کرده است.
این همان Optimistic Concurrency Conflict است.
🧠 نکتهی مهم
ءOptimistic Locking الزاماً به معنی استفاده از یک ستون به نام
Version نیست.میتوان از موارد زیر هم استفاده کرد:
ء
rowversion در SQL Server؛ءTimestamp یا Version Number؛
مقدار Hash؛
بررسی مقدار قبلی چند ستون؛
شرط روی
UpdatedAt؛ءConcurrency Tokenء در EF Core.
اما برای تشخیص Conflict، باید یک مقدار قابلاعتماد برای مقایسه داشته باشیم.
🟦 ءOptimistic Locking
در SQL Server میتوان از
rowversion استفاده کرد:public sealed class Account
{
public int Id { get; set; }
public decimal Balance { get; set; }
public byte[] RowVersion { get; set; } = [];
}
پیکربندی:
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>()
.Property(x => x.RowVersion)
.IsRowVersion();
}
حالا EF Core هنگام Update، مقدار
RowVersion قبلی را در شرط قرار میدهد.اگر شخص دیگری رکورد را تغییر داده باشد، EF Core معمولاً با این Exception مواجه میشود:
DbUpdateConcurrencyException
🔴 ءPessimistic Locking چیست؟
در Pessimistic Locking فرض میکنیم برخورد همزمانی محتمل است.
پس از همان ابتدا داده را قفل میکنیم.
یعنی:
تا وقتی من در حال کار روی این رکورد هستم، دیگران نباید بتوانند آن را به شکل ناسازگار تغییر دهند.
مثلاً:
Transaction A:
Lock Account 10
Read Balance = 1000
Withdraw 700
Commit
Release Lock
Transaction B:
Wait for Account 10
Read Balance = 300
Withdraw 500
Reject
در این روش، کاربر دوم باید منتظر آزادشدن Lock بماند.
مثال SQL Server با
UPDLOCKیک الگوی رایج در SQL Server:
BEGIN TRANSACTION;
SELECT Balance
FROM Accounts WITH (UPDLOCK, ROWLOCK)
WHERE Id = 10;
-- Validate balance
-- Update balance
UPDATE Accounts
SET Balance = Balance - 700
WHERE Id = 10;
COMMIT;
ءUPDLOCK باعث میشود SQL Server هنگام خواندن، قفل Update بگیرد تا عملیات تغییر همزمان کنترل شود.اما باید دقت کرد:
ءLock تا پایان Transaction باقی میماند؛
ءTransaction باید کوتاه باشد؛
ءLock طولانی میتواند باعث Blocking شود؛
چند Lock ناسازگار میتوانند Deadlock ایجاد کنند.
🎯 مثال واقعی: ویرایش پروفایل کاربر
فرض کن دو کاربر یا دو Tab، پروفایل یک نفر را باز کردهاند.
نسخهی اولیه:
Name = Milad
Version = 3
Tab A:
Name = Milad Heidarpour
Version = 3
Tab B:
Name = Milad Developer
Version = 3
اگر Tab A زودتر ذخیره کند:
Version = 4
Tab B دیگر نباید بیخبر تغییرات Tab A را Overwrite کند.
با Optimistic Locking:
Tab B → 409 Conflict
و UI میتواند بگوید:
این اطلاعات توسط کاربر دیگری تغییر کرده است. لطفاً دادهی جدید را بررسی کنید.
یه چیزی که توی Code Review دیر فهمیدیم:
ءPR بزرگ فقط Review کردنش سخت نیست؛ فهمیدنش سخته.
وقتی یک PR شامل:
ءFeature جدید
ءRefactoring
تغییر Database
و چند Bug Fix
باشه، Reviewer دیگه نمیدونه دقیقاً باید دنبال چی بگرده.
حتی اگر همهچیز درست باشه،
هزینهی فهمیدن تغییر بالا میره.
گاهی یک PR کوچکتر، با Code کمتر،
ارزش بیشتری از یک PR بزرگ و «کامل» داره.
چون هدف Code Review فقط پیدا کردن Bug نیست.
باید بتونی بفهمی چه چیزی تغییر کرده، چرا تغییر کرده، و چه چیزی ممکنه خراب بشه.
ءPR بزرگ فقط Review کردنش سخت نیست؛ فهمیدنش سخته.
وقتی یک PR شامل:
ءFeature جدید
ءRefactoring
تغییر Database
و چند Bug Fix
باشه، Reviewer دیگه نمیدونه دقیقاً باید دنبال چی بگرده.
حتی اگر همهچیز درست باشه،
هزینهی فهمیدن تغییر بالا میره.
گاهی یک PR کوچکتر، با Code کمتر،
ارزش بیشتری از یک PR بزرگ و «کامل» داره.
چون هدف Code Review فقط پیدا کردن Bug نیست.
باید بتونی بفهمی چه چیزی تغییر کرده، چرا تغییر کرده، و چه چیزی ممکنه خراب بشه.
ءPriority بهتنهایی کافی نیست؛ Provider هم محدودیت دارد
فرض کن Queue ما آماده است، اما SMS Provider فقط اجازه میدهد:
100 پیامک در ثانیه
ارسال کنیم.
اگر Dispatcher با سرعت ۱۰ هزار پیامک در ثانیه به Provider درخواست بفرستد، اتفاقهای بدی رخ میدهد:
خطای Rate Limit ،Timeout،Retry زیاد ،فشار روی شبکه ،افزایش Backlog و در نهایت Retry Storm
پس بعد از Priority Queue به یک Rate Limiter نیاز داریم:
ءRate Limiter باید ظرفیت واقعی Provider را رعایت کند.
برای مثال:
Provider Limit:
100 SMS/sec
Dispatcher:
بیشتر از 100 ارسال در ثانیه انجام ندهد
اما اگر OTP در صف باشد، نباید با افزایش Priority، محدودیت Provider را دور بزنیم. Priority یعنی:
این پیام زودتر انتخاب شود.
نه اینکه:
این پیام بدون توجه به ظرفیت Provider ارسال شود.
🔁 ءRetry را داخل همان صف اصلی نریزیم
فرض کن ارسال یک پیامک شکست میخورد.
راه ساده اما خطرناک:
Send Failed
↓
Put back into Main Queue
اگر Provider مشکل داشته باشد، پیام دائماً از Queue خارج و دوباره وارد آن میشود.
راه بهتر، داشتن Retry Policy است:
Attempt 1:
بعد از 1 ثانیه
Attempt 2:
بعد از 5 ثانیه
Attempt 3:
بعد از 30 ثانیه
در عمل باید از:
Exponential Backoff
Jitter
Maximum Attempts
Error Classification
Provider Retry-After
استفاده شود.
همهی خطاها هم نباید Retry شوند.
مثلاً:
Temporary Timeout:
احتمالاً Retry شود
Rate Limit:
با Backoff و رعایت Retry-After
Invalid Phone Number:
Retry نکن
Blocked Destination:
Retry نکن
پیامهای شکستخوردهی موقت بهتر است وارد صفی مانند این شوند:
sms-retry
و پیامهایی که پس از تعداد مشخصی تلاش همچنان شکست خوردهاند، به اینجا منتقل شوند:
sms-dead-letter
☠️ ءDead Letter Queue
اگر یک پیام بعد از چند بار تلاش پردازش نشود، نباید برای همیشه در صف اصلی بماند.
مثلاً:
Max Attempts = 5
بعد از پنجمین شکست:
Main Queue
↓
Dead Letter Queue
ءDLQ برای این موارد مفید است:
بررسی علت شکست
اصلاح دادهی خراب
ءRetry دستی
گزارشگیری
جلوگیری از گیرکردن صف اصلی
اما DLQ نباید تبدیل به سطل زباله شود.
باید برای آن:
Monitoring
Alert
Retention Policy
و فرآیند بررسی داشته باشیم.
🧱 ءDuplicate؛ آیا یک پیامک ممکن است دوبار ارسال شود؟ بله.
فرض کن Provider پیامک را دریافت کرده، اما پاسخ موفقیت به سیستم ما نرسیده است:
Our Service → Provider
│
├── SMS sent
└── Response lost
سیستم ما فکر میکند ارسال شکست خورده و دوباره Retry میکند:
Retry → Provider
حالا ممکن است کاربر دو پیامک دریافت کند.
برای کاهش این مشکل باید از یک شناسهی یکتا استفاده کنیم:
{
"messageId": "sms-8f91",
"idempotencyKey": "otp-login-user-42-request-981"
}اما یک نکتهی مهم:
داشتن Idempotency Key در سیستم خودمان، تضمین نمیکند Provider نیز ارسال را دقیقاً یکبار انجام دهد.
این موضوع به قابلیتهای خود Provider وابسته است.
پس باید:
وضعیت ارسال را ذخیره کنیم؛
شناسهی پیام را به Provider منتقل کنیم، اگر پشتیبانی میشود؛
ءCallback و Delivery Report را مدیریت کنیم؛
و Duplicate احتمالی را در طراحی در نظر بگیریم.
⏱️ پیامک قدیمی همیشه ارزش ارسال ندارد
فرض کن پیامک OTP مربوط به ۲۰ دقیقه قبل هنوز در Queue است.
ارسال آن دیگر فایدهای ندارد.
یا پیامک وضعیت سفارش مربوط به سفارشی است که لغو شده.
پس هر پیام باید اطلاعاتی مانند این داشته باشد:
{
"messageId": "msg-123",
"priority": "Critical",
"createdAt": "2026-09-15T17:00:00Z",
"expiresAt": "2026-09-15T17:01:00Z",
"attempt": 0
}ءDispatcher قبل از ارسال بررسی میکند:
اگر now > expiresAt:
پیام را منقضی کن
ارسال نکن
این موضوع برای جلوگیری از Stale Work مهم است.
گاهی پردازشکردن یک پیام قدیمی بدتر از پردازشنکردن آن است.
🎯 نتیجهگیری
در سیستم بزرگ، Priority Queue فقط به معنی این نیست که:
پیام مهمتر را زودتر از صف بردار.
طراحی درست باید این مسائل را همزمان حل کند:
Priority
+
Fairness
+
Rate Limiting
+
Retry
+
Idempotency
+
Expiration
+
Dead Letter Queue
+
Observability
پس معماری مناسب برای سیستم پیامک بلکه چیزی شبیه این است:
Application
↓
Classification
↓
Priority Queues
↓
Fair Dispatcher
↓
Provider Rate Limiter
↓
SMS Provider
↓
Delivery Tracking
و مهمترین تصمیم مهندسی:
پیامک OTP و پیامک تبلیغاتی نباید فقط به خاطر اینکه هر دو «SMS» هستند، با یک SLA و یک مسیر پردازش مدیریت شوند.
چون در سیستمهای واقعی، همهی پیامها ارزش یکسانی ندارند. 📩
🗄 10 قابلیت کمترشناختهشده SQL که هر Developer باید بداند(پارت 4️⃣)
🔄 6. ءUPSERT یا INSERT ... ON CONFLICT
اگر Row جدید است، آن را Insert کن؛ اگر از قبل وجود دارد، آن را Update کن.
این یک نیاز رایج است که معمولاً به یک
SELECT، یک IF و دو مسیر کدنویسی جداگانه نیاز دارد.ء
UPSERT تمام این کارها را در یک Statement اتمیک انجام میدهد و بین Check و Write نیز Race Condition ایجاد نمیشود.ALTER TABLE shipments.shipments
ADD CONSTRAINT shipments_number_unique
UNIQUE (number);
INSERT INTO shipments.shipments (
id,
number,
order_id,
address_street,
address_city,
address_zip,
carrier,
receiver_email,
status,
created_at,
updated_at
)
VALUES (
'550e8400-e29b-41d4-a716-446655440000',
'SH-2024-001',
'ORD-2024-001',
'123 Main St',
'New York',
'10001',
'FedEx',
'customer@example.com',
'pending',
NOW(),
NOW()
)
ON CONFLICT (number) DO UPDATE SET
carrier = EXCLUDED.carrier,
status = EXCLUDED.status,
updated_at = GREATEST(
shipments.updated_at,
EXCLUDED.updated_at
);
ابتدا یک
Unique Constraint روی Column مربوط به number اضافه میکنیم؛ یعنی همان Columnای که Conflict بر اساس آن شناسایی میشود.سپس
INSERT ... ON CONFLICT (number) DO UPDATE تلاش میکند Row را Insert کند. اگر Rowای با همان number از قبل وجود داشته باشد، بهجای Insert، آن را Update میکند.ءPseudo-table مربوط به
EXCLUDED مقادیری را نگه میدارد که تلاش کردهاید Insert کنید.بنابراین:
carrier = EXCLUDED.carrier
یعنی:
«از مقدار Carrier جدید استفاده کن.»
عبارت زیر:
GREATEST(
shipments.updated_at,
EXCLUDED.updated_at
)
مقدار جدیدتر از بین دو Timestamp را نگه میدارد.
یک Statement، بدون Duplicate Row و بدون Race Condition از نوع Read-Modify-Write بین Callerهای همزمان.
نکته: این Syntax مربوط به PostgreSQL است. در SQL استاندارد، مانند SQL Server و Oracle، از Statement مربوط به MERGE استفاده میشود. در MySQL نیز از عبارت زیر استفاده میشود:
INSERT ... ON DUPLICATE KEY UPDATE
🧩7. پشتیبانی از JSON
همهی دادهها رابطهای نیستند. گاهی لازم است یک payload منعطف و نیمهساختیافته ذخیره کنید؛ مثلاً یک event، بدنهی یک webhook یا یک blob مربوط به تنظیمات.
ءPostgreSQL دادههای JSON را بهصورت native در نوع
JSONB ذخیره میکند و اجازه میدهد داخل آن query بزنید؛ بنابراین برای JSONهای موردی، نیازی به یک document database جداگانه ندارید. همچنین مجبور نیستید JSON را بهصورت string ذخیره کنید و پردازش بیشتر آن را به کد backend بسپارید؛ روشی که تمام قابلیتهای index را از دست میدهد.CREATE TABLE shipments.events (
id SERIAL PRIMARY KEY,
payload JSONB NOT NULL
);
-- Insert sample data
INSERT INTO shipments.events (payload)
VALUES
('{"type":"click","coordinates":[{"x":10,"y":20},{"x":15,"y":25}]}'),
('{"type":"hover","coordinates":[{"x":5,"y":30}]}'),
('{"type":"scroll","coordinates":[{"x":0,"y":100},{"x":0,"y":200},{"x":0,"y":300}]}');
-- Extract simple JSON fields
SELECT
payload ->> 'type' AS event_type,
payload -> 'coordinates' -> 0 ->> 'x' AS first_x,
payload -> 'coordinates' -> 0 ->> 'y' AS first_y
FROM shipments.events;
جدول
events یک payload از نوع JSONB ذخیره میکند. سپس query وارد آن میشود:->> یک مقدار را بهصورت text استخراج میکند.-> یک JSON object یا یک عنصر تودرتو از یک array را استخراج میکند.بنابراین عبارت زیر، مقدار
x از اولین coordinate را میخواند:payload -> 'coordinates' -> 0 ->> 'x'
ءJSONB بهصورت binary و parseشده ذخیره میشود و میتوان روی آن index ساخت؛ بنابراین میتوانید بدون scan کردن کل documentها، روی آنها filter و extract انجام دهید.📝 نکته: SQL Server برای query زدن روی JSON از JSON_VALUE و OPENJSON استفاده میکند؛ استاندارد SQL نیز JSON_TABLE را اضافه میکند؛ قابلیتی که در Oracle، MySQL و PostgreSQL 17+ وجود دارد و یک JSON array را مستقیماً به ردیفهای relational تبدیل میکند.
Forwarded from tech-afternoon (Amin Mesbahi)
توجه: هیچ چکلیستی جهانشمول نیست؛ ولی میتونه بهانه و سرنخهایی برای فکر کردن باشه.
توی این مطلب موضوعاتی رو که برای آمادهسازی یک تیم توسعه نرمافزار (نه کارهای شخصی یا تجربی و...) برای توسعه با AI نیازه مرور کردم؛ اگر حوصله خوندن متن کامل رو ندارید، به آخرش مراجعه کنید که خلاصه چکلیست رو گذاشتم.
🔗 لینک به مطلب کامل
Please open Telegram to view this post
VIEW IN TELEGRAM
#Engineering_Leadership
تصمیم نگرفتن هم یک تصمیم است
یه چیز عجیب توی تیمهای مهندسی:
گاهی همه منتظرن یکی تصمیم بگیره.
جلسه برگزار میشه.
مزایا و معایب گفته میشن.
همه نظر میدن.
جلسه بعدی هم برگزار میشه.
و در نهایت:
هیچ تصمیمی گرفته نمیشه.
چون همه میخوان مطمئن باشن تصمیم اشتباهی نمیگیرن.
ولی در Software Engineering، بعضی تصمیمها فقط وقتی درست میشن که اجراشون کنی و Feedback بگیری.
گاهی:
تصمیم ناقص + Feedback سریع
از
تصمیم کامل + سه هفته تأخیر
ارزشمندتره.
چون بعضی وقتها بزرگترین هزینهی یک تصمیم اشتباه نیست. هزینهی تصمیم نگرفتنشه.
تصمیم نگرفتن هم یک تصمیم است
یه چیز عجیب توی تیمهای مهندسی:
گاهی همه منتظرن یکی تصمیم بگیره.
جلسه برگزار میشه.
مزایا و معایب گفته میشن.
همه نظر میدن.
جلسه بعدی هم برگزار میشه.
و در نهایت:
هیچ تصمیمی گرفته نمیشه.
چون همه میخوان مطمئن باشن تصمیم اشتباهی نمیگیرن.
ولی در Software Engineering، بعضی تصمیمها فقط وقتی درست میشن که اجراشون کنی و Feedback بگیری.
گاهی:
تصمیم ناقص + Feedback سریع
از
تصمیم کامل + سه هفته تأخیر
ارزشمندتره.
چون بعضی وقتها بزرگترین هزینهی یک تصمیم اشتباه نیست. هزینهی تصمیم نگرفتنشه.
🚨 طراحی سیستم Monitoring و Alerting در یک سیستم بزرگ
فرض کن ساعت ۳ صبح است.
سیستم شما با ۲۰۰۰ Request در ثانیه کار میکند. یکدفعه:
Error Rate ↑
Latency ↑
CPU ↑
DB Connections ↑
Queue Depth ↑
و چند ثانیه بعد:
📱 47 Alert
📱 132 Alert
📱 580 Alert
تیم On-Call گوشی را برمیدارد و با خودش میگوید:
«خب دقیقاً کدومش مشکل اصلیه؟! 😐»
اینجا متوجه میشویم که داشتن Monitoring با داشتن Observability و Alerting درست فرق دارد.
1️⃣ ءMetrics؛ سیستم الان چه وضعیتی دارد؟
Metric یک اندازهگیری عددی از وضعیت سیستم است.
مثلاً:
http_requests_total
http_request_duration
http_errors_total
cpu_usage
memory_usage
queue_depth
active_connections
مثلاً:
Request Rate = 5000 req/s
Error Rate = 4.2%
P99 Latency = 2.8s
ءMetric برای جوابدادن به سؤالهایی مثل این عالی است:
«الان سیستم سالم است؟»
2️⃣ ءLogs؛ چه اتفاقی افتاد؟
ءLog معمولاً یک Event یا Record مربوط به اتفاقی است که در سیستم رخ داده.
مثلاً:
{
"level": "Error",
"message": "Payment failed",
"orderId": "12345",
"paymentProvider": "X",
"traceId": "abc-123"
}ءLog به ما Context بیشتری میدهد.
مثلاً Metric میگوید:
Payment Error Rate = 8%
ولی Log میتواند نشان دهد:
PaymentProviderTimeout
OrderId = 12345
Provider = X
TraceId = abc-123
3️⃣ ءTrace؛ درخواست از کجا عبور کرد؟
فرض کن کاربر این Request را ارسال میکند:
POST /orders
ءRequest وارد سیستم میشود:
API
↓
Order Service
↓
Payment Service
↓
Inventory Service
↓
Database
ءTrace میتواند مسیر این Request را نشان دهد:
Trace
│
├── API 20ms
│
├── Order Service 35ms
│
├── Payment Service 800ms
│ │
│ └── Provider 780ms
│
└── Inventory 25ms
حالا میفهمیم مشکل دقیقاً کجاست.
🎯 اما Monitoring بهتنهایی کافی نیست
فرض کن این Metric را داریم:
CPU = 92%
آیا باید Alert بزنیم؟ لزوماً نه.
ممکن است:
CPU = 92%
Latency = normal
Error Rate = normal
Users = happy
در این حالت CPU بالا الزاماً به معنی Incident نیست.
حالا سناریوی دیگری:
Error Rate = 8%
P99 Latency = 4s
اینجا احتمالاً کاربران واقعاً مشکل دارند.
پس یک اصل مهم در Alerting این است:
تا جای ممکن روی Symptomای Alert کن که نشاندهندهی User Impact است، نه روی تکتک علتهای احتمالی.
ءPrometheus نیز در راهنمای Alerting خود توصیه میکند تا حد امکان روی Symptoms مرتبط با User Pain هشدار بدهیم و از Alertهایی که هیچ اقدام مشخصی به دنبال ندارند دوری کنیم.
🚨 پس چه چیزهایی را Alert کنیم؟
مثلاً برای یک API:
High Error Rate
High Latency
Service Unavailable
Queue Backlog
Database Saturation
Capacity Exhaustion
مثلاً:
Error Rate > 5%
for 5 minutes
یا:
P99 Latency > 2 seconds
for 10 minutes
اما این عددها Universal نیستند.
Threshold باید بر اساس:
SLO
Traffic Pattern
Business Requirement
Capacity
Historical Behavior
تعیین شود.
⏳ چرا
for مهم است؟فرض کن CPU برای یک لحظه میشود:
92%
اگر همان لحظه Alert بفرستیم:
🚨 CPU HIGH!
ممکن است فقط یک Spike چندثانیهای بوده باشد.
بهتر است بگوییم:
CPU > 90%
for 10 minutes
یعنی شرط باید مدتی پایدار بماند.
ءPrometheus برای Alert Rule چنین مفهومی را با
for پشتیبانی میکند؛ Alert ابتدا Pending میشود و اگر شرط برای مدت تعیینشده برقرار بماند، وارد حالت Firing میشود. همچنین keep_firing_for برای کاهش بعضی Flappingها قابل استفاده است.🧠 ءAlert خوب چه شکلی است؟
این Alert:
🚨 API Error Rate High
خیلی مفید نیست. On-Call باید دوباره برود بگردد:
کدام API؟
کدام Environment؟
کدام Region؟
از کی؟
چقدر؟
چرا؟
ءAlert بهتر:
🚨 High API Error Rate
Service: Payment
Environment: Production
Region: EU
Error Rate: 8.4%
Threshold: 5%
Started: 03:12 UTC
Dashboard: ...
Runbook: ...
Trace: ...
خود Prometheus هم برای Alertها امکان استفاده از Labels و Annotations را فراهم میکند تا اطلاعاتی مثل Summary، Description و لینک Runbook همراه Alert قرار بگیرد.
🔥 حالا مشکل اصلی: Alert Storm
فرض کن Database از دسترس خارج شده. ۱۰۰ Pod داری.
هر Pod میگوید:
اگر برای هر Pod یک Alert بفرستی:
تو ۱۰۰ مشکل نداری.
احتمالاً یک مشکل داری:
اینجا Alert Grouping مهم میشود.
ءAlertmanager میتواند Alertهای مشابه را Group کند، Deduplicate کند و به Receiver مناسب Route کند.
مثلاً:
اما جزئیات ۱۰۰ Pod همچنان قابل مشاهده هستند.
🧠 و مهمترین نکته
ءMonitoring خوب فقط به تو نمیگوید:
سیستم Observability خوب کمک میکند بفهمی:
و Alerting خوب قرار نیست گوشی تیم را با صدها Notification منفجر کند.
باید سیگنال قابلاقدام ایجاد کند.
پس:
بلکه:
🚨 ءMonitoring بدون Alerting فقط Dashboard است.
🚨 ءAlerting بدون Observability فقط سر و صداست.
و یک سیستم خوب باید هر دو را کنار هم داشته باشد.
فرض کن Database از دسترس خارج شده. ۱۰۰ Pod داری.
هر Pod میگوید:
❌ Cannot connect to Database
اگر برای هر Pod یک Alert بفرستی:
🚨 Pod-1 DB Down
🚨 Pod-2 DB Down
🚨 Pod-3 DB Down
...
🚨 Pod-100 DB Down
تو ۱۰۰ مشکل نداری.
احتمالاً یک مشکل داری:
Database Unavailable
اینجا Alert Grouping مهم میشود.
ءAlertmanager میتواند Alertهای مشابه را Group کند، Deduplicate کند و به Receiver مناسب Route کند.
مثلاً:
100 alerts
↓
Grouping
↓
1 Notification
اما جزئیات ۱۰۰ Pod همچنان قابل مشاهده هستند.
🧠 و مهمترین نکته
ءMonitoring خوب فقط به تو نمیگوید:
«سیستم مشکل دارد.»
سیستم Observability خوب کمک میکند بفهمی:
«چه اتفاقی افتاده، کجا اتفاق افتاده، روی چه چیزی اثر گذاشته و برای پیدا کردن علت از کجا شروع کنم.»
و Alerting خوب قرار نیست گوشی تیم را با صدها Notification منفجر کند.
باید سیگنال قابلاقدام ایجاد کند.
پس:
More Alerts
≠
Better Monitoring
بلکه:
Better Signals
+
Meaningful Alerts
+
Good Context
+
Good Runbooks
=
Better Incident Response
🚨 ءMonitoring بدون Alerting فقط Dashboard است.
🚨 ءAlerting بدون Observability فقط سر و صداست.
و یک سیستم خوب باید هر دو را کنار هم داشته باشد.
یه مدتی قراره از دنیای NET. فاصله بگیرم،
چون وقتشه برم سربازی.
راستش نمیدونم این مدت رو چجوری باید سر کنم و وقتی برگردم، دنیای Software Development چه شکلی باشه.
شاید اون موقع هنوز #C و NET. بخش بزرگی از دنیای Backend باشن،
شاید هم با سرعتی که هوش مصنوعی داره پیش میره، خیلی چیزها عوض شده باشه.
اصلا شاید چند سال دیگه بخشی از چیزهایی که امروز براشون وقت میذاریم، دیگه به شکل امروزشون وجود نداشته باشن.
ولی یک چیز رو میدونم،
اگر در طول خدمت فرصتی برای یادگیری، فکر کردن و نوشتن داشته باشم، و مرخصی و زمان آزاد اجازه بده، اینجا رو رها نمیکنم.
هر وقت چیزی برای گفتن داشته باشم،
از تجربههای واقعی گرفته تا C#، .NET، معماری، Backend و چیزهایی که در مسیر یاد میگیرم، باهاتون به اشتراک میذارم.
شاید تعداد پستها کمتر بشه،
شاید فاصله بینشون بیشتر بشه،
اما امیدوارم CSharpGeeks همچنان جایی باشه برای آدم هایی که فقط نمیخوان کد بزنن، میخوان بفهمن چرا اینطور کد میزنن.🤍
چون وقتشه برم سربازی.
راستش نمیدونم این مدت رو چجوری باید سر کنم و وقتی برگردم، دنیای Software Development چه شکلی باشه.
شاید اون موقع هنوز #C و NET. بخش بزرگی از دنیای Backend باشن،
شاید هم با سرعتی که هوش مصنوعی داره پیش میره، خیلی چیزها عوض شده باشه.
اصلا شاید چند سال دیگه بخشی از چیزهایی که امروز براشون وقت میذاریم، دیگه به شکل امروزشون وجود نداشته باشن.
ولی یک چیز رو میدونم،
اگر در طول خدمت فرصتی برای یادگیری، فکر کردن و نوشتن داشته باشم، و مرخصی و زمان آزاد اجازه بده، اینجا رو رها نمیکنم.
هر وقت چیزی برای گفتن داشته باشم،
از تجربههای واقعی گرفته تا C#، .NET، معماری، Backend و چیزهایی که در مسیر یاد میگیرم، باهاتون به اشتراک میذارم.
شاید تعداد پستها کمتر بشه،
شاید فاصله بینشون بیشتر بشه،
اما امیدوارم CSharpGeeks همچنان جایی باشه برای آدم هایی که فقط نمیخوان کد بزنن، میخوان بفهمن چرا اینطور کد میزنن.🤍