Interview Bank · بانک سوالات متوسطIntermediate ~46 دقیقه مطالعه~38 min read
پرسشوپاسخ سریع: Spring، JPA و پایگاهدادهRapid-Fire Q&A: Spring, JPA & Databases
یک دورهٔ کامل و آموزشمحور از بیش از ۴۵ پرسش واقعی مصاحبهٔ ارشد در Spring، JPA/Hibernate، SQL و Redis/Kafka/ClickHouse که هر تله (N+1، فراخوانی داخلی، بهروزرسانی گمشده، cache-aside) را با تشبیه و گامبهگام از صفر تا صد به تو یاد میدهد.A fully taught, zero-to-hero walk through 45+ real senior interview questions on Spring, JPA/Hibernate, SQL and Redis/Kafka/ClickHouse, unpacking every classic trap (N+1, self-invocation, lost update, cache-aside) with analogies and step-by-step reasoning.
این فصل یک میدان تیر است، ولی اینبار قرار نیست فقط شلیک کنی و رد شوی — قرار است بفهمی چرا هر گلوله به هدف میخورد. هر پرسش کوتاه است؛ اما هر پاسخ چیزی است که یک مهندس ارشد قوی واقعاً در اتاق مصاحبه میگوید: با استدلال، نه فقط کلیدواژه. پرسشها در هر بخش پلکانی سختتر میشوند. علامت [سخت] روی تلههایی است که «میانی» را از «ارشد» جدا میکند — همان جاهایی که مصاحبهگر لبخند میزند و منتظر است لیز بخوری.
هدف من این است که وقتی این فصل تمام شد، بتوانی هر یک از این ۴۵ پرسش را نه از روی حفظ، بلکه از روی درک جواب بدهی. اصطلاحات فنی داخل پرانتز به انگلیسی نگه داشته شدهاند چون در مصاحبه هم همانها را میشنوی.
چهار ایستگاه داریم و هرکدام یک لایه از پشتهٔ یک بکاند جاواست:
- هستهٔ Spring — تزریق وابستگی (DI)، scope بینها، و AOP مبتنی بر پروکسی. اینجا یاد میگیری چرا «فراخوانی داخلی» شرور است.
- JPA / Hibernate / تراکنشها — چرخهٔ عمر موجودیت، dirty checking، N+1، قفلگذاری، و کِی
@Transactionalبیصدا خیانت میکند. - SQL، ایندکس و ایزولهسازی — سطوح ایزوله، MVCC، ایندکس B-tree و خواندن یک EXPLAIN.
- Redis، Kafka، ClickHouse — کش، ترتیب پیام، و اینکه چرا یک دیتابیس ستونی برای OLTP فاجعه است. یک نخ مشترک همه را به هم میدوزد: مرز پروکسی در Spring، و همزمانی (concurrency) در دیتابیس. اگر این دو را عمیق بفهمی، نیمی از تلهها خودبهخود آشکار میشوند.
بخش ۰ — چند واژه که باید از قبل بدانی
قبل از شروع، سه واژه را با تشبیه سرِ جایشان بنشانیم تا در ادامه سرد رهایشان نکنم.
تصور کن یک آشپزخانهٔ حرفهای داری. تو (کد تو) غذا سفارش میدهی، ولی خودت گوجه را نمیکاری و چاقو را تیز نمیکنی. یک کانتینر (container) — همان آشپزخانه — مواد و ابزار آماده را به تو میدهد. هر شیء آمادهای که آشپزخانه مدیریت میکند یک بین (bean) است. تو فقط میگویی «من به OrderRepository نیاز دارم» و کانتینر یکی دستت میدهد. این کل ایدهٔ Spring است.
- پروکسی (proxy): یک «بدل» که جلوی شیء واقعی میایستد و قبل/بعد از هر تماس کاری انجام میدهد (مثل منشیای که پیش از وصلکردنت به مدیر، تماس را ثبت میکند). این کلید فهمِ AOP،
@Transactionalو@Cacheableاست. - اتمیک (atomic): عملیاتی که یا کامل انجام میشود یا اصلاً — وسط راه قطع نمیشود و کسی نیمکارهٔ آن را نمیبیند. مثل عوضکردن دنده که یا کامل جا میافتد یا نه.
- idempotent (خودتوان): عملی که اگر چند بار تکرارش کنی، نتیجه با یکبار فرقی نکند. زدنِ دکمهٔ «طبقهٔ ۳» در آسانسور idempotent است؛ صد بار بزنی باز طبقهٔ ۳ میروی. این مفهوم در Kafka جانِ کار است.
بخش ۱ — هستهٔ Spring، تزریق وابستگی (DI) و AOP
پ۱. تزریق وابستگی (Dependency Injection) واقعاً چه مشکلی را حل میکند؟
فرض کن هر کارمند مجبور بود لامپ سوختهٔ اتاقش را خودش بخرد، بالا برود و عوض کند. هرجومرج! در یک شرکت درست، تو فقط زنگ میزنی و تأسیسات لامپ درست را میآورد. تو دیگر نمیدانی لامپ از کدام برند است و کِی خریده شده — فقط «نور» میخواهی. این وارونهکردن مسئولیتِ تأمین است.
DI کنترل ساخت اشیاء را وارونه میکند. بهجای اینکه یک کلاس همکاران خود را با new بسازد (که نوع مشخص و چرخهٔ عمر را سیمکشی میکند)، یک کانتینر آنها را فراهم میکند. سود اصلی «کد کمتر» نیست — بلکه سه چیز است:
- قابلیت تست (testability): میتوانی بهجای وابستگی واقعی یک mock/fake تزریق کنی.
- پیادهسازی قابل تعویض: پشت یک اینترفیس، امروز پیادهسازی A و فردا B، بدون دستزدن به مصرفکننده.
- مدیریت متمرکز چرخهٔ عمر و scope.
DI یک شکل از وارونگی کنترل (Inversion of Control / IoC) است؛ و ApplicationContext همان کانتینر IoC است — همان «تأسیسات» رستوران/شرکت.
پ۲. تزریق سازنده (constructor) در مقابل فیلد (field) در مقابل ستر (setter) — کدام و چرا؟
اگر فقط یک قانون از این فصل با خودت ببری، همین باشد.
تزریق سازنده انتخاب پیشفرض است، چون:
- وابستگیها را صریح و اجباری میکند (نگاه به سازنده = فهرست کامل نیازها).
- امکان فیلد
finalمیدهد → تغییرناپذیری و ایمنی در برابر thread. - fail fast: اگر بینی موجود نباشد، همان لحظهٔ ساخت شکست میخورد، نه دیرتر و مرموزتر.
- اجازه میدهد کلاس را در تست واحد بدون خود Spring بسازی: فقط
new MyService(mockRepo).
تزریق فیلد (@Autowired روی فیلد خصوصی) وابستگیها را پنهان میکند، برای تست به reflection نیاز دارد، و اجازه میدهد وابستگیهای چرخهای پنهان بمانند. تزریق setter فقط برای وابستگیهای واقعاً اختیاری است.
از Spring 4.3 به بعد، اگر کلاس فقط یک سازنده داشته باشد، دیگر لازم نیست @Autowired رویش بگذاری — Spring خودش میفهمد.
پ۳. scope پیشفرض بین چیست و آیا thread-safe است؟
singleton بودن بهمعنای thread-safe بودن نیست. خیلیها این دو را قاطی میکنند.
scope پیشفرض singleton است — یعنی یک نمونه بهازای هر کانتینر (نه بهازای هر JVM؛ اگر دو کانتینر داشته باشی دو نمونه داری). Spring آن را thread-safe نمیکند؛ این نمونه بین همهٔ ریسهها مشترک است، پس یا باید بدون حالت (stateless) نگهش داری یا خودت حالت قابلتغییر را با قفل محافظت کنی.
scopeهای دیگر:
prototype: نمونهٔ جدید در هر lookup — و توجه: Spring چرخهٔ عمر کاملش را مدیریت نمیکند (callback نابودی/destroy ندارد).- scopeهای وب:
request،session،application،websocket.
پ۴. [سخت] یک بین prototype را در یک singleton تزریق میکنی. چند نمونهٔ prototype وجود دارد؟
«دقیقاً یکی.» و بعد دلیلش را بگو: prototype یکبار و هنگام ساختِ singleton حل (resolve) میشود و همان مرجع واحد برای همیشه بازاستفاده میشود. تزریق در زمان ساخت رخ میدهد، نه در هر فراخوانی متد. مصاحبهگر دنبال این است که بفهمی «تزریق یک رویداد یکباره است، نه یک کارخانهٔ همیشهفعال».
برای گرفتن یک prototype تازه در هر بار، باید حل را به تعویق بیندازی. سه راه:
ObjectProvider<T>(یاProvider<T>) تزریق کن و در لحظهٔ نیاز.getObject()بزن.- از تزریق متد
@Lookupاستفاده کن. ApplicationContextرا تزریق کن وgetBean(...)صدا بزن.
هر سه، تصمیمِ «کدام نمونه» را از زمان ساخت به زمان استفاده منتقل میکنند.
پ۵. Spring دو بین از یک نوع را چطور حل میکند؟
اگر دو بینِ همنوع داشته باشی و نگویی کدام، Spring گیج میشود و NoUniqueBeanDefinitionException پرتاب میکند. راههای رفع ابهام:
@Primaryروی یکی → «برندهٔ پیشفرض».@Qualifier("name")سرِ نقطهٔ تزریق → انتخاب صریح.- تطبیق نام متغیرِ نقطهٔ تزریق با نام بین.
اگر بهجای یک بین، یک List<Handler> تزریق کنی، Spring همهٔ بینهای منطبق را میریزد داخلش. این برای الگوی «چند استراتژی» عالی است: هر Handler را جدا تعریف کن و همه را یکجا بگیر.
پ۶. Spring چطور وابستگی چرخهای را میشکند و کِی نمیتواند؟
دو کارگر A و B: A میگوید «تا تو کارت را تمام نکنی من شروع نمیکنم» و B هم همین را میگوید. اگر هر دو در لحظهٔ تولد منتظر دیگری باشند، هیچکدام هرگز متولد نمیشود. ولی اگر بشود هرکدام «نیمهساخته» بهدنیا بیاید و بعداً کامل شود، گره باز میشود.
برای تزریق فیلد/setter روی singletonها، Spring از یک کش سهسطحی استفاده میکند که یک مرجعِ زودهنگام و نیمهساخته را نمایان میکند، پس چرخهٔ A↔B حل میشود.
برای تزریق سازنده نمیتواند — هیچکدام را نمیشود اول کامل ساخت، چون سازنده به دیگری نیاز دارد — و BeanCurrentlyInCreationException میگیری.
راهحلها: بازطراحی، شکستن چرخه با @Lazy روی یکی از وابستگیها، یا ObjectProvider. ولی یادت باشد: چرخه معمولاً بوی بد طراحی است، نه چیزی که باید هوشمندانه دورش زد.
پ۷. BeanPostProcessor در مقابل BeanFactoryPostProcessor چیست؟
BeanFactoryPostProcessor روی نقشهها کار میکند: پس از اینکه تعاریف بین بارگذاری شد ولی هنوز هیچ بینی ساخته نشده، میتواند نقشه را دستکاری کند (مثلاً PropertySourcesPlaceholderConfigurer که ${...}ها را با مقدار واقعی جایگزین میکند). BeanPostProcessor روی خودِ ساختمان کار میکند: گرد نمونهٔ هر بین در زمان مقداردهی اولیه.
BeanPostProcessor دو قلاب دارد: postProcessBeforeInitialization و postProcessAfterInitialization. اینجاست که کارهای جادویی Spring رخ میدهد: ساختِ پروکسیهای AOP، و سیمکشی @Autowired/@PostConstruct. دو نمونهٔ واقعی: AutowiredAnnotationBeanPostProcessor و CommonAnnotationBeanPostProcessor.
پ۸. AOP در Spring واقعاً زیر کاپوت چطور کار میکند؟
این جملهٔ کلیدی است. Spring بایتکد کلاس تو را دستکاری نمیکند (آن کار AspectJ است). بهجایش یک بدل جلوی بین میگذارد.
در زمان راهاندازی، یک BeanPostProcessor بینهای advised (آنهایی که باید قابلیت اضافه بگیرند) را در یک پروکسی میپیچد:
- اگر بین یک اینترفیس پیادهسازی کند → JDK dynamic proxy.
- وگرنه → یک زیرکلاسِ CGLIB.
فراخوانیها این مسیر را طی میکنند: پروکسی → زنجیرهٔ advice → هدف (target). نتیجهٔ کلیدی که مستقیماً به پرسش بعدی وصل میشود: advice فقط روی فراخوانیهایی فعال میشود که از پروکسی عبور میکنند — یعنی فراخوانیهای بیرونی به بین.
پ۹. [سخت] چرا فراخوانی this.someTransactionalMethod() تراکنش را رد میکند؟
اگر فقط یک تله در کل این فصل قرار است تو را در مصاحبه بگیرد، همین است. فراخوانی داخلی (self-invocation) بهشکل بدنامی بیصدا شکست میخورد — نه خطا، نه لاگ، فقط تراکنشی که هرگز شروع نشد.
چون this شیءِ هدفِ خام است، نه پروکسی. تمام adviceهای AOP — شامل @Transactional، @Cacheable و @Async — روی پروکسی زندگی میکنند. وقتی داخل همان کلاس this.method() صدا میزنی، تماس هرگز از پروکسی رد نمیشود، پس هیچ مرز تراکنش، کش یا async ساخته نمیشود.
راهحلها:
- متد را به یک بینِ دیگر منتقل کن تا تماس واقعاً از بیرون بیاید.
- مرجع به خود را تزریق کن:
@Lazy MyService selfو بعدself.method(). AopContext.currentProxy()را بگیر (نیازمندexposeProxy=true).- به AspectJ (بافت زمان بارگذاری/کامپایل) سوئیچ کن که خودِ کلاس را ابزارگذاری میکند و دیگر به مرز پروکسی وابسته نیست.
پ۱۰. ترتیب @PostConstruct، InitializingBean و @Bean(initMethod) چیست؟
ترتیب دقیق: ابتدا @PostConstruct، سپس InitializingBean.afterPropertiesSet()، سپس init-method سفارشی. برای قابلیت انتقال (portability) بهتر است @PostConstruct (یا منطق داخل سازنده) را ترجیح بدهی چون به Spring گره نمیخورد.
در Java 11+، انوتیشنهای @PostConstruct/@PreDestroy از خودِ JDK بیرون رفتند (بخشی از حذف Java EE modules). به همین خاطر Spring Boot کتابخانهٔ jakarta.annotation-api را بهعنوان وابستگی میآورد تا اینها باز هم کار کنند.
بخش ۲ — Spring Data، JPA، Hibernate و تراکنشها
پ۱۱. تفاوت JPA، Hibernate و Spring Data JPA؟
JPA مثل استانداردِ پریز برق است: یک مشخصات (specification) که میگوید شکل و ولتاژ باید چه باشد (انوتیشنها + API EntityManager). Hibernate مثل یک برندِ سازندهٔ پریز است: رایجترین پیادهسازی که استاندارد را واقعی میکند (provider). Spring Data JPA مثل یک برقکارِ دستیار است: یک لایه روی همه که پیادهسازی repository را از روی نامِ متدهای اینترفیس تولید میکند و EntityManager/تراکنشها را برایت سیمکشی میکند.
میتوانی هر وقت خواستی به قابلیتهای خاصِ Hibernate پایین بیایی، ولی هرچه بیشتر این کار را بکنی قابلیت انتقال (به provider دیگر) کمتر میشود.
پ۱۲. حالتهای چرخهٔ عمر موجودیت (entity) در JPA را توضیح بده.
یک entity مثل کارمندی است که رابطهاش با دفتر مرکزی (persistence context) چهار حالت دارد. تا وقتی «متصل» است، هر تغییرِ او خودکار در سوابق ثبت میشود؛ همینکه رابطه قطع شد، دیگر کسی حواسش به تغییراتش نیست.
- Transient/new — با
newساخته شده، بدون هویت، ردیابینشده. - Managed/persistent — به یک persistence context متصل؛ تغییرات خودکار flush میشوند (dirty checking).
- Detached — قبلاً managed بود ولی context بسته شد؛ تغییرات دیگر ردیابی نمیشوند.
- Removed — برای حذف علامت خورده؛ هنگام flush دستور DELETE اجرا میشود.
persist، merge، remove، detach و بستن context، موجودیتها را بین این حالتها جابهجا میکنند.
پ۱۳. persistence context و dirty checking چیست؟
persistence context یک کش سطح اول (first-level cache) و یک واحد کار (unit of work) است که به EntityManager (و با Spring، به تراکنش) گره خورده. موجودیتهای managed را با هویتشان ردیابی میکند.
هنگام flush، Hibernate هر موجودیت managed را با یک snapshot که در زمان بارگذاری گرفته مقایسه میکند و برای فیلدهای تغییریافته خودش UPDATE میسازد. یعنی برای موجودیتی که از پیش managed است، هرگز لازم نیست save() صدا بزنی؛ فقط کافی است مقدارش را عوض کنی. همین «مقایسه با snapshot و صدور خودکار UPDATE» را dirty checking مینامند.
پ۱۴. save() در مقابل saveAndFlush() در مقابل persist() در مقابل merge()؟
save() در Spring Data هوشمند است: برای موجودیت جدید persist() و برای موجودیتِ دارای id تنظیمشده (یعنی detached) merge() را صدا میزند.
persist: یک موجودیت جدید را managed میکند وvoidبرمیگرداند.merge: حالتِ موجودیت detached را روی یک کپیِ managed کپی میکند و آن کپی را برمیگرداند.saveAndFlush: یک flush فوری به دیتابیس را اجبار میکند، بهجای موکولکردن به commit تراکنش.
merge آرگومانت را managed نمیکند — آرگومان detached میماند و یک کپیِ جدید برمیگردد. اگر بعد از merge باز هم روی آرگومان اصلی کار کنی (نه مقدار بازگشتی)، تغییراتت گم میشوند. همیشه از مقدارِ بازگشتیِ merge استفاده کن.
پ۱۵. [سخت] مشکل N+1 select چیست و چطور آن را میکشی؟
فرض کن لیست ۱۰۰ مشتری را میگیری (۱ کوئری)، بعد برای هرکدام جداگانه زنگ میزنی تا آدرسش را بپرسی (۱۰۰ کوئری). میشد در همان تماسِ اول همه را بپرسی. این ۱۰۱ رفتوبرگشت بهجای ۱ یا ۲، همان بلای N+1 است.
با انجمنهای (association) FetchType.LAZY، بارگذاری N والد و سپس لمسِ انجمنِ هر والد باعث ۱ کوئری برای والدها + N کوئری برای فرزندان = N+1 رفتوبرگشت میشود. با لاگِ SQL یا ابزاری مثل datasource-proxy تشخیصش بده. راههای کشتنش:
JOIN FETCHدر JPQL، یا@EntityGraphروی متد repository.- واکشی دستهای:
@BatchSize(size=N)یاhibernate.default_batch_fetch_sizeکه N کوئری را بهN/sizeکوئریِIN (...)تبدیل میکند. - کوئری projection/DTO که فقط آنچه نیاز داری را انتخاب میکند.
اگر JOIN FETCH را روی یک collection با pagination ترکیب کنی، Hibernate نمیتواند در دیتابیس صفحهبندی کند و مجبور میشود همه را بیاورد و در حافظه صفحهبندی کند — و هشدار HHH000104 در لاگ میگذارد. برای این حالت بهجای JOIN FETCH از @BatchSize یا راهبرد دو-کوئری استفاده کن.
پ۱۶. [سخت] چرا FetchType.EAGER روی @ManyToOne یک تله است، حتی با اینکه پیشفرض است؟
EAGER یعنی هر کوئریای که موجودیت را بارگذاری میکند، انجمن را هم بارگذاری میکند — حتی وقتی اصلاً نیازش نداری، اغلب با selectهای اضافه. بدتر: بد ترکیب میشود؛ چند انجمن EAGER با هم میتوانند join دکارتی یا طوفانِ کوئری بسازند، و نمیتوانی بهازای یک کوئریِ خاص lazyاش کنی.
همهچیز را LAZY کن، بعد بهازای هر مورد استفاده صریحاً با JOIN FETCH یا entity graph واکشی کن. یادت باشد پیشفرضها متفاوتاند: @ManyToOne و @OneToOne پیشفرض EAGER هستند (پس باید صراحتاً fetch = LAZY بگذاری)، ولی @OneToMany و @ManyToMany پیشفرض LAZY هستند.
پ۱۷. چه چیزی باعث LazyInitializationException میشود و راهحل درست چیست؟
یک انجمن lazy مثل «بعداً میپرسم» است. اما اگر بعد از بستهشدنِ باجه (persistence context/تراکنش) سراغ پرسیدن بروی — مثلاً در لایهٔ view — دیگر کسی پشت باجه نیست. همین LazyInitializationException است.
راهحلِ غلط spring.jpa.open-in-view=true (الگوی Open Session In View) است؛ این باجه را تا پایان رندرِ view باز نگه میدارد، ولی در عوض اتصالِ دیتابیس را تمام این مدت گروگان میگیرد، توان عملیاتیِ connection pool را میخورد و N+1 را پنهان میکند.
راهحلِ درست: آنچه فراخواننده نیاز دارد را داخل تراکنش واکشی کن (fetch join، entity graph، projection DTO) و یک شیء یا DTOِ کاملاً مقداردهیشده برگردان. OSIV را در production غیرفعال کن.
پ۱۸. سطوح propagation در @Transactional که واقعاً استفاده میشوند را توضیح بده.
- REQUIRED (پیشفرض): به تراکنش موجود بپیوند، یا اگر نیست یکی شروع کن.
- REQUIRES_NEW: تراکنش فعلی را معلق کن و در یک تراکنش جدیدِ مستقل اجرا کن — برای لاگ audit یا کاری که باید مستقل از بقیه commit شود. به اتصال دوم نیاز دارد و میتواند با تراکنشِ معلقشده deadlock کند.
- NESTED: یک savepoint داخل تراکنش فعلی — به savepoint برمیگردد نه کل تراکنش (فقط روی JDBC).
- SUPPORTS / NOT_SUPPORTED / MANDATORY / NEVER: برای کنترل ظریفتر.
REQUIRES_NEW یک تراکنشِ کاملاً جدا (و اتصالِ جدا) است که مستقل commit میشود. NESTED فقط یک نقطهٔ ذخیره (savepoint) داخلِ همان تراکنش است؛ اگر rollback شود فقط تا آن نقطه برمیگردی، نه اول کار.
پ۱۹. [سخت] کِی @Transactional بیصدا roll back نمیکند؟
جملهٔ کلیدی: «بهطور پیشفرض Spring فقط روی استثناهای unchecked (RuntimeException/Error) roll back میکند — یک استثنای checked تراکنش را commit میکند.» بعد بگو چطور override میکنی: @Transactional(rollbackFor = Exception.class).
سه سناریوی بیصدا که باید بشناسی:
- یک استثنای checked پرتاب میشود → Spring بهطور پیشفرض commit میکند، نه rollback.
- استثنا را داخل متد
catchمیکنی → rollback بلعیده میشود. - پس از یک فراخوانی تودرتو که تراکنش را
rollback-onlyعلامت زده، استثنا را میگیری → هنگام commit بیرونیUnexpectedRollbackExceptionپرتاب میشود.
و البته self-invocation (پ۹) یعنی اصلاً تراکنشی فعال نمیشود که بخواهد roll back کند.
پ۲۰. @Transactional از چه سطح ایزولهسازی استفاده میکند و چطور تغییرش دهم؟
پیشفرض Isolation.DEFAULT است — یعنی Spring تصمیم را به پیشفرضِ datasource/دیتابیس واگذار میکند:
- Postgres/Oracle:
READ_COMMITTED. - MySQL InnoDB:
REPEATABLE_READ.
بهازای هر متد با @Transactional(isolation = Isolation.REPEATABLE_READ) بازنویسی کن. این مقدار به سطح ایزولهسازیِ اتصال JDBC برای آن تراکنش نگاشت میشود.
پ۲۱. قفلگذاری خوشبینانه (optimistic) در مقابل بدبینانه (pessimistic) در JPA — کدام کِی؟
خوشبینانه مثل این است که سند را باز میکنی، ویرایش میکنی، و هنگام ذخیره سیستم میگوید «کسی قبل از تو عوضش کرده، دوباره تلاش کن». هیچ قفلی نگرفتهای، فقط موقع ذخیره چک میشود. بدبینانه مثل این است که همان اول سند را قفل میکنی تا کسی دیگر حتی بازش نکند.
خوشبینانه (ستون @Version): بدون قفلِ دیتابیس؛ هنگام update، Hibernate یک WHERE version = ? اضافه میکند و اگر ردیف زیر دستت تغییر کرده باشد OptimisticLockException پرتاب میکند. بهترین برای بارِ کمرقابت و پرخوانش.
بدبینانه (LockModeType.PESSIMISTIC_WRITE → SELECT ... FOR UPDATE): یک قفلِ ردیفِ واقعی میگیرد و دیگران را مسدود میکند. برای بخشهای بحرانیِ کوتاه و داغ (مثل کمکردنِ موجودی انبار) که retry تلفکننده است.
خلاصه: خوشبینانه بهتر مقیاس میگیرد؛ بدبینانه از طوفانِ retry جلوگیری میکند.
پ۲۲. [سخت] باگ را پیدا کن: این یک شمارنده را زیر بار افزایش میدهد.
@Transactional
public void addPoints(Long userId, int pts) {
User u = userRepo.findById(userId).orElseThrow();
u.setPoints(u.getPoints() + pts); // dirty checking باعث flush شدن UPDATE میشود
}
زیر READ_COMMITTED با فراخوانندههای همزمان، این یک بهروزرسانیِ گمشده است: دو تراکنش همان points را میخوانند، هر دو مقدار اضافه میکنند، و UPDATE دوم اولی را بازنویسی میکند. الگوی خواندن-تغییر-نوشتن (read-modify-write) اتمیک نیست، پس یک افزایش کاملاً ناپدید میشود.
سه راهحل:
- (الف) افزودن
@Versionبرای قفل خوشبینانه + retry. - (ب)
PESSIMISTIC_WRITEروی find (یعنیSELECT ... FOR UPDATE). - (ج) بهترین راه: محاسبه را به داخل SQL هل بده تا در خودِ دیتابیس اتمیک شود:
UPDATE users SET points = points + :pts WHERE id = :id
اینجا دیگر خواندن و نوشتنِ جدا نداری؛ دیتابیس افزایش را اتمیک انجام میدهد و هیچ بهروزرسانیای گم نمیشود.
پ۲۳. چرا equals/hashCode روی موجودیتها نباید سادهلوحانه از id تولیدشده استفاده کند؟
تصور کن یک جعبه را در انبار (HashSet) بر اساس شمارهاش قفسهبندی میکنی، ولی شمارهٔ جعبه بعد از گذاشتنش عوض میشود. حالا دیگر هرگز پیدایش نمیکنی؛ در قفسهٔ اشتباه دنبالش میگردی. یک موجودیتِ transient دارای id == null است و پس از persist یک id میگیرد — دقیقاً همان تعویضِ برچسب وسط راه.
اگر hashCode به id وابسته باشد، hashِ شیء در میانهٔ چرخهٔ عمرش تغییر میکند و در HashSet گم میشود. راههای درست:
- از یک کلید تجاری/طبیعی (business/natural key) استفاده کن اگر وجود دارد.
- یا یک
UUIDکه در زمان ساخت اختصاص مییابد. - یا
hashCodeرا ثابت برگردان وequalsفقط وقتی هر دو non-null هستند id را مقایسه کند.
هرگز روی موجودیتها به @Data لومبوک تکیه نکن — equals/hashCode مبتنی بر id و انجمن تولید میکند که lazy load را تحریک میکند و مجموعهها را میشکند.
پ۲۴. spring.jpa.hibernate.ddl-auto چه میکند و چه چیزی برای prod امن است؟
این تولید schema را کنترل میکند و پنج مقدار دارد: none، validate، update، create، create-drop.
updateهرگز ستونها را با امنیت drop/alter نمیکند و میتواند بیصدا از schema واقعی واگرا شود.create-dropهنگام خاموششدن، داده را پاک میکند. در production ازvalidate(یاnone) استفاده کن و schema را با مهاجرتهای Flyway/Liquibase مدیریت کن. Auto-DDL فقط راحتیِ زمان توسعه است.
پ۲۵. [سخت] چرا @GeneratedValue(strategy = IDENTITY) برای درجهای دستهای بد است؟
«IDENTITY به auto-increment دیتابیس تکیه میکند، پس Hibernate باید INSERT را فوراً اجرا کند تا id تولیدشده را بداند. چون id تا بعد از درج معلوم نیست، Hibernate نمیتواند چند درج را در یک batch جمع کند — JDBC batching برای آن موجودیت غیرفعال میشود.»
راهحل: SEQUENCE (Postgres/Oracle) با یک بهینهسازِ pooled (allocationSize) که به Hibernate اجازه میدهد idها را پیشتخصیص کند و INSERTها را دستهای بزند. روی MySQL اغلب به IDENTITY گیر افتادهای؛ برای درج دستهایِ سنگین، TABLE یا کلیدِ تولیدشده در برنامه (مثل UUID) را در نظر بگیر.
بخش ۳ — SQL، ایندکسگذاری و سطوح ایزولهسازی
پ۲۶. چهار سطح ایزولهسازی SQL را با ناهنجاریهایی که جلوگیری میکنند توضیح بده.
هرچه سطح بالاتر برود، ناهنجاریهای بیشتری حذف میشوند، ولی همزمانی و سرعت هم کمتر میشود. هر سطح را با «کدام ناهنجاری را میبندد» بهخاطر بسپار، نه با اسمش.
| سطح | خواندن کثیف | خواندن غیرتکرارپذیر | فانتوم |
|---|---|---|---|
| READ UNCOMMITTED | ✗ مجاز | ✗ | ✗ |
| READ COMMITTED | ✓ جلوگیری | ✗ | ✗ |
| REPEATABLE READ | ✓ | ✓ جلوگیری | ✗ (spec مجاز میداند) |
| SERIALIZABLE | ✓ | ✓ | ✓ جلوگیری |
سه ناهنجاری:
- خواندن کثیف (dirty read): خواندنِ دادهٔ commitنشدهٔ تراکنش دیگر.
- خواندن غیرتکرارپذیر (non-repeatable read): خواندنِ دوبارهٔ یک ردیف مقدار متفاوتی میدهد.
- فانتوم (phantom): اجرای دوبارهٔ یک کوئریِ بازهای، ردیفهای جدید برمیگرداند.
REPEATABLE_READ در InnoDB با next-key lock بیشترِ فانتومها را هم مسدود میکند، و REPEATABLE_READ در Postgres (که مبتنی بر snapshot است) نیز فانتوم را مسدود میکند. یعنی حداقلِ spec یک چیز است و رفتار واقعیِ موتور معمولاً سختگیرانهتر.
پ۲۷. [سخت] ناهنجاری write-skew چیست و کدام سطح آن را متوقف میکند؟
قانون: «همیشه باید حداقل یک پزشک آنکال باشد.» دو پزشک همزمان چک میکنند و میبینند «آن یکی هست، پس من میتوانم بروم». هر دو ردیفِ خودشان را عوض میکنند و آنکال را ترک میکنند. جدا جدا هر تصمیم درست بود، ولی با هم قانون شکست.
در write-skew، دو تراکنش هرکدام یک مجموعهٔ همپوشان را میخوانند، یک محدودیت را بررسی میکنند، سپس هرکدام یک ردیفِ متفاوت را بر اساس آن خوانش بهروز میکنند. جداگانه معتبر، ولی با هم ناوردا (invariant) را نقض میکنند.
ایزولهسازیِ snapshot (یعنی REPEATABLE_READ در Postgres) از write-skew جلوگیری نمیکند، چون هر تراکنش ردیفِ متفاوتی را لمس میکند و تداخلی دیده نمیشود. فقط SERIALIZABLE جلویش را میگیرد — Postgres از SSI (Serializable Snapshot Isolation) استفاده میکند و یک تراکنش را با خطای serialization لغو میکند که باید retryاش کنی.
پ۲۸. MVCC در Postgres در سطح بالا چطور کار میکند؟
Postgres هرگز روی سند قدیمی خط نمیزند؛ بهجایش یک نسخهٔ جدید میسازد و روی نسخهٔ قبلی مهرِ «منسوخ» میزند. هرکس بسته به زمانِ ورودش، نسخهٔ متناسبِ خودش را میبیند. به همین خاطر خواننده و نویسنده سرِ یک سند دعوا نمیکنند.
هر نسخهٔ ردیف (tuple) دو برچسب دارد: xmin (تراکنشِ سازنده) و xmax (تراکنشِ حذف/بهروزکننده). یک تراکنش یک tuple را میبیند اگر xminاش commit شده و در snapshotش قابلمشاهده باشد و xmaxاش نباشد. UPDATE یک tuple جدید میسازد و قدیمی را مرده علامت میزند — یعنی درجا بهروزرسانی نمیکند. نتیجه: خوانندهها هرگز نویسندهها را مسدود نمیکنند و برعکس.
tupleهای مرده بهعنوان bloat (باد کردنِ جدول) انباشته میشوند و با VACUUM بازیابی میشوند. autovacuum علاوه بر پاکسازی، آمارِ planner را هم تازه میکند — همان آماری که در پرسش بعد سرنوشتِ ایندکس تو را تعیین میکند.
پ۲۹. ایندکس B-tree: کِی planner کوئریِ ایندکس تو را نادیده میگیرد؟
ایندکس مثل فهرستِ الفباییِ پشتِ کتاب است. اگر دنبالِ کلمهای بگردی که در نیمی از صفحهها آمده، برگزدن با فهرست کندتر از خواندنِ کلِ کتاب است. planner دقیقاً همین حساب را میکند.
planner ایندکس را نادیده میگیرد وقتی تخمین میزند پویش ترتیبی (sequential scan) ارزانتر است، مثلاً:
- predicate بخش بزرگی از جدول را منطبق میکند (گزینشپذیریِ پایین / low selectivity).
- آمار کهنه است.
- ستون در یک تابع پیچیده شده:
WHERE lower(email)=...— به یک ایندکس تابعی نیاز دارد. - یک cast نوعِ ضمنی وجود دارد.
- یک
LIKE '%x'با wildcardِ پیشرو داری.
همچنین یک ایندکس ترکیبی (a,b) نمیتواند بهطور کارآمد کوئریای را که فقط روی b فیلتر میکند برآورده کند — که ما را میبرد سراغ قانونِ بعدی.
پ۳۰. [سخت] قانون پیشوند چپترین (leftmost-prefix) و ایندکس پوششی (covering) را توضیح بده.
یک B-tree ترکیبی روی (a, b, c) مثل دفترچهتلفنی است که اول بر اساس نامخانوادگی، بعد نام، بعد نامپدر مرتب شده. میتوانی سریع بگردی روی a، یا a+b، یا a+b+c (و بازه روی آخرین ستونِ استفادهشده) — اما نه b تنها یا c تنها، چون کلِ ترتیب اول با a بسته شده.
یک ایندکس پوششی (covering index) همهٔ ستونهایی که کوئری میخواند را در خودش دارد (از طریق کلید، یا INCLUDE (...) در Postgres/SQL Server)، پس موتور کوئری را فقط از روی ایندکس برآورده میکند — یک index-only scan بدونِ واکشیِ heap.
ستونهای تساوی (=) را قبل از ستونهای بازه (>، <، BETWEEN) بگذار، و ستونهای با گزینشپذیریِ بالا را اول. این ترتیب تعیین میکند ایندکس چقدر برای کوئریت مفید بیفتد.
پ۳۱. ایندکس clustered در مقابل non-clustered؟
یک ایندکس clustered مثل قفسهای است که خودِ کتابها در آن به ترتیبِ موضوع چیده شدهاند — خودِ داده همان ترتیب را دارد. یک ایندکس non-clustered مثل کارتهای کاتالوگ است: یک ساختارِ جدا که فقط به تو میگوید کتاب کجاست.
- clustered: خودِ جدول است — ردیفها فیزیکی به ترتیبِ کلیدِ ایندکس ذخیره میشوند (کلید اصلیِ InnoDB، clustered index در SQL Server). بهازای هر جدول فقط یکی.
- non-clustered/secondary: ساختاری جداست که برگهایش به ردیف اشاره میکنند. در InnoDB، ایندکسهای ثانویه مقدارِ کلید اصلی را ذخیره میکنند، پس یک lookup ثانویه یک probeِ دوم به clustered index میزند (دو مرحله).
Postgres بهطور پیشفرض هیچ clustered index ندارد — heapش نامرتب است و همهٔ ایندکسها ثانویهاند.
پ۳۲. EXPLAIN ANALYZE چه میگوید و دنبالِ چه بگردم؟
EXPLAIN ANALYZE کوئری را واقعاً اجرا میکند و پلنِ حقیقی را با تعداد ردیف و زمانِ واقعی نشان میدهد. مفیدترین سیگنالِ واحد: شکافِ بزرگ بین ردیفهای تخمینی و واقعی — یعنی آمار بد است و باید ANALYZE بزنی.
دنبالِ اینها بگرد:
- Seq Scan روی جداولِ بزرگ.
- nested-loop join روی ورودیهای بزرگ.
Rows Removed by Filter(یعنی ایندکس گزینشپذیر نیست).- sort/hash که به دیسک سرریز میکند (
external merge).
پ۳۳. چطور یک deadlock را تشخیص داده و رفع میکنی؟
دو ماشین از دو سرِ یک کوچهٔ یکطرفه وارد میشوند؛ هرکدام نیمی جلو رفته و حالا هیچکدام نمیتواند عقب یا جلو برود. دیتابیس این بنبست را میبیند و به یکی میگوید «تو عقب برو» (قربانی را میکشد).
deadlock یک چرخه است: T1 قفلِ A را دارد و B را میخواهد، T2 قفلِ B را دارد و A را میخواهد. دیتابیس چرخه را تشخیص میدهد و یک قربانی را میکشد (deadlock detected). راهحلها:
- قفلها را با ترتیبِ سراسریِ ثابت در تمام مسیرهای کد بگیر.
- تراکنشها را کوتاه نگه دار.
- جایی که امن است، ایزولهسازی را پایین بیاور.
- ردپای قفل را کم کن (ستونهای فیلترشده را ایندکس کن تا ردیف قفل کنی نه بازه).
- کلاینت را وادار به retry تراکنشِ لغوشده با backoff کن.
پ۳۴. [سخت] SELECT COUNT(*) روی جدولِ ۱۰۰ میلیونردیفی Postgres کند است. چرا و چه میکنی؟
«چون MVCC در Postgres باید ردیفها/ایندکس را بازدید کند تا قابلیتِمشاهده را تأیید کند — شمارشِ O(1) وجود ندارد (برخلاف MyISAM که یک شمارندهٔ ذخیرهشده دارد).» بعد گزینههایت را بر اساس نیاز بچین.
گزینهها:
- یک تخمین از
pg_class.reltuples— تقریباً رایگان، اما تقریبی. - یک index-only scan اگر ایندکسِ مناسب باشد و جدول vacuum شده باشد.
- یک جدولِ شمارندهٔ نگهداریشده که با trigger یا برنامه برای شمارشِ دقیقِ داغ بهروز میشود.
- distinctِ تقریبی با
HyperLogLog.
همیشه از خودت بپرس: آیا محصول واقعاً به یک شمارشِ زندهٔ دقیق نیاز دارد؟ اغلب یک تخمین کافی است و کلِ مسئله حل میشود.
بخش ۴ — Redis، Kafka و ClickHouse
پ۳۵. الگوی cache-aside چیست و تلهٔ اصلیاش؟
cache-aside مثل یادداشتی روی یخچال است: اول یادداشت را نگاه میکنی (کش)؛ اگر نبود، در کمد را باز میکنی (دیتابیس) و یادداشت را مینویسی. وقتی چیزی عوض میشود، بهجای اصلاحِ یادداشت، پارهاش میکنی تا دفعهٔ بعد از کمد بخوانی.
جریانِ cache-aside (بارگذاریِ تنبل / lazy loading):
- خواندن: کش را بررسی کن؛ در miss از دیتابیس بخوان و کش را پر کن.
- نوشتن: دیتابیس را بهروز کن و کلیدِ کش را باطل (delete) کن.
تلهها:
- دادهٔ کهنه (stale) با ترتیبِ باطلسازیِ اشتباه.
- رقابتی (race): یک خواننده مقدارِ قدیمی را میخواند و آن را پس از اینکه نویسندهٔ همزمان کش را باطل کرده، دوباره در کش مینویسد — کش را دوباره مسموم میکند.
در باطلسازی، بهجای بهروزکردنِ مقدارِ کش، آن را حذف کن. بهعلاوه TTLِ کوتاه بهعنوان تور ایمنی، و کلیدهای نسخهدار (versioned keys) به تو کمک میکنند از حالتهای رقابتی جانِ سالم بهدر ببری.
پ۳۶. [سخت] cache stampede/penetration/avalanche و دفاعها را توضیح بده.
این سه اسم شبیهاند ولی سه مشکلِ متفاوتاند. در مصاحبه اگر تفاوتشان را دقیق بگویی، امتیاز بزرگی میگیری.
- Stampede (dog-piling): یک کلیدِ داغ منقضی میشود و هزاران درخواستِ همزمان به دیتابیس میزنند تا بازسازیاش کنند. دفاع: یک mutex/قفل تا فقط یک درخواست بازسازی کند و بقیه صبر کنند یا مقدارِ کهنه سرو کنند؛ یا بازمحاسبهٔ زودهنگامِ احتمالی.
- Penetration: کوئری برای کلیدهایی که اصلاً وجود ندارند کش را دور میزند و مستقیم به دیتابیس فشار میآورد. دفاع: نتیجهٔ منفی را کش کن (با TTLِ کوتاه)، یا با یک Bloom filter جلویش را بگیر.
- Avalanche: کلیدهای زیادی در یک لحظه منقضی میشوند. دفاع: به TTLها jitter (پراکندگیِ تصادفی) اضافه کن تا انقضاها پخش شوند.
پ۳۷. آیا Redis تکرشتهای است و چرا اشکالی ندارد؟
Redis مثل فروشگاهی با یک صندوقدار است — ولی صندوقداری آنقدر سریع که هیچکس معطل نمیماند. چون فقط یک صندوق است، هر مشتری کامل پردازش میشود قبل از بعدی؛ به همین خاطر هیچ دو تراکنشی با هم قاطی نمیشوند و اصلاً به قفل نیاز نیست.
اجرای دستور تکرشتهای است (یک event loop) که هر دستور را اتمیک و بدون سربارِ قفل میکند — به همین دلیل INCR، SETNX و MULTI/EXEC امناند. سریع است چون در حافظه است و عملیاتِ O(1)/O(log n) غالباند؛ گلوگاه شبکه/حافظه است، نه CPU.
Redis 6+ ورودی/خروجیِ چندرشتهای (تجزیه/پاسخ) اضافه کرد، ولی اجرای خودِ دستور همچنان سریالی است. پس یک دستورِ کند مثل KEYS * یا یک SORT بزرگ، همهچیز را برای همه مسدود میکند. در production از KEYS استفاده نکن؛ SCAN را جایگزینش کن.
پ۳۸. چطور یک قفل توزیعشدهٔ درست در Redis پیاده میکنی؟
گرفتنِ قفل باید اتمیک باشد:
SET key token NX PX 30000
NX: فقط اگر کلید وجود نداشت ست کن (اتمیک).token: یک مقدارِ یکتا برای هر گیرنده.PX 30000: یک TTL (۳۰ ثانیه) تا اگر گیرنده crash کرد، قفل خودکار آزاد شود.
آزادکردن را با یک اسکریپت Lua انجام بده که قبل از DEL بررسی میکند token با مالِ خودت یکی است.
اگر بدونِ چک token فقط DEL بزنی، ممکن است قفلِ کسِ دیگری را حذف کنی: فرض کن TTLِ تو منقضی شد، نفرِ بعدی قفل را گرفت، و حالا تو DEL میزنی و قفلِ او را باز میکنی. اسکریپتِ Lua این چک-و-حذف را اتمیک میکند.
این رویکردِ تکنمونهای زیرِ failover حالتهای مرزی دارد؛ Redlock چند master را میپوشاند ولی بحثبرانگیز است. برای درستیِ قوی، یک ذخیرهٔ اجماع (ZooKeeper/etcd) یا یک fencing token که خودِ منبع بررسیاش میکند ترجیح بده.
پ۳۹. [سخت] Kafka چطور ترتیب را تضمین میکند و کجا میشکند؟
این جملهٔ کلیدی است. Kafka ترتیبِ سراسری ندارد — فقط داخلِ یک پارتیشن ترتیب تضمین است. پیامهای با کلیدِ یکسان به همان پارتیشن hash میشوند، پس ترتیبِ per-key حفظ میشود.
ترتیب میشکند اگر:
- تعدادِ پارتیشن را تغییر بدهی (کلیدها دوباره remap میشوند).
- از
max.in.flight.requests > 1با retry و بدونِ idempotence استفاده کنی — یک retry میتواند ترتیب را بههم بزند. - مصرفکنندهها در رشتههای موازی پردازش کنند.
producerِ خودتوان را فعال کن: enable.idempotence=true. این in-flight را روی ۵ محدود میکند با حفظِ ترتیب، و بر اساسِ دامنهٔ ترتیبات (مثلاً userId) کلید بزن.
پ۴۰. معناشناسیِ تحویلِ Kafka را توضیح بده: حداکثر-یکبار / حداقل-یکبار / دقیقاً-یکبار.
اگر رسید را قبل از باز کردنِ بسته امضا کنی و بسته گم شود، ادعایی نداری (حداکثر-یکبار). اگر بعد از باز کردن امضا کنی ولی وسط کار قطع شود، ممکن است بسته دوباره برایت بیاید (حداقل-یکبار). امضای اتمیکِ «باز کردم و ثبت شد» همان دقیقاً-یکبار است.
- حداکثر-یکبار (at-most-once): offset را پیش از پردازش commit کن — بدون تکرار، ولی با crash داده گم میشود.
- حداقل-یکبار (at-least-once) (پیشفرض): پردازش، سپس commit — بدون گمشدن، ولی بازپردازش/تکرار در شکست. پس مصرفکنندهها باید خودتوان (idempotent) باشند.
- دقیقاً-یکبار (EOS): producerِ خودتوان + تراکنشها (
transactional.id) عملیاتِ produce + commit-offset را در یک read-process-write اتمیک میکنند؛ وisolation.level=read_committedروی مصرفکننده تراکنشهای لغوشده را پنهان میکند.
اگر پردازشت یک اثرِ جانبیِ خارجی دارد (مثلاً نوشتن در یک دیتابیسِ دیگر یا زدنِ ایمیل)، EOS آن را پوشش نمیدهد — هنوز به idempotency یا الگوی transactional outbox نیاز داری.
پ۴۱. rebalancing گروهِ مصرفکننده — چه چیزی تحریکش میکند و چرا مهم است؟
یک rebalance پارتیشنها را بازتخصیص میکند وقتی:
- یک مصرفکننده میپیوندد یا میرود،
- هماهنگکنندهٔ گروه heartbeat را از دست میدهد،
- یا
max.poll.interval.msگذشته شود (یعنی پردازشِ کند بین دو poll).
طیِ یک rebalanceِ کلاسیک، همهٔ مصرفکنندهها مکث میکنند (stop-the-world) — که به تأخیر ضربه میزند. علتِ کلاسیک هم پردازشِ طولانی بین pollهاست.
کاهشدهندهها: rebalancingِ تعاونی/تدریجی با CooperativeStickyAssignor، مقدارِ max.poll.records کوچکتر، و انتقالِ کارِ سنگین از رشتهٔ poll به رشتهای دیگر.
پ۴۲. [سخت] چرا ClickHouse برای تحلیل سریع است ولی برای OLTP غلط است؟
ClickHouse مثل یک انبارِ عظیم است که برای شمردن و تحلیلِ انبوه بهینه شده — «چند تا از این محصول در کل سال فروختیم؟» عالی جواب میدهد. ولی همان انبار برای «همین الان یک قلم را بردار و پولش را بگیر» (کارِ صندوقِ فروشگاه = OLTP) کُند و ناجور است.
ClickHouse یک ذخیرهٔ ستونی (columnar) و MPP است: فقط ستونهایی که کوئری لمس میکند را میخواند، هر ستون را سنگین فشرده میکند (مقادیرِ مشابهِ مجاور)، و اجرا را برداری (vectorize) میکند — ایدهآل برای پویش/تجمیع روی میلیاردها ردیف.
اما برای append + انبوه ساخته شده، پس برای OLTP فاجعه است:
- درجهای تکردیفی بیمارگونهاند (هرکدام یک part میشود؛ باید دستهای درج کنی).
- تراکنشِ واقعی یا اعمالِ کلیدِ یکتا وجود ندارد.
- UPDATE/DELETE بازنویسیهای ناهمزماناند (mutationهای
ALTER ... UPDATE). - lookupِ نقطهای با کلید اصلی مثلِ B-tree نیست.
خلاصه: برای تحلیل/تجمیع، نه بهعنوانِ سیستمِ مرجعِ تراکنشیات.
پ۴۳. کلیدِ اصلیِ موتور MergeTree چه تفاوتی با PK در Postgres دارد؟
PK در Postgres مثل شمارهٔ دقیقِ هر خانه است — یکتا و برای هر ردیف. کلید MergeTree مثل تابلوهای هر چند کیلومتر یکبار در بزرگراه است: به تو میگوید تقریباً کجایی تا بتوانی بخشهای نامربوط را رد کنی، ولی هر خانه را جدا علامت نمیزند.
ORDER BY در MergeTree (که همان «کلیدِ اصلی» نامیده میشود) یک ایندکسِ پراکنده (sparse) است:
- یکتایی را اعمال نمیکند و هر ردیف را ایندکس نمیکند.
- یک mark بهازای هر granule (پیشفرض ۸۱۹۲ ردیف) ذخیره میکند.
- کوئریها با آن granuleهای نامربوط را رد میکنند و بعد داخلشان پویش میکنند.
- ترتیبِ مرتبسازیِ روی دیسک را تعریف میکند که پویشِ بازهای و فشردهسازی را کارآمد میکند.
تکرار مجاز است؛ برای dedupِ نهایی از ReplacingMergeTree استفاده کن (ناهمزمان merge میشود — برای خواندنِ dedupشده باید FINAL بزنی یا تجمیع کنی). کاملاً متفاوت از یک کلیدِ اصلیِ OLTP که یکتا و متراکم است.
پ۴۴. [سخت] در Spring، آیا @Cacheable و @Transactional روی یک متد آنطور که انتظار میرود رفتار میکنند؟
هر دو advice مبتنی بر پروکسیاند، پس ترتیبشان مهم است. بهطور پیشفرض advisorِ @Transactional و advisorِ کش تقدمِ تعریفشده دارند، ولی تلهٔ واقعی باز هم self-invocation است — یک فراخوانیِ داخلی هیچکدام را نمیگیرد.
@Cacheable مقدارِ بازگشتی را کش میکند. اگر متدی یک موجودیتِ lazy برگرداند، ممکن است یک پروکسی کش شود که در cache hitِ بعدی — وقتی contextِ اصلی رفته — LazyInitializationException پرتاب کند. راهحل: DTO کش کن، نه موجودیتِ managed.
یک نکتهٔ ظریفِ دیگر: یک خواندنِ کششده داخلِ یک تراکنش، تغییراتِ commitنشدهٔ زودترِ همان تراکنش را منعکس نمیکند — کش از دیدِ دیتابیس بیخبر است.
پ۴۵. [سخت] این چه چاپ میکند؟
@Service
class OrderService {
@Autowired OrderService self;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { /* یک ردیف مینویسد، سپس پرتاب میکند */ throw new RuntimeException(); }
public void outer() {
try { self.inner(); } catch (Exception e) { System.out.println("caught"); }
System.out.println("done");
}
}
«چاپ میکند: caught سپس done.» بعد دلیلش را بگو: چون self.inner() از پروکسی عبور میکند، REQUIRES_NEW یک تراکنشِ مستقل شروع میکند که با استثنا roll back میشود — نوشتنِ ردیفش دور ریخته میشود، ولی تراکنشِ خودِ outer (اگر باشد) دستنخورده میماند.
نکتهٔ کلیدی: اگر outer مستقیماً this.inner() را صدا میزد، هیچ تراکنشِ جدیدی شروع نمیشد و معناشناسیِ roll back کاملاً فرق میکرد. تزریقِ بهخود (@Autowired OrderService self) کلِ نکته است: مرزِ پروکسی را بازمیگرداند و دوباره وصلت میکند به پ۹ — همان نخِ مشترکی که در نقشهٔ راه گفتم.
فهرست سریعِ بهترینروشها
اینها را مثل یک چکلیستِ پیشازمصاحبه مرور کن:
- تزریق سازنده، فیلدهای
final، بدونِ تزریقِ فیلد. - همهٔ انجمنهای JPA را
LAZYکن؛ بهازای هر کوئری صریح واکشی کن؛ OSIV را غیرفعال کن. - شمارندهها/تجمیعها را به SQL هل بده (
SET x = x + 1) تا از بهروزرسانیِ گمشده جلوگیری شود؛ جایی که خواندن-تغییر-نوشتن اجتنابناپذیر است@Versionبگذار. ddl-auto=validate+ Flyway/Liquibase در prod.- برای
WHERE/ORDER BYکوئری ایندکس بزن؛ باEXPLAIN ANALYZEتأیید کن؛ مراقبِ تخمیندرمقابلواقعی باش. - پیشفرضِ ایزولهسازیِ واقعیِ دیتابیسات را بدان و فقط برای ناورداهای مستعدِ write-skew به SERIALIZABLE برس.
- کش: delete هنگام نوشتن، jitter در TTL، کشِ منفی، بازسازیِ single-flight؛ DTO کش کن نه موجودیت.
- Kafka: برای ترتیب کلید بزن، مصرفکنندههای خودتوان، پردازش را از رشتهٔ poll دور کن.
- ClickHouse فقط برای تحلیل — درجِ دستهای، بدونِ انتظارِ OLTP.
اگر بخواهی کلِ این فصل را در چند خط بفشاری:
- در Spring، تقریباً همهٔ جادو روی مرزِ پروکسی مینشیند.
@Transactional،@Cacheable،@Async— همه فقط وقتی کار میکنند که تماس از بیرون و از پروکسی رد شود.this.method()= تله. - در JPA، persistence context یک واحدِ کار است که با dirty checking خودکار UPDATE میزند؛ N+1 و LazyInitializationException و lost update سه تلهٔ همیشگیاند که با «صریح واکشی کن» و «محاسبه را به SQL بسپار» رام میشوند.
- در دیتابیس، همهچیز به همزمانی برمیگردد: سطوحِ ایزوله ناهنجاریها را یکییکی میبندند (تا SERIALIZABLE که write-skew را هم میگیرد)، MVCC خواننده و نویسنده را جدا میکند، و ایندکس فقط وقتی گزینشپذیر و در ترتیبِ درست باشد کمک میکند.
- در Redis/Kafka/ClickHouse، سه شعار را نگه دار: کش را حذف کن نه بهروز، در Kafka برای ترتیب کلید بزن و مصرفکننده را خودتوان کن، و ClickHouse را فقط برای تحلیل بهکار ببر. دو نخِ مشترک را بهخاطر بسپار: مرزِ پروکسی و همزمانی. هر تلهای که در این فصل دیدی، یکی از این دو بود.
This chapter is a firing range — but this time you're not just pulling the trigger and moving on; you're going to understand why every shot lands. Each question is short; each answer is what a strong senior would actually say in the room: with the reasoning, not just the keyword. Questions escalate within each section. [HARD] marks the gotchas that separate mid from senior — the moments where the interviewer smiles and waits for you to slip.
My goal is that by the end, you can answer any of these 45 questions not from memory but from understanding.
Four stops, each a layer of a Java backend stack:
- Spring Core — dependency injection (DI), bean scopes, proxy-based AOP. Here you learn why "self-invocation" is evil.
- JPA / Hibernate / Transactions — entity lifecycle, dirty checking, N+1, locking, and when
@Transactionalsilently betrays you. - SQL, Indexing & Isolation — isolation levels, MVCC, B-tree indexes, and reading an EXPLAIN.
- Redis, Kafka, ClickHouse — caching, message ordering, and why a columnar DB is a disaster for OLTP. One thread stitches it all together: the proxy boundary in Spring, and concurrency in the database. Understand those two deeply and half the traps reveal themselves.
Part 0 — Words you must know first
Let me seat three words with analogies so I never drop them cold later.
Picture a professional kitchen. You (your code) order a dish, but you don't grow the tomato or sharpen the knife yourself. A container — the kitchen — hands you ready ingredients and tools. Every ready-made object the kitchen manages is a bean. You just say "I need an OrderRepository" and the container hands you one. That's the whole idea of Spring.
- Proxy: a "stand-in" that sits in front of the real object and does work before/after each call (like a secretary who logs the call before connecting you to the manager). This is the key to understanding AOP,
@Transactional, and@Cacheable. - Atomic: an operation that either completes fully or not at all — never half-done, never observed mid-way. Like shifting a gear: it engages fully or it doesn't.
- Idempotent: an operation that, repeated many times, yields the same result as running it once. Pressing "floor 3" in an elevator is idempotent; press it a hundred times, you still go to floor 3. This concept is the heart of Kafka.
Section 1 — Spring Core, DI & AOP
Q1. What problem does Dependency Injection actually solve?
Imagine every employee had to buy, climb up, and replace their own burnt-out bulb. Chaos! In a real company, you just call and facilities brings the right bulb. You no longer know the brand or when it was bought — you just want light. That's inverting the responsibility of supply.
DI inverts control of object construction. Instead of a class new-ing its collaborators (hardwiring concrete types and lifecycles), a container supplies them. The payoff isn't "less code" — it's three things:
- Testability: inject a mock/fake instead of the real dependency.
- Swappable implementations: behind an interface, impl A today, B tomorrow, without touching the consumer.
- Centralized lifecycle/scope management.
DI is one form of Inversion of Control (IoC); the ApplicationContext is the IoC container — the "facilities" of the restaurant/company.
Q2. Constructor vs field vs setter injection — which and why?
If you take only one rule from this chapter, take this one.
Constructor injection is the default because it:
- Makes dependencies explicit and mandatory (look at the constructor = full list of needs).
- Enables
finalfields → immutability and thread-safety. - Fails fast: if a bean is missing, it breaks at construction, not later and more mysteriously.
- Lets you build the class in a unit test without Spring at all: just
new MyService(mockRepo).
Field injection (@Autowired on a private field) hides dependencies, needs reflection to test, and lets circular dependencies slip through. Setter injection is only for genuinely optional dependencies.
Since Spring 4.3, if a class has a single constructor you no longer need @Autowired on it — Spring figures it out.
Q3. What is the default bean scope, and is it thread-safe?
Being a singleton does not mean being thread-safe. Many people conflate these two.
The default scope is singleton — meaning one instance per container (not per JVM; two containers = two instances). Spring does not make it thread-safe; the instance is shared across all threads, so you must keep it stateless or guard mutable state yourself with a lock.
Other scopes:
prototype: a new instance per lookup — and note: Spring does not manage its full lifecycle (no destroy callback).- Web scopes:
request,session,application,websocket.
Q4. [HARD] You inject a prototype bean into a singleton. How many prototype instances exist?
"Exactly one." Then give the reason: the prototype is resolved once, at the time the singleton is created, and that single reference is reused forever. Injection happens at construction time, not per method call. The interviewer wants to see you grasp that "injection is a one-time event, not an always-on factory."
To get a fresh prototype each time, you must defer resolution. Three ways:
- Inject
ObjectProvider<T>(orProvider<T>) and call.getObject()when needed. - Use
@Lookupmethod injection. - Inject
ApplicationContextand callgetBean(...).
All three move the "which instance" decision from construction time to use time.
Q5. How does Spring resolve two beans of the same type?
If you have two beans of the same type and don't say which, Spring gets confused and throws NoUniqueBeanDefinitionException. Ways to disambiguate:
@Primaryon one → "the default winner".@Qualifier("name")at the injection point → explicit pick.- Matching the injection-point variable name to a bean name.
If instead of one bean you inject a List<Handler>, Spring pours all matching beans into it. This is great for the "multiple strategies" pattern: define each Handler separately and grab them all at once.
Q6. How does Spring break a circular dependency, and when can't it?
Two workers A and B: A says "I won't start until you finish," and B says the same. If both wait for the other at the moment of birth, neither is ever born. But if each can be born "half-built" and completed later, the knot unties.
For field/setter injection of singletons, Spring uses a three-level cache that exposes an early, half-initialized reference, so the A↔B cycle resolves.
For constructor injection it cannot — neither can be fully built first, since the constructor needs the other — and you get BeanCurrentlyInCreationException.
Fixes: refactor, break the cycle with @Lazy on one dependency, or use ObjectProvider. But remember: a cycle is usually a design smell, not something to cleverly dodge.
Q7. What is a BeanPostProcessor vs BeanFactoryPostProcessor?
BeanFactoryPostProcessor works on the blueprints: after bean definitions are loaded but before any bean is built, it can mutate the plan (e.g. PropertySourcesPlaceholderConfigurer resolving ${...} with real values). BeanPostProcessor works on the building itself: around each bean's instance during initialization.
BeanPostProcessor has two hooks: postProcessBeforeInitialization and postProcessAfterInitialization. This is where Spring's magic happens: building AOP proxies, and wiring @Autowired/@PostConstruct. Two real examples: AutowiredAnnotationBeanPostProcessor and CommonAnnotationBeanPostProcessor.
Q8. How does Spring AOP actually work under the hood?
This is the key sentence. Spring does not rewrite your class's bytecode (that's AspectJ's job). It puts a stand-in in front of the bean instead.
At startup, a BeanPostProcessor wraps advised beans (those that need extra behavior) in a proxy:
- If the bean implements an interface → a JDK dynamic proxy.
- Otherwise → a CGLIB subclass.
Calls travel: proxy → advice chain → target. The key consequence, which flows straight into the next question: advice only fires on calls that pass through the proxy — that is, external calls into the bean.
Q9. [HARD] Why does calling this.someTransactionalMethod() skip the transaction?
If only one trap in this whole chapter is going to catch you in an interview, it's this one. Self-invocation fails silently and infamously — no error, no log, just a transaction that never started.
Because this is the raw target object, not the proxy. All AOP advice — including @Transactional, @Cacheable, and @Async — lives on the proxy. When you call this.method() inside the same class, the call never passes through the proxy, so no transaction, cache, or async boundary is created.
Fixes:
- Split the method into another bean so the call really comes from outside.
- Inject a self-reference:
@Lazy MyService self, thenself.method(). - Obtain
AopContext.currentProxy()(needsexposeProxy=true). - Switch to AspectJ (load-time/compile-time weaving), which instruments the class itself and no longer depends on the proxy boundary.
Q10. Order of @PostConstruct, InitializingBean, and @Bean(initMethod)?
The exact order: @PostConstruct first, then InitializingBean.afterPropertiesSet(), then the custom init-method. For portability, prefer @PostConstruct (or constructor logic) since it doesn't tie you to Spring.
In Java 11+, the @PostConstruct/@PreDestroy annotations moved out of the JDK itself (part of removing the Java EE modules). That's why Spring Boot pulls in the jakarta.annotation-api library as a dependency so these still work.
Section 2 — Spring Data, JPA, Hibernate & Transactions
Q11. Difference between JPA, Hibernate, and Spring Data JPA?
JPA is like the electrical-outlet standard: a specification that says what the shape and voltage must be (annotations + the EntityManager API). Hibernate is like a brand that makes outlets: the most common implementation that makes the standard real (the provider). Spring Data JPA is like an assistant electrician: a layer on top that generates repository implementations from interface method names and wires the EntityManager/transactions for you.
You can drop down to Hibernate-specific features anytime, but the more you do, the less portable (to another provider) you become.
Q12. Explain the JPA entity lifecycle states.
An entity is like an employee whose relationship with headquarters (the persistence context) has four states. As long as they're "attached," every change is auto-recorded; the moment the relationship is cut, nobody tracks their changes anymore.
- Transient/new — created with
new, no identity, untracked. - Managed/persistent — attached to a persistence context; changes are auto-flushed (dirty checking).
- Detached — was managed, but the context closed; changes are not tracked.
- Removed — marked for deletion; a DELETE fires at flush.
persist, merge, remove, detach, and closing the context move entities between these states.
Q13. What is the persistence context and dirty checking?
The persistence context is a first-level cache and a unit of work bound to the EntityManager (and, with Spring, to the transaction). It tracks managed entities by identity.
On flush, Hibernate compares each managed entity to a snapshot taken at load time and issues UPDATEs for changed fields itself. So for an already-managed entity, you never need to call save(); just mutating its value is enough. This "compare to snapshot and auto-emit UPDATE" is called dirty checking.
Q14. save() vs saveAndFlush() vs persist() vs merge()?
Spring Data's save() is smart: it calls persist() for a new entity and merge() for one with a set id (i.e. detached).
persist: makes a new entity managed and returnsvoid.merge: copies a detached entity's state onto a managed copy and returns that copy.saveAndFlush: forces an immediate flush to the DB rather than deferring to transaction commit.
merge does not manage your argument — the argument stays detached and a new copy is returned. If you keep working on the original argument after merge (instead of the return value), your changes are lost. Always use the value merge returns.
Q15. [HARD] What is the N+1 select problem and how do you kill it?
Say you fetch a list of 100 customers (1 query), then call each one separately to ask their address (100 queries). You could have asked everyone on that first call. Those 101 round trips instead of 1 or 2 — that's the N+1 plague.
With FetchType.LAZY associations, loading N parents and then touching each parent's association fires 1 query for the parents + N queries for the children = N+1 round trips. Detect it with SQL logging or a tool like datasource-proxy. Ways to kill it:
JOIN FETCHin JPQL, or an@EntityGraphon the repository method.- Batch fetching:
@BatchSize(size=N)orhibernate.default_batch_fetch_size, which turns N queries intoN/sizeIN (...)queries. - A projection/DTO query that selects only what you need.
If you combine JOIN FETCH on a collection with pagination, Hibernate can't paginate in the DB and is forced to load everything and paginate in memory — logging HHH000104. For this case, use @BatchSize or a two-query strategy instead of JOIN FETCH.
Q16. [HARD] Why is FetchType.EAGER on @ManyToOne a trap even though it's the default?
EAGER means every query that loads the entity also loads the association — even when you don't need it, often via extra selects. Worse: it composes badly; several EAGER associations together can produce cartesian joins or query storms, and you can't make it lazy per-query.
Make everything LAZY, then fetch explicitly per use case with JOIN FETCH or an entity graph. Remember the defaults differ: @ManyToOne and @OneToOne default to EAGER (so you must explicitly set fetch = LAZY), while @OneToMany and @ManyToMany default to LAZY.
Q17. What causes LazyInitializationException and what's the right fix?
A lazy association is like "I'll ask later." But if you go to ask after the window (persistence context/transaction) has closed — say, in the view layer — there's nobody behind the counter anymore. That's LazyInitializationException.
The wrong fix is spring.jpa.open-in-view=true (the Open Session In View pattern); it keeps the window open through view rendering, but in exchange holds the DB connection hostage the whole time, eating connection-pool throughput and hiding N+1.
The right fix: fetch what the caller needs inside the transaction (fetch joins, entity graphs, DTO projections) and return a fully-initialized object or DTO. Disable OSIV in production.
Q18. Explain the @Transactional propagation levels you actually use.
- REQUIRED (default): join an existing tx, or start one if none.
- REQUIRES_NEW: suspend the current tx and run in a brand-new independent tx — for audit logs or must-commit-regardless work. Needs a second connection and can deadlock against the suspended tx.
- NESTED: a savepoint inside the current tx — rolls back to the savepoint, not the whole tx (JDBC-only).
- SUPPORTS / NOT_SUPPORTED / MANDATORY / NEVER: for finer control.
REQUIRES_NEW is a completely separate transaction (and connection) that commits independently. NESTED is just a savepoint inside the same transaction; if it rolls back you only go back to that point, not the beginning.
Q19. [HARD] When does @Transactional silently NOT roll back?
The key sentence: "By default Spring rolls back only on unchecked exceptions (RuntimeException/Error) — a checked exception commits the transaction." Then say how you override it: @Transactional(rollbackFor = Exception.class).
Three silent scenarios to know:
- A checked exception is thrown → Spring commits by default, not rolls back.
- You
catchthe exception inside the method → the rollback is swallowed. - After a nested call already marked the tx
rollback-only, you catch the exception → the outer commit throwsUnexpectedRollbackException.
And of course self-invocation (Q9) means no transaction fires at all to roll back.
Q20. What isolation level does @Transactional use, and how do you change it?
The default is Isolation.DEFAULT — meaning Spring defers to the datasource/DB default:
- Postgres/Oracle:
READ_COMMITTED. - MySQL InnoDB:
REPEATABLE_READ.
Override per method with @Transactional(isolation = Isolation.REPEATABLE_READ). This maps to the JDBC connection's isolation level for that transaction.
Q21. Optimistic vs pessimistic locking in JPA — when each?
Optimistic is like opening a doc, editing, and on save the system says "someone changed it before you, try again." You took no lock, it's just checked at save time. Pessimistic is like locking the doc up front so nobody else can even open it.
Optimistic (@Version column): no DB lock; on update Hibernate adds WHERE version = ? and throws OptimisticLockException if the row changed underneath you. Best for low-contention, high-read workloads.
Pessimistic (LockModeType.PESSIMISTIC_WRITE → SELECT ... FOR UPDATE): takes a real row lock, blocking others. Use for short, hot critical sections (e.g. decrementing stock) where retries would be wasteful.
In short: optimistic scales better; pessimistic avoids retry storms.
Q22. [HARD] Find the bug: this increments a counter under load.
@Transactional
public void addPoints(Long userId, int pts) {
User u = userRepo.findById(userId).orElseThrow();
u.setPoints(u.getPoints() + pts); // dirty checking flushes UPDATE
}
Under READ_COMMITTED with concurrent callers, this is a lost update: two transactions read the same points, both add, and the second UPDATE overwrites the first. The read-modify-write pattern isn't atomic, so one increment vanishes entirely.
Three fixes:
- (a) Add
@Versionfor optimistic locking + retry. - (b)
PESSIMISTIC_WRITEon the find (i.e.SELECT ... FOR UPDATE). - (c) Best: push the arithmetic into SQL so it's atomic in the DB itself:
UPDATE users SET points = points + :pts WHERE id = :id
Here there's no separate read and write; the DB does the increment atomically and no update is lost.
Q23. Why should equals/hashCode on entities not use the generated id naively?
Imagine you shelve a box in a warehouse (HashSet) by its number, but the box's number changes after you put it in. Now you can never find it again; you look in the wrong shelf. A transient entity has id == null and gets an id after persist — exactly that mid-way label swap.
If hashCode depends on the id, the object's hash changes mid-lifecycle and it gets lost in a HashSet. The right approaches:
- Use a business/natural key if one exists.
- Or a
UUIDassigned at construction. - Or make
hashCodereturn a constant and haveequalscompare the id only when both are non-null.
Never rely on Lombok's @Data on entities — it generates id-and-association-based equals/hashCode that trigger lazy loads and break sets.
Q24. What does spring.jpa.hibernate.ddl-auto do and what's safe for prod?
It controls schema generation and has five values: none, validate, update, create, create-drop.
updatenever safely drops/alters columns and can silently diverge from the real schema.create-dropwipes data on shutdown. In production usevalidate(ornone) and manage schema with Flyway/Liquibase migrations. Auto-DDL is a dev-only convenience.
Q25. [HARD] Why is @GeneratedValue(strategy = IDENTITY) bad for batch inserts?
"IDENTITY relies on the DB auto-increment, so Hibernate must execute the INSERT immediately to learn the generated id. Since the id isn't known until after the insert, Hibernate cannot batch several inserts together — JDBC batching is disabled for that entity."
Fix: use SEQUENCE (Postgres/Oracle) with a pooled optimizer (allocationSize), which lets Hibernate pre-allocate ids and batch INSERTs. On MySQL you're often stuck with IDENTITY; for heavy batching consider TABLE or app-generated keys (like a UUID).
Section 3 — SQL, Indexing & Isolation Levels
Q26. Explain the four SQL isolation levels by the anomalies they prevent.
The higher the level, the more anomalies are eliminated — but the less concurrency and speed you get. Memorize each level by "which anomaly it blocks," not by its name.
| Level | Dirty read | Non-repeatable read | Phantom |
|---|---|---|---|
| READ UNCOMMITTED | ✗ allowed | ✗ | ✗ |
| READ COMMITTED | ✓ prevented | ✗ | ✗ |
| REPEATABLE READ | ✓ | ✓ prevented | ✗ (spec allows) |
| SERIALIZABLE | ✓ | ✓ | ✓ prevented |
The three anomalies:
- Dirty read: reading another tx's uncommitted data.
- Non-repeatable read: re-reading a row gives a different value.
- Phantom: re-running a range query returns new rows.
InnoDB's REPEATABLE_READ also blocks most phantoms via next-key locks, and Postgres's REPEATABLE_READ (snapshot-based) blocks phantoms too. So the spec minimum is one thing and the engine's real behavior is usually stricter.
Q27. [HARD] What is a write-skew anomaly and which level stops it?
The rule: "there must always be at least one doctor on call." Two doctors check simultaneously and each sees "the other is on, so I can leave." Each updates their own row and goes off-call. Individually each decision was valid, but together the rule broke.
In write-skew, two transactions each read an overlapping set, verify a constraint, then each updates a different row based on that read. Individually valid, together they violate the invariant.
Snapshot isolation (REPEATABLE_READ in Postgres) does not prevent write-skew, because each tx touches a different row and no conflict is seen. Only SERIALIZABLE stops it — Postgres uses SSI (Serializable Snapshot Isolation) and aborts one tx with a serialization failure you must retry.
Q28. How does Postgres MVCC work at a high level?
Postgres never crosses out the old document; instead it makes a new version and stamps the previous one "obsolete." Each reader sees the version appropriate to when they arrived. That's why a reader and writer never fight over a single document.
Every row version (tuple) carries two tags: xmin (the creating tx) and xmax (the deleting/updating tx). A transaction sees a tuple if its xmin is committed and visible in its snapshot and its xmax is not. UPDATE creates a new tuple and marks the old one dead — it never updates in place. Result: readers never block writers and vice versa.
Dead tuples accumulate as bloat and are reclaimed by VACUUM. Beyond cleanup, autovacuum also refreshes the planner's statistics — the same stats that decide your index's fate in the next question.
Q29. B-tree index: when does the query planner ignore your index?
An index is like the alphabetical index at the back of a book. If you're looking for a word that appears on half the pages, flipping via the index is slower than just reading the whole book. The planner does exactly this calculation.
The planner ignores the index when it estimates a sequential scan is cheaper, e.g.:
- The predicate matches a large fraction of the table (low selectivity).
- Stats are stale.
- The column is wrapped in a function:
WHERE lower(email)=...— needs a functional index. - There's an implicit type cast.
- A leading-wildcard
LIKE '%x'.
Also a composite index (a,b) can't efficiently satisfy a query filtering only on b — which leads us to the next rule.
Q30. [HARD] Explain the leftmost-prefix rule and a covering index.
A composite B-tree on (a, b, c) is like a phone book sorted first by last name, then first name, then middle name. You can search fast on a, or a+b, or a+b+c (and a range on the last used column) — but not b alone or c alone, because the whole ordering is anchored by a first.
A covering index includes all columns the query reads (via the key, or INCLUDE (...) in Postgres/SQL Server), so the engine satisfies the query from the index alone — an index-only scan, no heap fetch.
Put equality columns (=) before range columns (>, <, BETWEEN), and high-selectivity columns first. This order determines how useful the index is for your query.
Q31. Clustered vs non-clustered index?
A clustered index is like a shelf where the actual books are arranged by subject — the data itself is in that order. A non-clustered index is like catalog cards: a separate structure that just tells you where the book is.
- Clustered: is the table — rows are physically stored in index-key order (InnoDB primary key, SQL Server clustered index). Only one per table.
- Non-clustered/secondary: a separate structure whose leaves point back to the row. In InnoDB, secondary indexes store the primary key value, so a secondary lookup does a second probe into the clustered index (two steps).
Postgres has no clustered index by default — its heap is unordered and all indexes are secondary.
Q32. What does EXPLAIN ANALYZE tell you, and what to look for?
EXPLAIN ANALYZE actually runs the query and shows the real plan with real row counts and timings. The single most useful signal: a big gap between estimated and actual rows — meaning stats are bad and you should run ANALYZE.
Look for:
- Seq Scan on large tables.
- Nested-loop joins over large inputs.
Rows Removed by Filter(index not selective).- Sort/hash spilling to disk (
external merge).
Q33. How do you diagnose and fix a deadlock?
Two cars enter a one-lane alley from opposite ends; each has driven halfway and now neither can go forward or back. The DB sees this stalemate and tells one "you back up" (kills the victim).
A deadlock is a cycle: T1 holds lock A wants B, T2 holds B wants A. The DB detects the cycle and kills a victim (deadlock detected). Fixes:
- Acquire locks in a consistent global order across all code paths.
- Keep transactions short.
- Lower isolation where safe.
- Reduce the lock footprint (index the filtered columns so you lock rows, not ranges).
- Make the client retry the aborted transaction with backoff.
Q34. [HARD] SELECT COUNT(*) is slow on a 100M-row Postgres table. Why and what do you do?
"Because Postgres MVCC must visit rows/index to confirm visibility — there's no O(1) row count (unlike MyISAM, which keeps a stored counter)." Then lay out your options by need.
Options:
- An estimate from
pg_class.reltuples— near-free, but approximate. - An index-only scan if there's a suitable index and the table is vacuumed.
- A maintained counter table updated by triggers or the app for hot exact counts.
- Approximate distinct with
HyperLogLog.
Always ask yourself: does the product really need an exact live count? Often an estimate is enough and the whole problem dissolves.
Section 4 — Redis, Kafka & ClickHouse
Q35. What is the cache-aside pattern and its main pitfall?
Cache-aside is like a note on the fridge: first you check the note (cache); if it's not there, you open the pantry (DB) and write the note. When something changes, instead of editing the note, you tear it off so next time you read from the pantry.
The cache-aside flow (lazy loading):
- Read: check cache; on miss, load from DB and populate cache.
- Write: update the DB and invalidate (delete) the cache key.
Pitfalls:
- Stale data from a bad invalidation order.
- Race: a reader loads the old value and writes it back to cache after a concurrent writer invalidated it — re-poisoning the cache.
On invalidation, delete the cache value rather than updating it. Plus short TTLs as a safety net, and versioned keys, help you survive races.
Q36. [HARD] Explain cache stampede/penetration/avalanche and defenses.
These three names sound alike but are three different problems. In an interview, precisely distinguishing them earns big points.
- Stampede (dog-piling): a hot key expires and thousands of simultaneous requests hit the DB to rebuild it. Defend with a mutex/lock so only one request rebuilds while others wait or serve stale; or probabilistic early recomputation.
- Penetration: queries for keys that don't exist at all bypass the cache and pound the DB directly. Defend by caching the negative result (short TTL), or front it with a Bloom filter.
- Avalanche: many keys expire at the same instant. Defend by adding jitter (random spread) to TTLs so expiries are staggered.
Q37. Is Redis single-threaded, and why is that fine?
Redis is like a store with one cashier — but a cashier so fast that nobody waits. Because there's only one register, each customer is processed fully before the next; that's why no two transactions get tangled and no locks are needed at all.
Command execution is single-threaded (one event loop), which makes each command atomic with no lock overhead — that's why INCR, SETNX, and MULTI/EXEC are safe. It's fast because it's in-memory and O(1)/O(log n) ops dominate; the bottleneck is network/memory, not CPU.
Redis 6+ added multi-threaded I/O (parsing/replying), but command execution stays serialized. So a single slow command like KEYS * or a big SORT blocks everything for everyone. Never use KEYS in production; use SCAN instead.
Q38. How do you implement a correct distributed lock in Redis?
Acquiring the lock must be atomic:
SET key token NX PX 30000
NX: set only if the key didn't exist (atomic).token: a unique value per acquirer.PX 30000: a TTL (30 s) so if the holder crashes, the lock auto-releases.
Release it with a Lua script that checks the token matches yours before DEL.
If you just DEL without checking the token, you might delete someone else's lock: suppose your TTL expired, the next person acquired the lock, and now you DEL and free theirs. The Lua script makes this check-and-delete atomic.
This single-instance approach has edge cases under failover; Redlock spans multiple masters but is debated. For strong correctness, prefer a consensus store (ZooKeeper/etcd) or a fencing token that the resource itself checks.
Q39. [HARD] How does Kafka guarantee ordering, and where does it break?
This is the key sentence. Kafka has no global order — order is guaranteed only within a partition. Messages with the same key hash to the same partition, so per-key order holds.
Order breaks if:
- You change the partition count (keys remap).
- You use
max.in.flight.requests > 1with retries and no idempotence — a retry can reorder. - Consumers process in parallel threads.
Enable the idempotent producer: enable.idempotence=true. This caps in-flight at 5 while preserving ordering, and key by your ordering domain (e.g. userId).
Q40. Explain Kafka delivery semantics: at-most / at-least / exactly-once.
If you sign the receipt before opening the package and the package is lost, you have no claim (at-most-once). If you sign after opening but get interrupted mid-way, the package might be sent again (at-least-once). An atomic "opened and recorded" signature is exactly-once.
- At-most-once: commit the offset before processing — no dupes, but data is lost on crash.
- At-least-once (default): process, then commit — no loss, but reprocessing/dupes on failure. So consumers must be idempotent.
- Exactly-once (EOS): the idempotent producer + transactions (
transactional.id) make produce + offset-commit atomic across a read-process-write; andisolation.level=read_committedon the consumer hides aborted transactions.
If your processing has an external side effect (writing to another DB, sending an email), EOS doesn't cover it — you still need idempotency or the transactional outbox pattern.
Q41. Consumer group rebalancing — what triggers it and why care?
A rebalance reassigns partitions when:
- A consumer joins or leaves,
- the group coordinator misses heartbeats,
- or
max.poll.interval.msis exceeded (slow processing between polls).
During a classic rebalance, all consumers pause (stop-the-world) — hurting latency. And the classic cause is long processing between polls.
Mitigations: cooperative/incremental rebalancing with CooperativeStickyAssignor, a smaller max.poll.records, and moving heavy work off the poll thread onto another thread.
Q42. [HARD] Why is ClickHouse fast for analytics but wrong for OLTP?
ClickHouse is like a giant warehouse optimized for counting and bulk analysis — "how many of this product did we sell all year?" answers beautifully. But that same warehouse is slow and awkward for "grab one item right now and take payment" (the store-checkout job = OLTP).
ClickHouse is a columnar MPP store: it reads only the columns a query touches, compresses each column heavily (similar values adjacent), and vectorizes execution — ideal for scans/aggregations over billions of rows.
But it's built for append + bulk, so it's a disaster for OLTP:
- Single-row inserts are pathological (each becomes a part; you must batch).
- No real transactions or unique-key enforcement.
- UPDATE/DELETE are asynchronous rewrites (
ALTER ... UPDATEmutations). - Point lookups by primary key aren't like a B-tree.
In short: for analytics/aggregation, not as your transactional system of record.
Q43. How does the MergeTree engine's primary key differ from a Postgres PK?
A Postgres PK is like the exact number of every house — unique and per-row. A MergeTree key is like mile markers every few kilometers on a highway: it tells you roughly where you are so you can skip irrelevant stretches, but it doesn't mark every house individually.
MergeTree's ORDER BY (its "primary key") is a sparse index:
- It does not enforce uniqueness and doesn't index every row.
- It stores one mark per granule (default 8192 rows).
- Queries use it to skip irrelevant granules, then scan within.
- It defines the on-disk sort order that makes range scans and compression efficient.
Duplicates are allowed; for eventual dedup use ReplacingMergeTree (merged asynchronously — to read deduped you FINAL or aggregate). Totally different from a unique, dense OLTP primary key.
Q44. [HARD] In Spring, will @Cacheable and @Transactional on the same method behave as expected?
Both are proxy-based advices, so their order matters. By default the @Transactional advisor and the cache advisor have a defined precedence, but the real trap is again self-invocation — an internal call gets neither.
@Cacheable caches the return value. If a method returns a lazily-initialized entity, you might cache a proxy that throws LazyInitializationException on a later cache hit — when the original context is gone. The fix: cache DTOs, not managed entities.
Another subtlety: a cached read inside a transaction won't reflect uncommitted changes made earlier in the same transaction — the cache is oblivious to the DB's view.
Q45. [HARD] What does this print?
@Service
class OrderService {
@Autowired OrderService self;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() { /* writes row, then throws */ throw new RuntimeException(); }
public void outer() {
try { self.inner(); } catch (Exception e) { System.out.println("caught"); }
System.out.println("done");
}
}
"It prints caught then done." Then give the reason: because self.inner() goes through the proxy, REQUIRES_NEW starts an independent transaction that rolls back on the exception — its row write is discarded, but outer's own transaction (if any) is untouched.
The key point: had outer called this.inner() directly, no new transaction would start and the rollback semantics would differ entirely. The self-injection (@Autowired OrderService self) is the whole point: it restores the proxy boundary and ties us right back to Q9 — the same common thread I promised in the roadmap.
Rapid Best-Practices Checklist
Run through these like a pre-interview checklist:
- Constructor injection,
finalfields, no field injection. - Make all JPA associations
LAZY; fetch explicitly per query; disable OSIV. - Push counters/aggregates into SQL (
SET x = x + 1) to avoid lost updates; add@Versionwhere read-modify-write is unavoidable. ddl-auto=validate+ Flyway/Liquibase in prod.- Index for the query's
WHERE/ORDER BY; verify withEXPLAIN ANALYZE; watch estimate-vs-actual. - Know your DB's actual isolation default and reach for SERIALIZABLE only for write-skew-prone invariants.
- Cache: delete-on-write, TTL jitter, negative caching, single-flight rebuild; cache DTOs not entities.
- Kafka: key for ordering, idempotent consumers, keep processing off the poll thread.
- ClickHouse for analytics only — batch inserts, no OLTP expectations.
If you compress this whole chapter into a few lines:
- In Spring, almost all the magic sits on the proxy boundary.
@Transactional,@Cacheable,@Async— they only work when the call comes from outside and passes through the proxy.this.method()= trap. - In JPA, the persistence context is a unit of work that auto-UPDATEs via dirty checking; N+1, LazyInitializationException, and lost update are the three recurring traps, tamed by "fetch explicitly" and "push arithmetic into SQL."
- In the database, it all comes back to concurrency: isolation levels close anomalies one by one (up to SERIALIZABLE, which also catches write-skew), MVCC separates readers from writers, and an index only helps when it's selective and in the right order.
- In Redis/Kafka/ClickHouse, hold three slogans: delete the cache rather than update it, in Kafka key for ordering and make the consumer idempotent, and use ClickHouse only for analytics. Remember the two common threads: the proxy boundary and concurrency. Every trap in this chapter was one of the two.