این مطلب به بررسی مشکلی میپردازد که در یک سرویس مصرفکننده کافکا (Kafka consumer service) با پردازش هزاران پیام در دقیقه رخ داد. این سیستم برای ماهها پایدار بود، اما ناگهان هشدارهای گرافانا (Grafana) در کانال اسلک (Slack) تیم توسعه و عملیات (DevOps) فعال شدند و نشان از افزایش سریع تاخیر مصرفکننده کافکا (Kafka consumer lag) داشتند.
شرح مسئله و تشخیص اولیه
بررسیها نشان داد که هیچ استقرار جدیدی صورت نگرفته، تغییرات زیرساختی عمدهای اعمال نشده و افزایش ناگهانی در ترافیک وجود نداشته است. با این حال، با بررسی ابزار نیو رلیک (New Relic) و ردیابی توزیعشده (distributed traces)، مشخص شد که یک کلاس خاص، یعنی نگاشتگر اشیاء انتقال داده (DTO mapper)، به طور مداوم بخش عمدهای از زمان پردازش هر پیام را به خود اختصاص میدهد. به عبارت دیگر، یک فرایند به ظاهر ساده مانند تبدیل اشیاء، به گلوگاه اصلی خط لوله پردازش تبدیل شده بود.
پیشینه و دلیل استفاده از روش نگاشت فعلی
در گذشته، به منظور تسهیل فرایند شکستن یک سیستم یکپارچه بزرگ (large monolith) به میکروسرویسها (microservices)، یکی از جریانهای پردازش اصلی به یک میکروسرویس مستقل منتقل شده بود. برای حفظ برابری رفتار و کاهش خطر مهاجرت، منطق نگاشت موجود به همان شکل قبلی منتقل شد. این منطق قدیمی به شدت به نگاشت مبتنی بر بازتاب (reflection-based mapping) متکی بود، که در آن زمان اهمیت چندانی به کارایی آن داده نشد؛ زیرا در سیستم یکپارچه به خوبی کار میکرد. از بازتاب برای نگاشت پویا (dynamically map) فیلدها بین مدلهای داخلی و اشیاء انتقال داده (DTOs) بدون نیاز به نوشتن کد تکراری (boilerplate)، مدیریت ساختارهای چندشکلی (polymorphic structures) یا فیلدهای اختیاری (optional fields) به صورت عمومی و تبدیل خودکار بارهای داده (payloads) با ساختار JSON تودرتو به مدلهای دامنه (domain models) استفاده میشد. در ابتدا، این روش کارآمد به نظر میرسید، اما با افزایش ترافیک و تغییر الگوهای استفاده، مشکلات کارایی آشکار شدند.
علائم بروز مشکل
علائم این مشکل عبارت بودند از: افزایش تدریجی تاخیر کافکا (Kafka lag)، افزایش غیرعادی مصرف واحد پردازش مرکزی (CPU) در گره مصرفکننده (consumer node)، افزایش زمان تاخیر (latency increase) پردازش هر پیام از حدود 15 میلیثانیه به بیش از 200 میلیثانیه و در نهایت، کاهش کیفیت سرویس (service degradation) و تجربه زمان انتظار (timeouts) در سیستمهای پاییندستی (downstream systems). در ابتدا تصور میشد که وابستگیهای خارجی (external dependencies) عامل این مشکلات هستند، اما ردیابی توزیعشده در نیو رلیک (New Relic's distributed tracing) نشان داد که گلوگاه در داخل سیستم قرار دارد.
ریشه اصلی مشکل
با بررسی دقیقتر ردیابیها (traces)، مشخص شد که لایه نگاشت اشیاء انتقال داده (DTO mapping layer) بیش از 80 درصد از کل زمان پردازش را به خود اختصاص میدهد. دلیل این امر، حتی با وجود اینکه همین منطق برای ماهها بدون مشکل کار میکرد، به موارد زیر نسبت داده شد:
- فیلدهای اختیاری در بار داده (Optional payload fields): برخی از پیامهای ورودی کافکا (incoming Kafka messages) دارای فیلدهای اختیاری بودند که با دادههای بزرگ یا ساختارهای تودرتو عمیق پر شده بودند. فرایند بازتاب (Reflection) برای تجزیه (parse) و تبدیل پویای این فیلدها به تلاش بیشتری نیاز داشت.
- وجود چندین بار داده سنگین (Multiple such payloads): زمانی که دستهای از این بارهای داده سنگین به طور همزمان دریافت میشد، یک وضعیت بحرانی ایجاد میشد و مصرفکننده (consumer) نمیتوانست با سرعت کافی آنها را پردازش کند.
- جمعآوری زباله (Garbage collection): نگاشت مبتنی بر بازتاب (reflection-based mapping) منجر به تخصیص بیش از حد اشیاء (excessive object allocations) میشد که این امر چرخههای مکرر جمعآوری زباله (GC cycles) را فعال میکرد.
به طور خلاصه، سیستم به طور ناگهانی از کار نیفتاد، بلکه به تدریج زیر بار سربار ناشی از لایه انتزاعی (abstraction) خود غرق شد.
راه حل: رویکرد دستی و سریع
برای رفع فوری مشکل، منطق نگاشت اشیاء انتقال داده (DTO mapping logic) برای مسیرهای حیاتی (critical paths) به صورت دستی بازنویسی شد. در این رویکرد:
- از بازتاب (reflection) اجتناب شد.
- ساختارهای شیء در صورت امکان مسطح (flattened) شدند.
- معیارهای واحد (unit benchmarks) برای مقایسه استراتژیهای نگاشت اضافه شدند.
- از ساختارها (structs) برای تبدیلات داده سبکوزن (lightweight data transformations) استفاده شد.
همچنین اطمینان حاصل شد که:
- تبدیلات قابل استفاده مجدد (reusable transformations) کش (caching) شوند.
- اشیاء انتقال داده میانی غیرضروری (unnecessary intermediate DTOs) حذف شوند.
نگاشت دستی (manual mapping) به توقف سریع روند رو به وخامت کمک کرد، اما تیم توسعه آگاه بود که این یک راه حل پایدار بلندمدت نیست.
راه حلهای بلندمدت
برای جلوگیری از بازگشت مشکلات (regressions) و بازگرداندن قابلیت نگهداری (maintainability) بدون کاهش کارایی، راه حلهای بلندمدت زیر در نظر گرفته شدند:
- تولیدکنندههای سورس (Source generators): استفاده از تولیدکنندههای سورس داتنت (.NET source generators) برای ایجاد کد نگاشت با کارایی بالا و ایمن در زمان کامپایل (compile-time-safe, high-performance mapping code) بدون نیاز به بازتاب در زمان اجرا (runtime reflection).
- کتابخانههای نگاشت سبکوزن (Lightweight mapping libraries): ارزیابی جایگزینهایی مانند مپستر (Mapster) که نگاشت بدون تخصیص حافظه (zero-allocation mapping) با پشتیبانی از تولید استاتیک (static generation) ارائه میدهند.
- تسطیح بار داده (Payload flattening): تغییر تولیدکنندگان بالادستی (upstream producers) برای مسطح کردن و سادهسازی ساختارهای بار داده قبل از رسیدن آنها به مصرفکننده.
- واگردانی جزئی (Partial deserialization): واگردانی (deserializing) تنها بخشهای مورد نیاز از بار داده JSON در صورت لزوم (به عنوان مثال، با استفاده از JsonDocument یا تجزیه انتخابی).
تاثیرات اعمال تغییرات
نتایج اعمال این تغییرات فوری بود:
- تاخیر کافکا (Kafka lag) ظرف 20 دقیقه پس از استقرار مجدد (redeployment) تقریباً به صفر رسید.
- زمان پردازش هر پیام (latency) از حدود 200 میلیثانیه به حدود 30 میلیثانیه کاهش یافت.
- مصرف واحد پردازش مرکزی (CPU usage) در گره مصرفکننده (consumer node) 60 درصد کاهش یافت.
- مصرف حافظه (memory usage) با کاهش مکثهای جمعآوری زباله (GC pauses) تثبیت شد.
درسهای آموخته شده
درسهای کلیدی که از این تجربه به دست آمد عبارتند از:
- اندازهگیری آنچه مهم است (Measure what matters): بدون ابزاری مانند نیو رلیک (New Relic)، تشخیص ریشه اصلی مشکل بسیار دشوارتر میبود.
- بازتاب رایگان نیست (Reflection isn't free): لایههای انتزاعی میتوانند هزینههای عملکردی قابل توجهی داشته باشند و باید از هزینههای آنها آگاه بود.
- به طور منظم پروفایل کنید (Profile regularly): گلوگاههای عملکردی اغلب در غیرمنتظرهترین قسمتهای سیستم پنهان میشوند.
- کد دستی لزوماً بد نیست (Manual ≠ Bad): در مسیرهای با توان عملیاتی بالا (high-throughput paths)، کد دستنویس میتواند به بهبود کارایی سیستم کمک کند.