Databases & SQL · پایگاه‌داده و SQL سنیورSenior ~59 دقیقه مطالعه~50 min read

SQL Server و T-SQL (به‌همراه مدیریت پایگاه‌داده)SQL Server & T-SQL (with Database Administration)

راهنمای عمیق SQL Server و T-SQL: از تفاوت‌های گویش و تله‌های MERGE و توابع اسکالر تا خواندن execution plan، ایندکس و parameter sniffing، همزمانی و RCSI، و مدیریت واقعی پایگاه‌داده شامل backup/restore و RPO/RTO، پارتیشن، امنیت و HA/DR — با معادل PostgreSQL هر دستور و نکات اتصال از Spring Boot.A senior-level guide to SQL Server and T-SQL: dialect differences and the traps in MERGE and scalar functions, reading execution plans, indexing and parameter sniffing, concurrency and RCSI, and real database administration — backup/restore with RPO/RTO, partitioning, security and HA/DR — with the PostgreSQL equivalent of every statement and notes on connecting from Spring Boot.

پیش‌نیاز:Prerequisites: تسلط بر SQL: Join، Window، CTE و ایندکسSQL Mastery: Joins, Windows, CTEs & Indexes


خیلی از آگهی‌های بک‌اند کنار Java و Spring یک خط کوتاه دارند: «تسلط پیشرفته بر SQL Server». پشت این یک خط، یک دنیا کار است: باید T-SQL بنویسی، execution plan بخوانی، بفهمی چرا کوئری‌ای که دیروز ۲۰ میلی‌ثانیه بود امروز ۲۰ ثانیه شده، و اگر DBA اختصاصی نبود، خودت استراتژی backup/restore را طراحی کنی و بدانی اگر دیسک بسوزد چند دقیقه داده از دست می‌رود.

در sql-mastery و rdbms-tuning و oracle-postgres-dialects پایهٔ SQL و تیونینگ و دو گویش PostgreSQL و Oracle را ساختیم. این فصل گویش سوم را به‌عنوان شهروند درجه‌یک اضافه می‌کند و بعد سراغ مدیریت پایگاه‌داده می‌رود — همان کاری که وقتی سیستم روی هوا است دیده نمی‌شود و وقتی زمین می‌خورد همه دنبال کسی می‌گردند که بلد باشد.

نقشهٔ راه

معماری و نسخه‌ها ← گویش T-SQL (صفحه‌بندی، IDENTITY/SEQUENCE، NULL، رشته، تاریخ) ← ساختارهای موقت ← APPLY، window، PIVOT، MERGE، OUTPUT، JSON ← رویه، TRY/CATCH، XACT_ABORT، SQL پویا، UDF، trigger ← کارایی (plan، ایندکس، آمار، parameter sniffing، DMV، Query Store) ← همزمانی (ایزولاسیون، RCSI، deadlock، tempdb) ← مدیریت (backup/restore، RPO/RTO، لاگ، پارتیشن، امنیت، HA/DR) ← اتصال از Spring Boot.


۱. تصویر بزرگ

یک ساختمان اداری، نه یک اتاق

SQL Server را مثل ساختمان اداری ببین: یک instance (کل ساختمان — یک سرویس، یک استخر حافظه، یک مجموعه تنظیمات) و داخلش چند database (طبقه‌ها). login، حافظه، CPU و tempdb مال ساختمان‌اند؛ user، schema، جدول و ایندکس مال طبقه. همین تمایز ریشهٔ نصف سردرگمی‌هاست: «با login وصل شدم ولی جدول را نمی‌بینم» — چون login مال ساختمان است و user مال طبقه.

نسخه شماره compat level مهم‌ترین افزوده‌ها
2016 13.x 130 Query Store، Always Encrypted، JSON، temporal table، RLS
2017 14.x 140 اجرا روی Linux، STRING_AGG، TRIM، CONCAT_WS، adaptive join
2019 15.x 150 scalar UDF inlining، table variable deferred compilation، Accelerated Database Recovery، memory-optimized tempdb metadata
2022 16.x 160 Query Store پیش‌فرض روشن، Parameter Sensitive Plan optimization، Query Store hints، GENERATE_SERIES، DATE_BUCKET، GREATEST/LEAST، contained AG
2025 17.x 170 نوع بومی json و JSON index، توابع regex بومی، نوع vector، ADR برای tempdb، Query Store روی replica ثانویه
نسخهٔ موتور ≠ compatibility level

وقتی یک database را از ۲۰۱۶ روی ۲۰۲۲ بازیابی می‌کنی، موتور جدید است ولی سطح سازگاری همان ۱۳۰ می‌ماند و بخش بزرگی از قابلیت‌های optimizer خاموش است.

-- PostgreSQL معادلی ندارد؛ رفتار optimizer با نسخهٔ موتور می‌آید
SHOW server_version;

قاعدهٔ senior: هرگز سطح سازگاری را بدون Query Store روشن بالا نبر. اول Query Store را فعال کن و baseline بگیر تا اگر پلنی رگرسیون کرد بتوانی پلن قبلی را force کنی. (در Oracle همین نقش را OPTIMIZER_FEATURES_ENABLE و SQL Plan Baseline بازی می‌کنند.)

هر مرحله از مسیر اجرا یک ابزار عیب‌یابی دارد: plan cache را با DMV می‌بینی، buffer pool را با wait stats، لاگ را با sys.dm_db_log_space_usage. هر database حداقل یک فایل داده (.mdf، بعدی‌ها .ndf) داخل filegroup و یک فایل لاگ (.ldf) دارد. لاگ تراکنش دفتر روزنامه است: قبل از هر تغییر، خودِ تغییر ثبت می‌شود (write-ahead logging) تا بعد از قطع برق بشود کارهای نیمه‌تمام را برگرداند. tempdb هم database سیستمی است که بعد از هر restart از نو ساخته می‌شود و همه‌چیزِ موقت — جدول موقت، مرتب‌سازی، hash و version store — داخلش می‌رود؛ گلوگاه مشترک کل instance.


۲. گویش T-SQL: تفاوت‌هایی که می‌سوزانند

نود درصد SQL استانداردت کار می‌کند. آن ده درصد یا کامپایل نمی‌شود، یا بدتر: کامپایل می‌شود و اشتباه جواب می‌دهد.

۲.۱ صفحه‌بندی

SELECT id, total FROM orders ORDER BY total DESC LIMIT 10;
SELECT id, total FROM orders ORDER BY id OFFSET 40 LIMIT 20;

TOP دو قابلیت اضافه دارد: TOP (10) PERCENT و TOP (10) WITH TIES. معادل Oracle: FETCH FIRST 10 ROWS ONLY.

`TOP` بدون `ORDER BY` یعنی «هر ده سطری که دم دستم بود»

SELECT TOP (10) * FROM orders; هیچ تضمینی دربارهٔ ترتیب نمی‌دهد؛ امروز به‌ترتیب کلید خوشه‌ای می‌آید و فردا بعد از ساخت یک ایندکس عوض می‌شود — باگ کلاسیک گزارش «۱۰ سفارش آخر». برای صفحه‌بندی هم ORDER BY باید قطعی باشد (روی کلید یکتا)، وگرنه بین صفحه‌ها سطر تکراری و گم‌شده داری. و OFFSET 100000 یعنی خواندن و دور ریختن صد هزار سطر؛ الگوی درست keyset pagination است — و چون SQL Server مقایسهٔ چندستونی (row constructor) ندارد، باید شرط را دستی باز کنی: WHERE created_at < @last OR (created_at = @last AND id < @lastId).

۲.۲ کلید خودافزا: IDENTITY در برابر SEQUENCE

CREATE TABLE customer (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, full_name text);
CREATE SEQUENCE order_no_seq START 1000 INCREMENT 1 CACHE 50;
SELECT nextval('order_no_seq');

IDENTITY به ستون چسبیده و بدون SET IDENTITY_INSERT customer ON; نمی‌شود مقدارش را دستی داد. SEQUENCE شیء مستقل است، بین چند جدول به اشتراک گذاشته می‌شود و مقدارش را می‌شود قبل از INSERT گرفت. (Oracle از 12c هر دو را دارد.)

سه تلهٔ IDENTITY که در مصاحبه می‌پرسند.

۱. @@IDENTITY آخرین identity همان session را می‌دهد — حتی اگر یک trigger روی جدول دیگری ساخته باشدش، یعنی کلید اشتباه. همیشه SCOPE_IDENTITY() یا بهتر، OUTPUT. ۲. identity تراکنشی نیست؛ rollback شماره را برنمی‌گرداند. اگر شماره باید پیوسته باشد (فاکتور رسمی)، ابزار غلطی است. ۳. بعد از خاموشی ناگهانی، کش identity می‌تواند هزارتایی جهش کند؛ اگر مهم است SEQUENCE با NO CACHE بساز.

۲.۳ NULL، رشته و تاریخ

SELECT COALESCE(nickname, full_name, 'unknown') FROM customer;
`ISNULL` می‌تواند بی‌صدا رشته‌ات را ببرد

نوع خروجی ISNULL از آرگومان اول می‌آید، ولی COALESCE از قاعدهٔ تقدم نوع بین همهٔ آرگومان‌ها:

DECLARE @v varchar(5) = NULL;
SELECT ISNULL(@v, 'default value');    -- 'defau'  ← بی‌صدا بریده شد
SELECT COALESCE(@v, 'default value');  -- 'default value'

از طرف دیگر COALESCE به CASE بسط داده می‌شود، پس آرگومانِ زیرکوئری ممکن است دو بار ارزیابی شود. تلهٔ دوم: 'a' + NULL می‌شود NULL ولی CONCAT('a', NULL) می‌شود 'a' — منبع کلاسیک باگ در ساختن نام کامل و آدرس. و برای دادهٔ فارسی همیشه nvarchar و همیشه پیشوند یونیکد N'...'؛ بدون آن با collation نامناسب کاراکترها به ? تبدیل می‌شوند.

کار PostgreSQL SQL Server Oracle
الحاق a || b a + b یا CONCAT(a,b) a || b
طول length(s) LEN(s) (فاصلهٔ انتهایی را نمی‌شمارد) / DATALENGTH(s) بایت LENGTH(s)
اکنون now() SYSDATETIME() / GETDATE() / SYSUTCDATETIME() SYSTIMESTAMP
جمع تاریخ now() + interval '7 day' DATEADD(day, 7, SYSDATETIME()) SYSDATE + 7
بریدن تا ماه date_trunc('month', t) DATETRUNC(month, t) (2022+) TRUNC(t,'MM')
آخر ماه محاسبهٔ دستی EOMONTH(t) LAST_DAY(t)
قالب‌بندی to_char(t,'YYYY-MM-DD') CONVERT(varchar(10), t, 23) TO_CHAR(t,'YYYY-MM-DD')
تبدیل امن CAST(x AS int DEFAULT NULL ON CONVERSION ERROR) (PG 17+) TRY_CAST / TRY_CONVERT / TRY_PARSE ... DEFAULT NULL ON CONVERSION ERROR
تجمیع رشته string_agg(s, ',' ORDER BY s) STRING_AGG(s, ',') WITHIN GROUP (ORDER BY s) LISTAGG(s, ',') WITHIN GROUP (ORDER BY s)
regex s ~ 'p' REGEXP_LIKE(s,'p') (2025+) REGEXP_LIKE(s,'p')

دو تلهٔ تاریخ و قالب‌بندی.

FORMAT(t, 'yyyy-MM-dd') پشت صحنه به CLR می‌رود، سطر‌به‌سطر اجرا می‌شود و می‌تواند ده‌ها برابر کندتر از CONVERT با style number باشد. قالب‌بندی نمایشی کارِ لایهٔ اپلیکیشن است.

«سه تفاوت مهم T-SQL با SQL استاندارد را نام ببر»

(۱) صفحه‌بندی: TOP غیر ANSI است؛ OFFSET-FETCH استاندارد از ۲۰۱۲ آمد و همیشه به ORDER BY نیاز دارد. (۲) الحاق رشته: عملگر + است نه ||، و با NULL کل نتیجه NULL می‌شود در حالی که CONCAT نمی‌کند. (۳) کنترل جریان در خود زبان: در T-SQL در همان batch می‌شود DECLARE @x int و IF/WHILE نوشت؛ در PostgreSQL باید داخل DO $$ ... $$ یا یک function بروی. ضمناً ISNULL غیر استاندارد است و شناسه‌ها با [bracket] نقل‌قول می‌شوند.


۳. جدول موقت، متغیر جدولی، CTE و TVP

جدول موقت (#t) کاغذ یادداشت واقعی است: در tempdb ساخته می‌شود، آمار دارد، می‌شود رویش ایندکس ساخت. متغیر جدولی (@t) تختهٔ سفید کوچک است: سبک‌تر، ولی optimizer تقریباً چیزی از محتوایش نمی‌داند. CTE هیچ چیز ذخیره نمی‌کند؛ فقط اسمی برای یک زیرکوئری است.

ویژگی #temp @table CTE TVP
محل ذخیره tempdb tempdb (بله، نه فقط RAM) هیچ‌جا tempdb
statistics و ایندکس ثانویه دارد ندارد ندارد ندارد
تخمین سطر واقعی ۱ سطر (deferred از ۲۰۱۹) از کوئری پایه ۱ سطر
متأثر از rollback بله خیر خیر
بهترین کاربرد مجموعهٔ متوسط/بزرگ، چند بار مصرف چند ده سطر، لاگ داخل تراکنشی که rollback می‌شود خوانایی، بازگشتی ارسال دسته‌ای سطر از اپلیکیشن
متغیر جدولی «در حافظه» نیست و optimizer را کور می‌کند

دو باور غلط: «@table در RAM است و #temp روی دیسک» (هر دو در tempdb‌اند) و «متغیر جدولی سریع‌تر است» (برای ۵ سطر بله؛ برای ۵۰۰ هزار سطر optimizer تخمین ۱ سطر می‌زند، nested loop انتخاب می‌کند و کوئری از ۲ ثانیه به ۲۰ دقیقه می‌رود). SQL Server 2019 با table variable deferred compilation (سطح ۱۵۰) کامپایل را تا پر شدن متغیر عقب می‌اندازد و cardinality واقعی را می‌بیند، ولی هنوز statistics ستونی ندارد؛ روی نسخهٔ قدیمی‌تر راه فرار OPTION (RECOMPILE) است.

نکتهٔ تمایز: CTE در SQL Server هرگز materialize نمی‌شود — سه بار صدا زدن یعنی سه بار اجرا. در PostgreSQL تا ۱۱ برعکس بود (optimization fence) و از ۱۲ پیش‌فرض inline است مگر MATERIALIZED بنویسی.


۴. APPLY: همان چیزی که PostgreSQL اسمش را LATERAL گذاشته

JOIN یعنی «دو لیست را مطابقت بده». APPLY یعنی «برای هر سطر سمت چپ، این زیرکوئری را دوباره با مقادیر همان سطر اجرا کن» — مثل اینکه به‌جای گرفتن کل انبار، برای هر مشتری فقط به قفسهٔ خودش سر بزنی.

SELECT c.id, o.id AS order_id FROM customer c
CROSS JOIN LATERAL (SELECT id FROM orders o WHERE o.customer_id = c.id
                    ORDER BY o.created_at DESC LIMIT 3) o;

CROSS APPLY مثل INNER JOIN است و OUTER APPLY مثل LEFT JOIN. سه جای برنده: Top-N به‌ازای هر گروه (بالا — با ایندکس (customer_id, created_at DESC) فقط سه سطر اول هر گروه خوانده می‌شود)، فراخوانی table-valued function به‌ازای هر سطر، و حذف تکرار عبارت‌های محاسباتی با CROSS APPLY (SELECT o.total - o.discount AS net) calc(net) که اجازه می‌دهد net را در SELECT، WHERE و ORDER BY بی‌تکرار استفاده کنی.

هشدار: APPLY تقریباً همیشه به nested loop ترجمه می‌شود. اگر سمت چپ ده میلیون سطر باشد و زیرکوئری ایندکس مناسب نداشته باشد، ده میلیون بار scan می‌کنی. APPLY را وقتی بزن که سمت چپ کوچک است یا زیرکوئری seek تمیز دارد.


۵. window function، PIVOT و UNPIVOT

سینتکس window function در هر سه گویش یکسان است و در sql-mastery عمیق پوشش داده شده؛ اینجا فقط تلهٔ مخصوص:

پیش‌فرض قاب `RANGE` است، نه `ROWS`

اگر ORDER BY بدهی و قاب (frame) را ننویسی، پیش‌فرض RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW است. RANGE سطرهای هم‌ارزش را یکجا حساب می‌کند: دو سفارش با created_at یکسان، running total یکسان و شامل هر دو می‌گیرند. ضمناً RANGE در SQL Server معمولاً یک worktable روی دیسک در tempdb می‌سازد و کندتر است.

همیشه ROWS BETWEEN ... را صریح بنویس. این دقیقاً سؤال مصاحبه‌گر عمیق است.

PIVOT مخصوص T-SQL است؛ PostgreSQL معادل داخلی ندارد و با FILTER یا crosstab از افزونهٔ tablefunc انجام می‌شود:

SELECT customer_id,
       count(*) FILTER (WHERE status = 'NEW')  AS "NEW",
       count(*) FILTER (WHERE status = 'PAID') AS "PAID"
FROM orders GROUP BY customer_id;

ستون‌های PIVOT باید در زمان کامپایل ثابت باشند؛ لیست پویا یعنی SQL پویا و ریسک تزریق. قضاوت senior: pivot را در لایهٔ گزارش انجام بده.


۶. MERGE و OUTPUT

INSERT INTO product AS p (sku, name, price)
SELECT sku, name, price FROM staging_product
ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price;
پنج نکتهٔ `MERGE` که مصاحبه‌گر دنبالشان است

۱. سمی‌کالن اجباری: MERGE بدون ; انتهایی خطای 10713 می‌دهد. ۲. بدون HOLDLOCK مسابقه داری: مستندات می‌گوید وقتی کلید یکتا هم‌زمان insert و update می‌شود، HOLDLOCK (مترادف SERIALIZABLE) جلوی نقض کلید یکتا را می‌گیرد؛ بدون آن دو نشست هم‌زمان هر دو «not matched» می‌بینند و هر دو insert می‌کنند. ۳. الگوی قفل متفاوت: مستندات هشدار می‌دهد MERGE مکانیزم قفل‌گذاری متفاوتی از سه جملهٔ جدا دارد و در مقیاس بالا ممکن است مسائل پیچیدهٔ همزمانی بسازد؛ برای ETL سنگین در ساعت شلوغ جمله‌های جدا کم‌بلاک‌ترند. ۴. @@ROWCOUNT در trigger دروغ می‌گوید: اگر MERGE یک سطر insert، یک update و یک delete کند، @@ROWCOUNT داخل هر trigger برابر ۳ است.

OUTPUT معادل RETURNING است ولی قوی‌تر: هم inserted و هم deleted را می‌دهد و می‌تواند مستقیم داخل جدول بنویسد:

WITH upd AS (
  UPDATE orders SET status = 'PAID' WHERE id = $1 AND status = 'NEW'
  RETURNING id, status)
INSERT INTO order_audit (order_id, new_status) SELECT id, status FROM upd;
`OUTPUT` جایگزین امن `SCOPE_IDENTITY()` برای درج دسته‌ای است

SCOPE_IDENTITY() فقط یک مقدار می‌دهد. اگر ۵۰۰ سطر را با یک INSERT ... SELECT درج کنی و کلیدهای تولیدشده را بخواهی، تنها راه تمیز OUTPUT inserted.id INTO @newIds است — دقیقاً همان چیزی که در batch insert لازم می‌شود. نکتهٔ ریز: OUTPUT ... INTO روی جدول دارای trigger یا foreign key محدودیت دارد و مقصد باید جدول موقت باشد.

«`MERGE` یا upsert دستی؟ کدام و چرا؟»

MERGE یک جملهٔ اتمی است که منبع و هدف را join می‌کند و برای هر سطر یکی از شاخه‌های WHEN MATCHED / WHEN NOT MATCHED BY TARGET / WHEN NOT MATCHED BY SOURCE را اجرا می‌کند؛ یک بار داده را می‌خواند و با OUTPUT $action می‌شود فهمید هر سطر insert شد یا update. اما بدون HOLDLOCK مستعد race condition است و مستندات خود Microsoft برای بار سنگین با همزمانی زیاد جمله‌های جدا را توصیه می‌کند. انتخاب من: برای ETL و sync دسته‌ای در پنجرهٔ کنترل‌شده MERGE با HOLDLOCK؛ برای OLTP پرترافیک الگوی «UPDATE بزن؛ اگر @@ROWCOUNT = 0 بود INSERT کن» داخل تراکنش.


۷. JSON

CREATE TABLE event (id bigserial PRIMARY KEY, payload jsonb NOT NULL);
CREATE INDEX ix_event_payload ON event USING GIN (payload jsonb_path_ops);
SELECT payload ->> 'orderId' FROM event WHERE payload @> '{"type":"ORDER_PAID"}';

از SQL Server 2025 نوع دادهٔ بومی json وجود دارد که به‌جای متن، باینری بهینه ذخیره می‌شود و می‌شود رویش JSON index ساخت؛ همچنین JSON_CONTAINS، JSON_OBJECTAGG و JSON_ARRAYAGG اضافه شدند (JSON_OBJECT/JSON_ARRAY از ۲۰۲۲). روی نسخه‌های قدیمی‌تر، الگوی «ستون محاسبه‌شدهٔ PERSISTED + ایندکس» جواب استاندارد مصاحبه است.


۸. رویهٔ ذخیره‌شده و برنامه‌نویسی رویه‌ای

-- در PostgreSQL همین کار با یک PROCEDURE و پارامتر OUT انجام می‌شود
CREATE OR REPLACE PROCEDURE app.place_order(IN p_total numeric, OUT p_order_id bigint)
LANGUAGE plpgsql AS $$ BEGIN
  INSERT INTO orders (total, status) VALUES (p_total, 'NEW') RETURNING id INTO p_order_id;
END $$;
سه عادت که کد رویه‌ای را حرفه‌ای می‌کند

SET NOCOUNT ON: بدون آن هر DML یک پیام DONE_IN_PROC روی شبکه می‌فرستد؛ بعضی مسیرهای JDBC/ORM این پیام‌ها را «نتیجه» می‌بینند و getMoreResults() را گیج می‌کنند — علامت کلاسیک «در SSMS کار می‌کند، از اپلیکیشن نه». CREATE OR ALTER (از 2016 SP1) اسکریپت‌های Flyway/Liquibase را تمیز می‌کند و برخلاف DROP + CREATE مجوزها را نگه می‌دارد. GO دستور SQL نیست؛ جداکنندهٔ batch است که فقط ابزارهای کلاینت می‌فهمند و داخل PreparedStatement جاوا خطا می‌دهد.

۸.۱ خطا و تراکنش

تراکنش مثل قرارداد ساخت است: یا کل ساختمان تحویل می‌شود یا هیچ. اما یک حالت خطرناک هست: تراکنش می‌تواند doomed شود — هنوز باز است، هنوز در CATCH هستی، ولی هیچ کاری جز ROLLBACK ممکن نیست.

-- در PL/pgSQL بلوک EXCEPTION همین نقش TRY/CATCH را دارد
DO $$ BEGIN
  INSERT INTO orders (total, status) VALUES (100, 'NEW');
EXCEPTION WHEN unique_violation THEN RAISE EXCEPTION 'duplicate order';
END $$;

دیاگرام: چرخهٔ عمر تراکنش و حالت doomed | Diagram: transaction lifecycle and the doomed state

stateDiagram-v2
  [*] --> NoTransaction
  NoTransaction --> Active: BEGIN TRANSACTION
  Active --> Committed: COMMIT
  Active --> RolledBack: ROLLBACK
  Active --> Doomed: runtime error with XACT_ABORT ON
  Doomed --> RolledBack: ROLLBACK only
  Doomed --> Doomed: any DML fails with error 3930
`THROW` در برابر `RAISERROR`، و دروغِ تراکنش تودرتو

RAISERROR قالب‌بندی printf-مانند دارد (RAISERROR('order %d not found', 16, 1, @id)) ولی نمی‌تواند خطای اصلی را با شمارهٔ اصلی دوباره پرتاب کند. THROW (از ۲۰۱۲) همیشه شدت ۱۶ دارد، batch را قطع می‌کند و بدون آرگومان داخل CATCH دقیقاً خطای اصلی را برمی‌گرداند. کد جدید فقط THROW؛ تنها کاربرد باقی‌ماندهٔ RAISERROR پیام‌های اطلاعاتی با شدت ۰–۱۰ و WITH NOWAIT است. نکتهٔ نحوی: جملهٔ قبل از THROW باید به ; ختم شود.

تلهٔ بزرگ‌تر: تراکنش تودرتو یک دروغ است. دو بار BEGIN TRANSACTION یعنی @@TRANCOUNT = 2 ولی همچنان یک تراکنش؛ COMMIT داخلی فقط شمارنده را کم می‌کند، اما ROLLBACK بدون نام savepoint همه‌چیز را تا بیرونی‌ترین سطح برمی‌گرداند. الگوی درست این است که هر رویه فقط چیزی را rollback کند که خودش شروع کرده: اگر @@TRANCOUNT > 0 بود SAVE TRANSACTION sp_step بزن و در CATCH فقط به همان savepoint برگرد؛ وگرنه خودت BEGIN TRANSACTION کن و خودت COMMIT/ROLLBACK کن.

اگر XACT_STATE() برابر -1 باشد بازگشت به savepoint ممکن نیست؛ SET XACT_ABORT ON هر خطا را doomed می‌کند — امن است ولی با savepoint سازگار نیست.

«`SET XACT_ABORT ON` دقیقاً چه می‌کند و چرا توصیه می‌شود؟»

به‌طور پیش‌فرض بسیاری از خطاهای زمان اجرا فقط همان جمله را لغو می‌کنند و batch با تراکنشِ همچنان باز ادامه می‌دهد؛ یعنی می‌توانی نیمی از کار را انجام داده باشی، خطا بگیری و بعد بی‌خبر COMMIT کنی — دادهٔ نیمه‌کاره. با SET XACT_ABORT ON هر خطای زمان اجرا کل تراکنش را غیرقابل‌commit می‌کند و rollback رخ می‌دهد.

۸.۲ SQL پویا

EXECUTE format('SELECT count(*) FROM %I WHERE status = $1', p_table) INTO v_count USING p_status;

سه قانون آهنین SQL پویا.

۱. هرگز مقدار را الحاق نکن؛ مقدار همیشه پارامتر sp_executesql است. EXEC('...' + @x) درِ باز برای تزریق است (appsec-owasp). ۲. شناسه‌ها را با QUOTENAME() بپوشان و از allow-list عبور بده. ۳. EXEC(@sql) را با sp_executesql عوض کن: فقط این یکی پارامتری است و چون متن جمله ثابت می‌ماند پلن بازاستفاده می‌شود؛ الحاق مقدار یعنی هر مقدار یک پلن جدید → آلودگی plan cache.

نکتهٔ امنیتی: SQL پویا داخل رویه، زنجیرهٔ مالکیت (ownership chaining) را می‌شکند و کاربر باید روی جدول‌های زیرین مستقیماً مجوز داشته باشد؛ راه درست WITH EXECUTE AS OWNER یا امضای رویه با گواهی است.

۸.۳ UDF، trigger و cursor

-- inline TVF: یک SELECT، بدون BEGIN/END؛ optimizer بازش می‌کند ✅
CREATE OR ALTER FUNCTION app.orders_of(@customer_id bigint)
RETURNS TABLE
AS RETURN (SELECT id, total, created_at FROM dbo.orders WHERE customer_id = @customer_id);

-- در مقابل، multi-statement TVF (`RETURNS @r TABLE ... BEGIN ... END`) جدول را می‌سازد
-- و پر می‌کند و برای optimizer جعبهٔ سیاه است ⚠️
تابع اسکالر یعنی حلقه‌ای که نمی‌بینی‌اش

قبل از SQL Server 2019 یک تابع اسکالر در SELECT برای هر سطر یک بار اجرا می‌شد، کل کوئری را از حالت موازی خارج می‌کرد و هزینه‌اش در execution plan دیده نمی‌شد. SQL Server 2019 با scalar UDF inlining (سطح ۱۵۰) تابع را به زیرکوئری اسکالر معادل تبدیل می‌کند، ولی هر تابعی واجد شرایط نیست (حلقه، WHILE، توابع زمان‌محور، تغییر داده مانع می‌شوند):

SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID('app.net_total');
CREATE OR ALTER FUNCTION app.f(...) RETURNS int WITH INLINE = OFF AS ...;   -- سطح تابع
ALTER DATABASE SCOPED CONFIGURATION SET TSQL_SCALAR_UDF_INLINING = OFF;     -- سطح دیتابیس
SELECT ... OPTION (USE HINT ('DISABLE_TSQL_SCALAR_UDF_INLINING'));          -- سطح جمله

الگوی طلایی: هر تابع اسکالری که فقط یک محاسبه است را به inline TVF تک‌ستونی تبدیل کن و با CROSS APPLY صدا بزن.

trigger در SQL Server مجموعه‌ای است نه سطری: یک UPDATE روی ۱۰۰۰ سطر، trigger را یک بار با ۱۰۰۰ سطر در inserted/deleted صدا می‌زند.

CREATE TRIGGER orders_audit AFTER UPDATE ON orders
  FOR EACH ROW EXECUTE FUNCTION trg_orders_audit();   -- سطری
CREATE OR ALTER TRIGGER dbo.trg_orders_audit ON dbo.orders AFTER UPDATE AS
BEGIN
  SET NOCOUNT ON;
  IF NOT UPDATE(status) RETURN;
  INSERT INTO dbo.order_audit (order_id, old_status, new_status, changed_at)
  SELECT i.id, d.status, i.status, SYSUTCDATETIME()
  FROM inserted i JOIN deleted d ON d.id = i.id
  WHERE i.status <> d.status;      -- مجموعه‌ای، نه سطری
END;
trigger سطری‌نوشته‌شده = بمب ساعتی

پرتکرارترین باگ: SELECT @id = id FROM inserted; — فقط یک سطر تصادفی را می‌گیرد و بقیه را بی‌صدا نادیده می‌گیرد، تا روزی که کسی یک UPDATE دسته‌ای بزند. سه هشدار دیگر: trigger داخل تراکنش کاربر اجرا می‌شود و کندی‌اش مستقیماً blocking می‌سازد؛ ROLLBACK داخل trigger کل تراکنش کاربر را برمی‌گرداند؛ و برای audit، temporal table (SYSTEM_VERSIONING = ON) یا Change Data Capture جایگزین تمیزتری است.

«چرا توابع اسکالر در SQL Server بدنام‌اند و جایگزینت چیست؟»

چون تا قبل از ۲۰۱۹ برای هر سطر یک بار در context جداگانه اجرا می‌شدند، هزینه‌شان در plan دیده نمی‌شد و حضورشان کل کوئری را serial می‌کرد. از ۲۰۱۹ با سطح سازگاری ۱۵۰، scalar UDF inlining بسیاری‌شان را به زیرکوئری معادل تبدیل می‌کند، ولی همه واجد شرایط نیستند و باید sys.sql_modules.is_inlineable را چک کرد. جایگزین من: منطق را یا مستقیم در کوئری بنویسم، یا در یک inline table-valued function بگذارم و با CROSS APPLY صدا بزنم — چون optimizer بسطش می‌دهد و جریمهٔ سطر‌به‌سطر ندارد.


۹. کارایی: از پلن تا ایندکس

پلن نقشه‌ای است که optimizer برای اجرای کوئری کشیده: estimated (بدون اجرا) و actual (بعد از اجرا، با تعداد سطر واقعی؛ در SSMS با Ctrl+M). پلن را از راست به چپ بخوان و سه چیز را به‌ترتیب ببین:

۱. اختلاف Estimated Rows با Actual Rows. تخمین ۱ و واقعی ۲۰۰٬۰۰۰ یعنی ریشهٔ مشکل همین است: statistics قدیمی، متغیر جدولی، تابع، یا parameter sniffing. تا این را درست نکنی بقیهٔ تیونینگ حدس‌زدن است. ۲. عملگرهای گران: Key Lookup، Sort، Hash Match، Table Spool و هر Scan روی جدول بزرگ. ۳. هشدارها (مثلث زرد): Implicit Conversion، No Join Predicate، Spill to tempdb (حافظهٔ اختصاص‌یافته کم بوده و مرتب‌سازی روی دیسک رفته).

چهار عملگری که باید در یک نگاه بشناسی

Index Seek با B-tree مستقیم به سطرهای هدف می‌رود — چیزی که می‌خواهی. Index/Table Scan کل ساختار را می‌خواند؛ برای ۹۰٪ سطرها طبیعی، برای ۱۰ سطر فاجعه. Key Lookup یعنی ایندکس غیرخوشه‌ای ستون موردنیاز را نداشته و برای هر سطر به جدول خوشه‌ای برگشته؛ علاجش INCLUDE است. Nested Loops / Hash Match / Merge Join به‌ترتیب برای «چپ کوچک + ایندکس سمت راست»، «هر دو بزرگ و بی‌ترتیب» و «هر دو مرتب» مناسب‌اند؛ nested loop روی جدول ده‌میلیونی معمولاً یعنی تخمین سطر اشتباه بوده.

۹.۱ ایندکس‌ها

هر جدول یا heap است یا clustered index دارد، یعنی خودِ جدول به‌ترتیب کلید خوشه‌ای روی برگ‌های B-tree ذخیره شده — پس هر جدول فقط یک ایندکس خوشه‌ای دارد. ایندکس‌های nonclustered ساختار جداگانه‌اند و در برگ‌هایشان کلید خوشه‌ای (یا RID در heap) را نگه می‌دارند تا به سطر اصلی برگردند.

-- در PostgreSQL همهٔ ایندکس‌ها ثانویه‌اند؛ ترتیب فیزیکی با CLUSTER دستی است
CREATE INDEX ix_orders_cust ON orders (customer_id, created_at DESC) INCLUDE (total);
CREATE INDEX ix_orders_open ON orders (created_at) WHERE status = 'NEW';   -- partial index
SARGability: یک تابع روی ستون، ایندکس را می‌کشد

WHERE YEAR(created_at) = 2026 یا WHERE CONVERT(varchar, id) = '42' یعنی scan کامل؛ معادل SARGable آن‌ها WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' و WHERE id = 42 است. بدترین نسخه‌اش implicit conversion است: اگر ستون varchar باشد و پارامتر nvarchar بفرستی، به‌خاطر قاعدهٔ تقدم نوع ستون به nvarchar تبدیل می‌شود و ایندکس بی‌فایده — دقیقاً همان چیزی که درایور JDBC به‌طور پیش‌فرض می‌سازد (در بخش Spring Boot برمی‌گردیم). در پلن به‌شکل CONVERT_IMPLICIT دیده می‌شود.

ترتیب ستون‌ها در ایندکس مرکب: اول ستون‌های تساوی (=)، بعد ستون بازه، بعد ستون‌های ORDER BY؛ ستون‌هایی که فقط در SELECTاند به INCLUDE بروند.

۹.۲ statistics و parameter sniffing

statistics هیستوگرام توزیع مقادیر یک ستون است و optimizer فقط بر اساس همین تصمیم می‌گیرد. parameter sniffing یعنی موتور در اولین اجرا مقدار پارامتر را بو می‌کشد، پلن بهینه برای آن مقدار می‌سازد و کش می‌کند؛ اگر توزیع نامتوازن باشد (یک مشتری ۲ سفارش، یکی ۲ میلیون)، همان پلن به همه تحمیل می‌شود.

«دیشب کوئری خوب بود، امروز نیست» تقریباً همیشه parameter sniffing است

علائم: همان کوئری با پارامتر A سریع و با B کند؛ یا بعد از restart / rebuild ایندکس / به‌روزرسانی آمار ناگهان کند می‌شود، چون پلن دوباره کامپایل شد و این بار پارامتر «بد» را دید. جعبه‌ابزار درمان از سبک به سنگین:

UPDATE STATISTICS dbo.orders WITH FULLSCAN;               -- اول از همه: آمار تازه
SELECT ... OPTION (RECOMPILE);                            -- هر بار پلن اختصاصی
SELECT ... OPTION (OPTIMIZE FOR (@customer_id = 42));     -- برای یک مقدار نماینده
SELECT ... OPTION (OPTIMIZE FOR UNKNOWN);                 -- بر اساس میانگین توزیع
EXEC sys.sp_query_store_set_hints @query_id = 39,
     @query_hints = N'OPTION(RECOMPILE)';                 -- بدون تغییر کد اپلیکیشن، 2022+

راه‌حل ساختاری: از SQL Server 2022 با سطح سازگاری ۱۶۰، Parameter Sensitive Plan optimization برای یک کوئری پارامتری چند پلن فعال در کش نگه می‌دارد و بر اساس بازهٔ cardinality پارامتر انتخاب می‌کند — بزرگ‌ترین دلیل عملی برای رفتن به ۱۶۰. و ترفند قدیمیِ کپی‌کردن پارامتر در متغیر محلی را نزن: sniffing را خاموش می‌کند ولی optimizer را کور می‌کند.

۹.۳ جعبه‌ابزار عیب‌یابی

SET STATISTICS IO ON; و SET STATISTICS TIME ON; را روشن کن. logical reads (تعداد صفحات ۸ کیلوبایتی خوانده‌شده) بهترین معیار برای مقایسهٔ دو نسخه از یک کوئری است، چون برخلاف زمان به بار لحظه‌ای سرور وابسته نیست.

می‌خواهی بدانی ابزار
الان چه اجرا می‌شود و منتظر چیست sys.dm_exec_requests + sys.dm_exec_sessions
گران‌ترین کوئری‌های تاریخی sys.dm_exec_query_stats (total_worker_time, total_logical_reads)
ایندکس‌های پیشنهادی sys.dm_db_missing_index_details + _groups + _group_stats
قفل و بلاک sys.dm_tran_locks، sys.dm_os_waiting_tasks
فضای لاگ و علت عدم بازاستفاده sys.dm_db_log_space_usage، sys.databases.log_reuse_wait_desc
تاریخچهٔ پلن و رگرسیون sys.query_store_query, _plan, _runtime_stats

Query Store جعبهٔ سیاه هواپیماست: تاریخچهٔ کوئری‌ها، پلن‌ها، آمار اجرا و wait را داخل خود database ذخیره می‌کند و از restart جان سالم به در می‌برد (برخلاف plan cache). از SQL Server 2022 برای database‌های جدید پیش‌فرض در حالت READ_WRITE روشن است.

روشن کردنش با ALTER DATABASE app SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO, MAX_STORAGE_SIZE_MB = 2048); است و تثبیت یک پلن خوب با sys.sp_query_store_force_plan.

پیشنهادهای «missing index» را کورکورانه اجرا نکن

این DMVها فقط می‌گویند «اگر این ایندکس بود، این یک کوئری بهتر می‌شد». هیچ اطلاعی از ایندکس‌های موجود، هزینهٔ نوشتن، فضای دیسک یا هم‌پوشانی پیشنهادها ندارند و impact را اغراق‌آمیز گزارش می‌کنند؛ تیم‌هایی ۴۰ ایندکس پیشنهادی ساخته‌اند و INSERTشان سه برابر کند شده. روش درست: پیشنهادها را جمع کن، هم‌پوشان‌ها را در یک ایندکس مرکب ادغام کن، با sys.dm_db_index_usage_stats بی‌مصرف‌ها را حذف کن، و هر تغییر را با Query Store قبل/بعد بسنج.

«یک کوئری در production کند شده. مرحله‌به‌مرحله چه می‌کنی؟»

(۱) اندازه‌گیری نه حدس: از Query Store یا sys.dm_exec_query_stats می‌فهمم واقعاً گران است یا نه و آیا رگرسیون پلن داشته. (۲) wait را ببین: sys.dm_exec_requests.wait_type می‌گوید مشکل CPU است، I/O (PAGEIOLATCH_*)، بلاک (LCK_M_*) یا حافظه (RESOURCE_SEMAPHORE). (۳) پلن واقعی را بگیرم و اختلاف estimated/actual rows را ببینم. (۴) اگر اختلاف زیاد بود: آمار تازه و بررسی parameter sniffing. (۵) اگر پلن منطقی بود ولی خواندن زیاد: ایندکس پوشا یا SARGable کردن کوئری. (۶) اگر پلن قبلاً خوب بوده: با Query Store همان پلن را force کن تا وقت بخری، بعد ریشه را درست کن.


۱۰. همزمانی: قفل، ایزولاسیون، deadlock

SQL Server پیش‌فرض قفل‌محور است نه نسخه‌محور — بزرگ‌ترین تفاوت رفتاری با PostgreSQL و Oracle. در READ COMMITTED پیش‌فرض، خواننده‌ها قفل اشتراکی می‌گیرند و نویسنده‌ها خواننده‌ها را بلاک می‌کنند؛ در PostgreSQL و Oracle به‌خاطر MVCC خواندن هرگز بلاک نمی‌شود.

سطح ایزولاسیون dirty read non-repeatable phantom مکانیزم
READ UNCOMMITTED ✅ ممکن بدون قفل اشتراکی (NOLOCK)
READ COMMITTED (پیش‌فرض) قفل اشتراکی کوتاه
READ COMMITTED SNAPSHOT نسخه‌سازی سطر در tempdb، بدون قفل خواندن
REPEATABLE READ قفل اشتراکی تا پایان تراکنش
SNAPSHOT تصویر لحظه‌ای شروع تراکنش + تشخیص تعارض
SERIALIZABLE قفل بازه‌ای (key-range)
SHOW default_transaction_isolation;   -- PostgreSQL ذاتاً MVCC است
RCSI مهم‌ترین سوئیچ همزمانی است — و رایگان نیست

READ_COMMITTED_SNAPSHOT پیش‌فرض خاموش است (برخلاف Azure SQL Database). با روشن کردنش، READ COMMITTED به‌جای قفل اشتراکی نسخهٔ قبل از تغییر سطر را از version store در tempdb می‌خواند: گزارش‌ها دیگر تراکنش‌های OLTP را بلاک نمی‌کنند و نیاز به WITH (NOLOCK) تقریباً از بین می‌رود. هزینه‌ها: هر سطر ۱۴ بایت اضافه برای اشاره‌گر نسخه (page split و رشد جدول)؛ فشار جدی روی tempdb — یک تراکنش طولانی می‌تواند version store را باد کند؛ و کدی که به بلاک‌شدن تکیه کرده ممکن است رفتارش عوض شود. خود تغییر هم قفل انحصاری روی database می‌خواهد، پس پنجرهٔ تعمیرات لازم است.

و WITH (NOLOCK) «سریع‌کردن کوئری» نیست بلکه «نادیده‌گرفتن قوانین» است: علاوه بر dirty read می‌تواند به‌خاطر جابه‌جایی صفحات حین scan سطرها را تکراری یا گم‌شده برگرداند. جواب درست برای گزارش بدون بلاک، RCSI است نه NOLOCK.

deadlock یعنی دو تراکنش هرکدام قفلی را نگه داشته‌اند که دیگری می‌خواهد. SQL Server چرخه را تشخیص می‌دهد، یکی را قربانی می‌کند (خطای ۱۲۰۵) و تراکنشش را کامل rollback می‌کند. گراف deadlock از session رویداد system_health (پیش‌فرض روشن) بیرون می‌آید.

دیاگرام: تشکیل یک deadlock کلاسیک | Diagram: how a classic deadlock forms

sequenceDiagram
  participant A as Session A
  participant O as orders row 1
  participant L as order_line row 9
  participant B as Session B
  A->>O: UPDATE, holds X lock
  B->>L: UPDATE, holds X lock
  A->>L: UPDATE, waits for B
  B->>O: UPDATE, waits for A
  Note over A,B: cycle detected, error 1205 kills one victim
«چطور در برابر deadlock و blocking مقاوم می‌نویسی؟»

deadlock خطای گذرا است، پس اول retry با backoff نمایی فقط برای شماره‌های گذرا (۱۲۰۵ deadlock، ۱۲۲۲ lock timeout). بعد ریشه‌کنی: (الف) همهٔ کدها منابع را با ترتیب یکسان بگیرند؛ (ب) تراکنش‌ها کوتاه باشند و هیچ فراخوانی HTTP یا صبر کاربر داخلشان نباشد؛ (ج) ایندکس مناسب بسازم تا UPDATE به‌جای scan، seek کند — بخش بزرگی از deadlockها در واقع مسئلهٔ ایندکس‌اند؛ (د) اگر خواندن‌ها با نوشتن‌ها گلاویزند RCSI را روشن کنم؛ (ه) برای الگوی صف، WITH (UPDLOCK, READPAST, ROWLOCK). در سمت جاوا، retry بیرون تراکنش — resilience و spring-data-tx.

tempdb گلوگاه مشترک کل instance است؛ علامت کلاسیک، انتظار PAGELATCH_* روی صفحات سیستمی (2:1:1 یعنی PFS) است. درمان: چند فایل داده با اندازه و autogrowth برابر (شروع با تعداد هسته تا سقف ۸)، و از SQL Server 2019 هم ALTER SERVER CONFIGURATION SET MEMORY_OPTIMIZED TEMPDB_METADATA = ON;.


۱۱. columnstore و پارتیشن‌بندی

columnstore داده را ستونی و فشرده در گروه‌های سطری تا ۱٬۰۴۸٬۵۷۶ سطری ذخیره می‌کند و همراهش batch mode می‌آید که به‌جای یک سطر، دسته‌ای از سطرها را در هر فراخوانی عملگر پردازش می‌کند. برای جدول‌های تحلیلی ده‌ها میلیونی ۱۰ تا ۱۰۰ برابر تفاوت می‌سازد؛ برای جست‌وجوی تک‌سطری OLTP بدترین انتخاب است.

پارتیشن‌بندی هم در وهلهٔ اول ابزار مدیریت است نه کارایی؛ سود اصلی‌اش حذف آنی دادهٔ قدیمی با SWITCH (به‌جای DELETE میلیون‌سطری و انفجار لاگ) و نگه‌داری ایندکس پارتیشن‌به‌پارتیشن است.

CREATE TABLE orders (id bigint, created_at date NOT NULL) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
ALTER TABLE orders DETACH PARTITION orders_2026_01;   -- حذف آنی

۱۲. پشتیبان‌گیری و بازیابی

RPO و RTO با زبان آدمیزاد

RPO یعنی «حاضرم چند دقیقه داده از دست بدهم» و مستقیماً فاصلهٔ backup لاگ را تعیین می‌کند: هر ۱۵ دقیقه backup لاگ یعنی RPO پانزده‌دقیقه‌ای. RTO یعنی «حاضرم چند دقیقه سیستم پایین باشد» و تعیین می‌کند چند backup تفاضلی بگیری و آیا اصلاً به availability group نیاز داری. این دو عدد را کسب‌وکار تعیین می‌کند نه DBA؛ کار تو ترجمهٔ آن‌ها به زمان‌بندی است.

سه نوع backup: full (کل database)، differential (فقط صفحات تغییرکرده از آخرین full — نه از آخرین diff)، log (رکوردهای لاگ از آخرین backup لاگ؛ فقط در recovery model کامل یا bulk-logged).

recovery model backup لاگ بازیابی نقطه‌ای رشد لاگ کاربرد
SIMPLE ندارد ندارد؛ فقط تا آخرین full/diff خودکار truncate می‌شود توسعه، انبار دادهٔ قابل‌بازسازی
FULL لازم و اجباری تا لحظهٔ دلخواه بدون backup لاگ بی‌نهایت رشد می‌کند تقریباً هر production
BULK_LOGGED دارد فقط اگر در آن بازه عملیات bulk نبوده برای bulk کم می‌ماند پنجرهٔ موقت بارگذاری سنگین
-- PostgreSQL دستور BACKUP داخلی ندارد؛ base backup + آرشیو پیوستهٔ WAL:
--   pg_basebackup -D /backup/base -Ft -z -X stream -c fast
ALTER SYSTEM SET archive_mode = on;
ALTER SYSTEM SET archive_command = 'test ! -f /wal/%f && cp %p /wal/%f';

دیاگرام: زنجیرهٔ بازیابی نقطه‌ای | Diagram: the point-in-time restore chain

flowchart LR
  F[Full backup 02:00] --> D[Differential 08:00]
  D --> L1[Log 10:00]
  L1 --> L2[Log 10:15]
  L2 --> T[Tail-log backup NORECOVERY]
  T --> R[RESTORE ... STOPAT 10:19 WITH RECOVERY]
پنج اشتباه backup که سیستم را از بین می‌برد

۱. recovery model روی FULL است ولی backup لاگ گرفته نمی‌شود — لاگ تا پر شدن دیسک رشد می‌کند و database متوقف می‌شود؛ یا لاگ بگیر یا مدل را SIMPLE کن. ۲. backupی که هیچ‌وقت restore نشده، backup نیست؛ بازیابی را دوره‌ای روی سرور جدا تمرین کن و زمانش را ثبت کن — همان زمان، RTO واقعی توست. ۳. WITH CHECKSUM را جا انداختن؛ بدون آن خرابی صفحه بی‌صدا داخل backup کپی می‌شود. کنارش PAGE_VERIFY CHECKSUM و DBCC CHECKDB هفتگی. ۴. نگه‌داری همهٔ backupها روی همان دیسک و همان سرور؛ قاعدهٔ ۳-۲-۱ را رعایت کن. ۵. فراموش کردن دم لاگ: اگر سرور هنوز بالا می‌آید، اول BACKUP LOG ... WITH NORECOVERY بگیر، وگرنه همهٔ تراکنش‌های بعد از آخرین backup لاگ رفته‌اند.

«RPO پانزده دقیقه و RTO یک ساعت برای دیتابیس یک‌ترابایتی — چه طراحی می‌کنی؟»

recovery model را FULL می‌گذارم: full هفتگی، differential روزانه، log هر ۱۵ دقیقه — این RPO را تضمین می‌کند. برای RTO یک‌ساعته روی یک ترابایت، restore کامل احتمالاً کافی نیست، پس یا تعداد diff را زیاد می‌کنم (هر ۶ ساعت) تا زنجیرهٔ log کوتاه شود، یا — ترجیح من — یک availability group با replica همگام می‌گذارم تا failover چند ثانیه‌ای شود و backup برای فاجعهٔ منطقی بماند.


۱۳. لاگ تراکنش: رشد، shrink و VLF

فایل لاگ داخلاً به قطعات مجازی (VLF) تقسیم می‌شود. هر backup لاگ بخش‌های غیرفعال را برای بازاستفاده آزاد می‌کند (truncate منطقی، نه کوچک شدن فایل). وضعیت را با sys.databases.log_reuse_wait_desc، sys.dm_db_log_space_usage و sys.dm_db_log_info(DB_ID('app')) (شمارش VLF، از ۲۰۱۷) ببین.

shrink کردن لاگ تقریباً همیشه اشتباه است

DBCC SHRINKFILE وسوسه‌انگیز است ولی: (۱) اگر لاگ بزرگ شده کارش همین بوده — دوباره رشد می‌کند و در حین رشد تراکنش‌ها منتظر می‌مانند، چون فایل لاگ از instant file initialization بهره نمی‌برد و باید صفر شود؛ (۲) چرخهٔ shrink/grow صدها VLF کوچک می‌سازد که recovery و backup لاگ را کند می‌کند؛ (۳) SHRINKDATABASE روی فایل داده بدتر است و fragmentation ایندکس‌ها را به بالاترین حد می‌رساند.

کار درست: اول log_reuse_wait_desc را ببین تا بفهمی چرا لاگ آزاد نمی‌شود — LOG_BACKUP، ACTIVE_TRANSACTION، AVAILABILITY_REPLICA یا REPLICATION. علت را حل کن، بعد لاگ را یک بار به اندازهٔ درست تنظیم کن، autogrowth را مقدار ثابت معقول بگذار (نه درصدی)، و برای فایل‌های داده instant file initialization را با مجوز «Perform Volume Maintenance Tasks» فعال کن.


۱۴. نگه‌داری ایندکس و آمار

REINDEX INDEX CONCURRENTLY ix_orders_cust;
ANALYZE orders;

قاعدهٔ سنتی «زیر ۵٪ هیچ، ۵ تا ۳۰٪ reorganize، بالای ۳۰٪ rebuild» امروز ساده‌انگارانه است: روی SSD، fragmentation منطقی تأثیر کمی دارد و به‌روزرسانی آمار معمولاً بیشتر از rebuild فایده می‌رساند (REBUILD آمار را با اسکن کامل تازه می‌کند، REORGANIZE اصلاً نمی‌کند). نگه‌داری را روی جدول‌های واقعاً بزرگ و پرتغییر متمرکز کن و ONLINE = ON و RESUMABLE = ON را استفاده کن.


۱۵. امنیت

login کارت ورود به ساختمان (instance) است، user کلید یک اتاق (database)، role دستهٔ کلیدهای یک نقش، و schema قفسه‌بندی داخل اتاق. اگر database را restore کنی و login مقصد نباشد، user «یتیم» می‌شود: کلید هست، کارت نیست.

CREATE ROLE app_login LOGIN PASSWORD 'x';
CREATE ROLE app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT app_rw TO app_login;

به اپلیکیشن هرگز sysadmin یا db_owner نده؛ نقش سفارشی با مجوز سطح schema بده تا جدول‌های جدید خودکار پوشش داده شوند. لایه‌های محافظت داده: TDE (رمزنگاری شفاف در حالت سکون)، Always Encrypted (رمزنگاری سمت کلاینت)، Row-Level Security (CREATE SECURITY POLICY ... ADD FILTER PREDICATE) و Dynamic Data Masking (ADD MASKED WITH (FUNCTION = 'email()')).

TDE و Always Encrypted دو چیز کاملاً متفاوت‌اند

TDE فقط دادهٔ در حالت سکون را محافظت می‌کند: اگر کسی فایل .mdf یا backup را بدزدد بی‌فایده است، ولی هر کسی که به database وصل شود همه‌چیز را متن روشن می‌بیند — و گواهی TDE را باید جدا backup بگیری وگرنه backupهایت غیرقابل‌بازیابی‌اند. Always Encrypted رمزگذاری را در درایور کلاینت انجام می‌دهد و سرور فقط بایت می‌بیند؛ قیمتش سنگین است: با ENCRYPTION_TYPE = RANDOMIZED هیچ فیلتری روی آن ستون ممکن نیست، با DETERMINISTIC فقط تساوی و join کار می‌کند (و مقدار یکسان همیشه سایفر یکسان می‌دهد که به تحلیل فراوانی آسیب‌پذیر است)؛ برای فیلتر بازه‌ای باید سراغ secure enclaves بروی. اصول رمزنگاری در crypto-foundations و مدیریت کلید در key-management-secrets.


۱۶. دسترس‌پذیری بالا و بازیابی از فاجعه

گزینه واحد محافظت failover خودکار استفادهٔ ثانویه نکته
Always On AG گروهی از database‌ها بله (با WSFC، حالت همگام) خواندنی انتخاب پیش‌فرض امروز
Failover Cluster Instance کل instance (استوریج مشترک) بله ندارد از خرابی سرور محافظت می‌کند نه استوریج
Log shipping هر database جدا خیر (دستی) خواندنی با قطعی ساده، ارزان، RPO در حد دقیقه
Replication جدول/مقاله خیر نوشتنی (بسته به نوع) ابزار توزیع داده، نه HA
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (s1, s2)';
SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;

دیاگرام: توپولوژی availability group | Diagram: availability group topology

flowchart TD
  APP[Application] -->|listener DNS + port| LSN[AG Listener]
  LSN -->|ReadWrite intent| P[Primary replica]
  LSN -->|ApplicationIntent=ReadOnly| S1[Secondary sync, readable]
  P -->|log blocks, synchronous| S1
  P -->|log blocks, asynchronous| S2[Secondary in DR site]
  WSFC[WSFC quorum + witness] --- P
  WSFC --- S1

سلامت replicaها را با sys.dm_hadr_database_replica_states (log_send_queue_size, redo_queue_size) و ایندکس‌های بی‌مصرف را با sys.dm_db_index_usage_stats و تأخیر دیسک را با sys.dm_io_virtual_file_stats مانیتور کن.

«AG همگام یا ناهمگام؟ و AG جای backup را می‌گیرد؟»

همگام یعنی commit روی primary تا وقتی رکورد لاگ روی ثانویه سخت‌نویسی نشود تمام نمی‌شود: RPO صفر و failover خودکار ممکن است، ولی هر commit تأخیر شبکه را می‌پردازد — پس فقط برای replicaهای نزدیک منطقی است. ناهمگام برای سایت دور (DR) است: بدون جریمهٔ تأخیر، ولی failover دستی و با احتمال از دست رفتن داده. طراحی رایج: دو replica همگام محلی برای HA و یک ناهمگام در سایت دور برای DR. و نه، AG جایگزین backup نیست: خرابی منطقی (یک DELETE اشتباه) در چند میلی‌ثانیه به همهٔ replicaها می‌رسد؛ AG از خرابی سخت‌افزار محافظت می‌کند، backup از خرابی انسان.


۱۷. پیکربندی پایه و مانیتورینگ

پیکربندی پایه‌ای که در هر بررسی سلامت چک می‌شود: max server memory برابر کل RAM منهای ۴–۸ گیگابایت؛ cost threshold for parallelism بین ۲۵ تا ۵۰ (پیش‌فرض ۵ خیلی کم است)؛ MAXDOP حداکثر ۸ و حداکثر تعداد هستهٔ هر NUMA node؛ چند فایل هم‌اندازه برای tempdb؛ PAGE_VERIFY CHECKSUM؛ instant file initialization؛ Query Store روشن روی همهٔ database‌ها؛ و DBCC CHECKDB هفتگی.

EXEC sys.sp_configure 'cost threshold for parallelism', 50; RECONFIGURE;
ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 8;
ALTER DATABASE app SET ACCELERATED_DATABASE_RECOVERY = ON;            -- rollback آنی، 2019+
DBCC CHECKDB (app) WITH NO_INFOMSGS, ALL_ERRORMSGS, DATA_PURITY;

مانیتورینگ خوب یعنی چند سیگنال کم ولی معنادار: آخرین backup موفق هر database، فضای خالی دیسک داده/لاگ/tempdb، log_reuse_wait_desc، تأخیر replica، بلاک‌های طولانی و تعداد deadlock، تأخیر I/O هر فایل، و خطاهای شدت ۱۹+ کنار نتیجهٔ DBCC CHECKDB. و داده، لاگ، tempdb و backup را روی مسیرهای فیزیکی جدا بگذار.


۱۸. اتصال از Spring Boot: JDBC و JPA

spring.datasource.url=jdbc:sqlserver://sql01.corp.local:1433;databaseName=app;encrypt=true;trustServerCertificate=false;hostNameInCertificate=sql01.corp.local;sendStringParametersAsUnicode=false;applicationName=orders-api;loginTimeout=30;multiSubnetFailover=true
spring.datasource.username=app_login
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=20
spring.jpa.properties.hibernate.jdbc.batch_size=50

وابستگی Maven: com.microsoft.sqlserver:mssql-jdbc:13.4.0.jre11 (نسخهٔ jre11 برای Java 11 به بعد).

`sendStringParametersAsUnicode` گران‌ترین پیش‌فرضی است که نمی‌شناسی

پیش‌فرض درایور true است، یعنی هر setString به‌صورت nvarchar فرستاده می‌شود. اگر ستون varchar باشد، به‌خاطر قاعدهٔ تقدم نوع SQL Server ستون را به nvarchar تبدیل می‌کند، ایندکس بی‌فایده می‌شود و کوئری تک‌سطری‌ات به scan کامل تبدیل می‌شود (در پلن: CONVERT_IMPLICIT). اگر schema از varchar استفاده می‌کند sendStringParametersAsUnicode=false بگذار — ولی آن‌وقت برای ستون‌های nvarchar واقعی باید setNString استفاده شود. تمیزترین راه در پروژهٔ جدید: همه‌جا nvarchar و همان پیش‌فرض.

سه نکتهٔ دیگر: (۱) از درایور ۱۰.۲ به بعد encrypt پیش‌فرض true است — ارتقای درایور روی سروری بدون گواهی معتبر اتصال را می‌شکند؛ راه درست نصب گواهی است نه trustServerCertificate=true. مقدار encrypt=strict (از ۱۱.۲) یعنی TDS 8.0. (۲) loginTimeout پیش‌فرض از ۱۱.۲ برابر ۳۰ ثانیه است. (۳) برای اتصال به listener یک availability group multiSubnetFailover=true بگذار و برای هدایت گزارش‌ها به replica خواندنی applicationIntent=ReadOnly (پیش‌فرض ReadWrite).

نکات JPA/Hibernate مخصوص SQL Server (پایه‌اش در jpa-hibernate): GenerationType.IDENTITY دسته‌ای‌سازی insert را از بین می‌برد چون Hibernate هر INSERT را جدا می‌فرستد تا کلید تولیدشده را بگیرد؛ برای درج انبوه از SEQUENCE با allocationSize مناسب استفاده کن. برای تست هم با Testcontainers ایمیج رسمی mcr.microsoft.com/mssql/server:2022-latest را بالا بیاور، و در نگاشت نوع datetime2 و decimal(19,4) را انتخاب کن.

«چرا یک کوئری از اپلیکیشن کند است ولی همان کوئری در SSMS سریع؟»

کلاسیک‌ترین سؤال. چهار علت واقعی: (۱) تفاوت SET options: SSMS با ARITHABORT ON وصل می‌شود و درایورهای JDBC/ODBC معمولاً نه؛ چون مجموعهٔ SET بخشی از کلید کش پلن است، دو پلن جداگانه برای همان متن وجود دارد و اپلیکیشن ممکن است به پلن بد گیر کرده باشد. (۲) parameter sniffing: SSMS معمولاً با مقدار ثابت پلن تازه می‌گیرد. (۳) تبدیل ضمنی ناشی از sendStringParametersAsUnicode=true روی ستون varchar. (۴) مصرف نتیجه: زمان صرف کشیدن سطرها روی شبکه می‌شود نه اجرا (ASYNC_NETWORK_IO). روش تشخیص: پلن واقعی اپلیکیشن را از Query Store بگیر، نه اینکه در SSMS دوباره اجرا کنی.

«چه زمانی منطق را در stored procedure می‌گذاری و چه زمانی در اپلیکیشن؟»

معیار من حجم داده در برابر پیچیدگی دامنه است. اگر منطق یعنی «ده میلیون سطر را تجمیع کن و ده هزار سطر برگردان»، در دیتابیس بنویسش — انتقال داده گران‌ترین بخش است. اگر منطق یعنی قواعد کسب‌وکار، اعتبارسنجی و فراخوانی سرویس‌های دیگر، در اپلیکیشن بنویسش: نسخه‌بندی با Git، تست واحد، دیباگ و مقیاس افقی آنجا واقعی است. رویه‌ها هزینهٔ پنهان دارند: تست خودکارشان سخت است و مهاجرتشان باید با Flyway/Liquibase و CREATE OR ALTER مدیریت شود.

«فرق `TRUNCATE`، `DELETE` و `DROP` در SQL Server؟»

DELETE جمله‌ای DML است: سطر‌به‌سطر لاگ می‌شود، trigger را فعال می‌کند، WHERE می‌پذیرد و identity را reseed نمی‌کند. TRUNCATE عملیات DDL با لاگ حداقلی است: فقط تخصیص صفحات را آزاد می‌کند، بسیار سریع‌تر است، trigger را فعال نمی‌کند، WHERE ندارد و identity را به مقدار شروع برمی‌گرداند — ولی برخلاف باور عمومی قابل rollback است اگر داخل تراکنش باشد، و اگر جدول هدفِ یک foreign key باشد کار نمی‌کند.

جمع‌بندی

SQL Server در سطح senior یعنی سه مهارت گره‌خورده. گویش: TOP و OFFSET-FETCH، IDENTITY در برابر SEQUENCE، تلهٔ نوعِ ISNULL، کندی FORMAT، رفتار مجموعه‌ای trigger، و ابزارهایی مثل APPLY، OUTPUT و STRING_AGG — کنارِ تله‌های MERGE، توابع اسکالر و متغیرهای جدولی. کارایی: بگذار پلن و عدد حرف بزنند — اختلاف estimated/actual rows را ببین، Key Lookup را با INCLUDE بکش، کوئری را SARGable نگه دار، parameter sniffing را با آمار تازه و RECOMPILE و در ۲۰۲۲ با PSP optimization مهار کن، و Query Store را همیشه روشن نگه دار. مدیریت: recovery model و زنجیرهٔ full/differential/log مستقیماً RPO و RTO را می‌سازند، دم لاگ قبل از هر restore حیاتی است، shrink کردن لاگ تقریباً همیشه اشتباه است، RCSI مهم‌ترین سوئیچ همزمانی است، و availability group از خرابی سخت‌افزار محافظت می‌کند در حالی که backup از خرابی انسان. یک جمله برای مصاحبه: اول اندازه بگیر، بعد تغییر بده، و هر تغییر را با یک عدد قبل/بعد ثابت کن.

Plenty of backend job ads carry one short line next to Java and Spring: "advanced SQL Server". Behind it: writing T-SQL, reading execution plans, explaining why a 20 ms query now takes 20 seconds, and — with no dedicated DBA — owning backup/restore and knowing how many minutes of data a dead disk costs you.

sql-mastery, rdbms-tuning and oracle-postgres-dialects built the SQL foundation, the tuning mindset and two dialects (PostgreSQL and Oracle). This chapter adds the third dialect as a first-class citizen and then goes where those chapters do not: database administration — the work nobody sees while the system is up, and everybody needs the moment it goes down.

Roadmap

Architecture and versions → the T-SQL dialect (paging, IDENTITY/SEQUENCE, NULL, strings, dates) → temporary structures → APPLY, window functions, PIVOT, MERGE, OUTPUT, JSON → procedures, TRY/CATCH, XACT_ABORT, dynamic SQL, UDFs, triggers → performance (plans, indexes, statistics, parameter sniffing, DMVs, Query Store) → concurrency (isolation, RCSI, deadlocks, tempdb) → administration (backup/restore, RPO/RTO, the log, partitioning, security, HA/DR) → connecting from Spring Boot.


1. The big picture

An office building, not a room

Picture SQL Server as an office building: one instance (the whole building — one service, one memory pool, one set of settings) containing several databases (the floors). Logins, memory, CPU and tempdb belong to the building; users, schemas, tables and indexes belong to a floor.

Version Build Compat level Headline additions
2016 13.x 130 Query Store, Always Encrypted, JSON, temporal tables, RLS
2017 14.x 140 runs on Linux, STRING_AGG, TRIM, CONCAT_WS, adaptive joins
2019 15.x 150 scalar UDF inlining, table variable deferred compilation, Accelerated Database Recovery, memory-optimized tempdb metadata
2022 16.x 160 Query Store on by default, Parameter Sensitive Plan optimization, Query Store hints, GENERATE_SERIES, DATE_BUCKET, GREATEST/LEAST, contained AGs
2025 17.x 170 native json type and JSON index, native regex functions, vector type, ADR for tempdb, Query Store on secondary replicas
Engine version ≠ compatibility level

Restore a 2016 database onto a 2022 engine and the engine is new, but the compatibility level stays at 130 and a large share of optimizer features stays switched off.

-- PostgreSQL has no equivalent knob; optimizer behaviour ships with the engine version
SHOW server_version;

Senior rule: never raise the compatibility level without Query Store turned on first. Enable Query Store, capture a baseline, then raise the level, so a regressed plan can be forced back. (In Oracle the same job is done by OPTIMIZER_FEATURES_ENABLE and SQL Plan Baselines.)

Every step of the execution path has its own diagnostic tool: the plan cache through DMVs, the buffer pool through wait stats, the log through sys.dm_db_log_space_usage. Every database has at least one data file (.mdf, then .ndf) inside a filegroup plus one log file (.ldf). The transaction log is the accountant's journal: every change is written there before the data page changes (write-ahead logging), so after a power cut the engine can undo half-finished work.


2. The T-SQL dialect: the differences that burn you

Ninety percent of your standard SQL just works. The other ten percent either fails to compile, or — worse — compiles and quietly returns the wrong answer.

2.1 Paging

SELECT id, total FROM orders ORDER BY total DESC LIMIT 10;
SELECT id, total FROM orders ORDER BY id OFFSET 40 LIMIT 20;

TOP has two extras LIMIT lacks: TOP (10) PERCENT and TOP (10) WITH TIES. Oracle's equivalent is FETCH FIRST 10 ROWS ONLY.

`TOP` without `ORDER BY` means "any ten rows I happened to touch"

SELECT TOP (10) * FROM orders; guarantees nothing about which rows come back; today they arrive in clustered-key order, tomorrow a new index changes it — the classic "last 10 orders" report bug. For paging the ORDER BY must also be deterministic (include a unique key), otherwise rows get duplicated and skipped across pages. And OFFSET 100000 reads and discards a hundred thousand rows; the right pattern is keyset pagination, expanded by hand because SQL Server has no row-constructor comparison.

2.2 Auto-numbering: IDENTITY vs SEQUENCE

CREATE TABLE customer (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, full_name text);
CREATE SEQUENCE order_no_seq START 1000 INCREMENT 1 CACHE 50;
SELECT nextval('order_no_seq');

IDENTITY is welded to the column and you cannot supply values by hand without SET IDENTITY_INSERT customer ON;. A SEQUENCE is a standalone object, can be shared across tables, and its value can be fetched before the INSERT. (Oracle has had both since 12c.)

Three IDENTITY traps interviewers ask about.

  1. @@IDENTITY returns the last identity generated in the session — even one produced by a trigger on a different table, i.e. the wrong key. Always use SCOPE_IDENTITY(), or better, OUTPUT.
  2. Identity is not transactional; a rollback does not give the number back. If invoice numbers must be gap-free, identity is the wrong tool.
  3. After an abrupt shutdown the identity cache can jump by a thousand. If that matters, create a SEQUENCE with NO CACHE.

2.3 NULL, strings and dates

SELECT COALESCE(nickname, full_name, 'unknown') FROM customer;
`ISNULL` can silently truncate your string

ISNULL takes its result type from the first argument, while COALESCE derives it from data type precedence across all arguments:

DECLARE @v varchar(5) = NULL;
SELECT ISNULL(@v, 'default value');    -- 'defau'  <- silently truncated
SELECT COALESCE(@v, 'default value');  -- 'default value'

Second trap: 'a' + NULL is NULL, but CONCAT('a', NULL) is 'a' — the classic source of broken full names and addresses. And for non-Latin data always use nvarchar and always the Unicode literal prefix N'...'; without it, an unsuitable collation turns characters into ?.

Task PostgreSQL SQL Server Oracle
concatenate a || b a + b or CONCAT(a,b) a || b
length length(s) LEN(s) (ignores trailing spaces) / DATALENGTH(s) bytes LENGTH(s)
now now() SYSDATETIME() / GETDATE() / SYSUTCDATETIME() SYSTIMESTAMP
add days now() + interval '7 day' DATEADD(day, 7, SYSDATETIME()) SYSDATE + 7
truncate to month date_trunc('month', t) DATETRUNC(month, t) (2022+) TRUNC(t,'MM')
end of month compute manually EOMONTH(t) LAST_DAY(t)
format to_char(t,'YYYY-MM-DD') CONVERT(varchar(10), t, 23) TO_CHAR(t,'YYYY-MM-DD')
safe conversion CAST(x AS int DEFAULT NULL ON CONVERSION ERROR) (PG 17+) TRY_CAST / TRY_CONVERT / TRY_PARSE ... DEFAULT NULL ON CONVERSION ERROR
string aggregate string_agg(s, ',' ORDER BY s) STRING_AGG(s, ',') WITHIN GROUP (ORDER BY s) LISTAGG(s, ',') WITHIN GROUP (ORDER BY s)
regex s ~ 'p' REGEXP_LIKE(s,'p') (2025+) REGEXP_LIKE(s,'p')

Two date and formatting traps.

FORMAT(t, 'yyyy-MM-dd') goes through the CLR, runs row by row and can be an order of magnitude slower than CONVERT with a style number. Display formatting belongs in the application layer.

"Name three important differences between T-SQL and standard SQL"

(1) Paging: TOP is not ANSI; the standard OFFSET-FETCH arrived in 2012 and always requires ORDER BY. (2) String concatenation: the operator is +, not ||, and any NULL makes the whole result NULL, whereas CONCAT does not. (3) Control flow in the language itself: in T-SQL you can write DECLARE @x int plus IF/WHILE in the same batch; in PostgreSQL you need a DO $$ ... $$ block or a function. On top of that, ISNULL is non-standard and identifiers are quoted with [brackets].


3. Temp tables, table variables, CTEs and TVPs

A temp table (#t) is a real scratch pad: created in tempdb, it has statistics and you can index it afterwards. A table variable (@t) is a small whiteboard: lighter, but the optimizer knows almost nothing about its contents. A CTE stores nothing at all; it is just a name for a subquery.

Property #temp @table CTE TVP
stored in tempdb tempdb (yes, not just RAM) nowhere tempdb
statistics / secondary indexes yes no no no
row estimate real 1 row (deferred since 2019) from the base query 1 row
affected by rollback yes no no
best used for medium/large sets consumed repeatedly a few dozen rows, logging inside a transaction that rolls back readability, recursion shipping a batch of rows from the application
A table variable is not "in memory" and it blinds the optimizer

Two myths: "@table lives in RAM and #temp on disk" (both are created in tempdb) and "table variables are faster" (true for 5 rows; for 500,000 rows the optimizer estimates 1 row, picks nested loops, and a 2-second query becomes a 20-minute one). SQL Server 2019 added table variable deferred compilation (level 150), which defers compilation until the variable is populated so real cardinality is visible — but there are still no column statistics. On older levels the escape hatch is OPTION (RECOMPILE) on the consuming statement.

One distinguishing detail: a CTE in SQL Server is never materialized — reference it three times and it executes three times.


4. APPLY: what PostgreSQL calls LATERAL

JOIN says "match up two lists". APPLY says "for each left row, run this subquery again with that row's values" — like walking to each customer's own shelf instead of fetching the whole warehouse.

SELECT c.id, o.id AS order_id FROM customer c
CROSS JOIN LATERAL (SELECT id FROM orders o WHERE o.customer_id = c.id
                    ORDER BY o.created_at DESC LIMIT 3) o;

CROSS APPLY behaves like INNER JOIN, OUTER APPLY like LEFT JOIN. Three places it genuinely wins: top-N per group (above — with an index on (customer_id, created_at DESC) only the first three rows of each group are read), calling a table-valued function per row, and removing repeated expressions via CROSS APPLY (SELECT o.total - o.discount AS net) calc(net).

A caution: APPLY almost always compiles to a nested loop. If the left side has ten million rows and the subquery has no suitable index, you scan ten million times.


5. Window functions, PIVOT and UNPIVOT

Window function syntax is identical across all three dialects and is covered in depth in sql-mastery; here is the SQL Server-specific trap.

The default frame is `RANGE`, not `ROWS`

If you supply ORDER BY but omit the frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. RANGE lumps peer rows together: two orders with the same created_at get the same running total, including both. RANGE also usually builds an on-disk worktable in tempdb, so it is slower.

Always write ROWS BETWEEN ... explicitly. This is precisely what a deep interviewer probes.

PIVOT is T-SQL only; PostgreSQL has no built-in equivalent and uses FILTER or crosstab from the tablefunc extension:

SELECT customer_id,
       count(*) FILTER (WHERE status = 'NEW')  AS "NEW",
       count(*) FILTER (WHERE status = 'PAID') AS "PAID"
FROM orders GROUP BY customer_id;

PIVOT columns must be fixed at compile time; a dynamic list means dynamic SQL and injection risk. Senior judgment: pivot in the reporting layer.


6. MERGE and OUTPUT

INSERT INTO product AS p (sku, name, price)
SELECT sku, name, price FROM staging_product
ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price;
Five `MERGE` facts interviewers are fishing for
  1. The semicolon is mandatory: a MERGE without a trailing ; raises error 10713.
  2. Without HOLDLOCK you have a race: the documentation states that when unique keys are both inserted and updated concurrently, HOLDLOCK (a synonym for SERIALIZABLE) prevents unique key violations; without it two concurrent sessions can both see "not matched" and both insert.

OUTPUT is the equivalent of RETURNING, but stronger: it exposes both inserted and deleted and can write straight into a table.

WITH upd AS (
  UPDATE orders SET status = 'PAID' WHERE id = $1 AND status = 'NEW'
  RETURNING id, status)
INSERT INTO order_audit (order_id, new_status) SELECT id, status FROM upd;
`OUTPUT` is the safe replacement for `SCOPE_IDENTITY()` on batch inserts

SCOPE_IDENTITY() returns a single value. If you insert 500 rows with one INSERT ... SELECT and need the generated keys, the only clean route is OUTPUT inserted.id INTO @newIds — exactly what a batch insert from the application needs. Fine print: OUTPUT ... INTO is restricted on tables with triggers or foreign keys, in which case the target must be a temp table.

"`MERGE` or a hand-written upsert? Which, and why?"

MERGE is a single atomic statement that joins source to target and runs one of the WHEN MATCHED / WHEN NOT MATCHED BY TARGET / WHEN NOT MATCHED BY SOURCE branches per row; it reads the data once and OUTPUT $action tells you what happened to each row. But without HOLDLOCK it is race-prone, and Microsoft's own documentation recommends separate statements for heavy, highly concurrent workloads. My choice: MERGE with HOLDLOCK for batch ETL and syncs in a controlled window; for high-traffic OLTP, "UPDATE, and if @@ROWCOUNT = 0 then INSERT" inside a transaction.


7. JSON

CREATE TABLE event (id bigserial PRIMARY KEY, payload jsonb NOT NULL);
CREATE INDEX ix_event_payload ON event USING GIN (payload jsonb_path_ops);
SELECT payload ->> 'orderId' FROM event WHERE payload @> '{"type":"ORDER_PAID"}';

SQL Server 2025 introduces a native json type stored as optimized binary rather than text, with a dedicated JSON index, plus JSON_CONTAINS, JSON_OBJECTAGG and JSON_ARRAYAGG (JSON_OBJECT/JSON_ARRAY arrived in 2022). On older versions the "PERSISTED computed column + index" pattern is the standard interview answer.


8. Stored procedures and procedural T-SQL

-- in PostgreSQL the same job is done by a PROCEDURE with an OUT parameter
CREATE OR REPLACE PROCEDURE app.place_order(IN p_total numeric, OUT p_order_id bigint)
LANGUAGE plpgsql AS $$ BEGIN
  INSERT INTO orders (total, status) VALUES (p_total, 'NEW') RETURNING id INTO p_order_id;
END $$;
Three habits that make procedural code professional

SET NOCOUNT ON: without it every DML statement sends a DONE_IN_PROC message over the wire, and some JDBC/ORM paths treat those messages as results and get confused around getMoreResults() — the classic symptom is "it works in SSMS but not from the application". CREATE OR ALTER (2016 SP1 onwards) keeps Flyway/Liquibase scripts clean and, unlike DROP + CREATE, preserves permissions.

8.1 Errors and transactions

A transaction is like a construction contract: either the whole building is delivered or nothing is. But there is one dangerous state: a transaction can become doomed — still open, still inside CATCH, yet nothing but ROLLBACK is possible.

-- in PL/pgSQL an EXCEPTION block plays the TRY/CATCH role
DO $$ BEGIN
  INSERT INTO orders (total, status) VALUES (100, 'NEW');
EXCEPTION WHEN unique_violation THEN RAISE EXCEPTION 'duplicate order';
END $$;

Diagram: transaction lifecycle and the doomed state | دیاگرام: چرخهٔ عمر تراکنش و حالت doomed

stateDiagram-v2
  [*] --> NoTransaction
  NoTransaction --> Active: BEGIN TRANSACTION
  Active --> Committed: COMMIT
  Active --> RolledBack: ROLLBACK
  Active --> Doomed: runtime error with XACT_ABORT ON
  Doomed --> RolledBack: ROLLBACK only
  Doomed --> Doomed: any DML fails with error 3930
`THROW` vs `RAISERROR`, and the lie of nested transactions

RAISERROR offers printf-style formatting (RAISERROR('order %d not found', 16, 1, @id)) but cannot rethrow the original error with its original number. THROW (since 2012) is always severity 16, aborts the batch, and with no arguments inside CATCH rethrows exactly the original error. New code should use only THROW.

The bigger trap: nested transactions are a lie. Two BEGIN TRANSACTION statements make @@TRANCOUNT = 2 but there is still only one transaction; the inner COMMIT merely decrements the counter, while a ROLLBACK without a savepoint name unwinds everything to the outermost level. The correct pattern is that each procedure only rolls back what it started: if @@TRANCOUNT > 0 issue SAVE TRANSACTION sp_step and in CATCH roll back only to that savepoint; otherwise open your own transaction and commit or roll it back yourself.

"What exactly does `SET XACT_ABORT ON` do, and why is it recommended?"

By default many runtime errors abort only the statement, and the batch carries on with the transaction still open — so you can complete half the work, hit an error, and then commit the half-finished result unknowingly. With SET XACT_ABORT ON any runtime error makes the whole transaction uncommittable and it is rolled back.

8.2 Dynamic SQL

EXECUTE format('SELECT count(*) FROM %I WHERE status = $1', p_table) INTO v_count USING p_status;

Three iron rules of dynamic SQL.

  1. Never concatenate a value; values are always sp_executesql parameters. EXEC('...' + @x) is an open door for injection (appsec-owasp). 2. Wrap identifiers in QUOTENAME() and pass them through an allow-list. 3. Replace EXEC(@sql) with sp_executesql: only the latter is parameterized, and because the statement text stays constant the plan is reused; concatenating values means a new plan per value and a polluted plan cache.

A security note: dynamic SQL inside a procedure breaks ownership chaining, so the caller needs direct permissions on the underlying tables; the correct fix is WITH EXECUTE AS OWNER or signing the procedure with a certificate.

8.3 UDFs, triggers and cursors

-- inline TVF: a single SELECT, no BEGIN/END; the optimizer expands it
CREATE OR ALTER FUNCTION app.orders_of(@customer_id bigint)
RETURNS TABLE
AS RETURN (SELECT id, total, created_at FROM dbo.orders WHERE customer_id = @customer_id);

-- by contrast a multi-statement TVF (`RETURNS @r TABLE ... BEGIN ... END`) builds and fills
-- a table and is a black box to the optimizer
A scalar function is a loop you cannot see

Before SQL Server 2019 a scalar UDF in a SELECT executed once per row in a separate context, forced the whole query to go serial, and its cost was invisible in the execution plan. SQL Server 2019 added scalar UDF inlining (level 150), which rewrites the function into an equivalent scalar subquery — but not every function qualifies (loops, WHILE, time-dependent functions and data modification all block it):

SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID('app.net_total');
CREATE OR ALTER FUNCTION app.f(...) RETURNS int WITH INLINE = OFF AS ...;   -- function level
ALTER DATABASE SCOPED CONFIGURATION SET TSQL_SCALAR_UDF_INLINING = OFF;     -- database level
SELECT ... OPTION (USE HINT ('DISABLE_TSQL_SCALAR_UDF_INLINING'));          -- statement level

The golden pattern: turn every scalar function that is only a calculation into a single-column inline TVF and call it with CROSS APPLY.

Triggers in SQL Server are set-based, not row-based: one UPDATE touching 1,000 rows fires the trigger once with 1,000 rows in inserted/deleted.

CREATE TRIGGER orders_audit AFTER UPDATE ON orders
  FOR EACH ROW EXECUTE FUNCTION trg_orders_audit();   -- row-level
CREATE OR ALTER TRIGGER dbo.trg_orders_audit ON dbo.orders AFTER UPDATE AS
BEGIN
  SET NOCOUNT ON;
  IF NOT UPDATE(status) RETURN;
  INSERT INTO dbo.order_audit (order_id, old_status, new_status, changed_at)
  SELECT i.id, d.status, i.status, SYSUTCDATETIME()
  FROM inserted i JOIN deleted d ON d.id = i.id
  WHERE i.status <> d.status;      -- set-based, not row-based
END;
A row-minded trigger is a time bomb

The most common bug is SELECT @id = id FROM inserted; — it grabs one arbitrary row and silently ignores the rest, until somebody runs a batch UPDATE. A trigger also runs inside the user's transaction, so its slowness becomes blocking, and a ROLLBACK inside it unwinds the whole user transaction; for auditing, temporal tables or Change Data Capture are cleaner.

"Why do scalar functions have such a bad reputation in SQL Server, and what do you use instead?"

Because until 2019 they ran once per row in a separate context, their cost never appeared in the plan, and their mere presence forced the query to run serially. Since 2019, at compatibility level 150, scalar UDF inlining rewrites many of them into equivalent subqueries — but not all qualify, and you should check sys.sql_modules.is_inlineable. My alternative: put the logic directly in the query, or in an inline table-valued function invoked with CROSS APPLY, because the optimizer expands it and there is no per-row penalty.


9. Performance: from the plan to the index

A plan is the map the optimizer drew for your query: estimated (without running it) and actual (after execution, with real row counts; Ctrl+M in SSMS). Read it right to left and check three things in order:

  1. The gap between Estimated Rows and Actual Rows. Estimated 1, actual 200,000 is the root cause: stale statistics, a table variable, a function, or parameter sniffing. Until you fix that, the rest is guesswork.
  2. Expensive operators: Key Lookup, Sort, Hash Match, Table Spool, and any Scan on a large table.
  3. Warnings (the yellow triangle): Implicit Conversion, No Join Predicate, Spill to tempdb (the memory grant was too small and the sort went to disk).
Four operators you must recognise at a glance

Index Seek walks the B-tree straight to the target rows — what you want. Index/Table Scan reads the whole structure; fine for 90% of the rows, a disaster for 10. Key Lookup means the nonclustered index lacked a needed column and went back to the clustered table once per row; the cure is INCLUDE. Nested Loops / Hash Match / Merge Join suit "small left side + index on the right", "both large and unordered" and "both already sorted".

9.1 Indexes

Every table is either a heap or has a clustered index, meaning the table itself is stored in clustered-key order on the leaf pages of a B-tree — so a table has exactly one clustered index. Nonclustered indexes are separate structures whose leaves carry the clustering key (or a RID in a heap) so they can get back to the row.

-- in PostgreSQL every index is secondary; physical ordering is a manual CLUSTER
CREATE INDEX ix_orders_cust ON orders (customer_id, created_at DESC) INCLUDE (total);
CREATE INDEX ix_orders_open ON orders (created_at) WHERE status = 'NEW';   -- partial index
SARGability: one function on a column kills the index

WHERE YEAR(created_at) = 2026 forces a scan; WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01' seeks. The nastiest variant is implicit conversion: if the column is varchar and you send an nvarchar parameter, data type precedence converts the column, and the index becomes useless — exactly what the JDBC driver does by default (we return to it below). It shows up in the plan as CONVERT_IMPLICIT.

9.2 Statistics and parameter sniffing

Statistics are a histogram of a column's value distribution, and they are the only thing the optimizer reasons from. Parameter sniffing means the engine sniffs the parameter on the first execution, builds the best plan for that value and caches it; with a skewed distribution (one customer with 2 orders, another with 2 million) that plan is imposed on everyone.

"It was fine last night, it isn't today" is almost always parameter sniffing

Symptoms: fast with parameter A, slow with B; or everything slows down after a restart, an index rebuild or a statistics update, because the plan recompiled and this time saw the "bad" parameter. The treatment kit, lightest first:

UPDATE STATISTICS dbo.orders WITH FULLSCAN;               -- first of all: fresh statistics
SELECT ... OPTION (RECOMPILE);                            -- a bespoke plan every time
SELECT ... OPTION (OPTIMIZE FOR (@customer_id = 42));     -- optimise for a representative value
SELECT ... OPTION (OPTIMIZE FOR UNKNOWN);                 -- use average density instead
EXEC sys.sp_query_store_set_hints @query_id = 39,
     @query_hints = N'OPTION(RECOMPILE)';                 -- no application change needed, 2022+

The structural fix: from SQL Server 2022 at compatibility level 160, Parameter Sensitive Plan optimization keeps several active plans in cache for one parameterized query and picks by the parameter's cardinality range — the strongest practical reason to move to 160.

9.3 The troubleshooting toolbox

Turn on SET STATISTICS IO ON; and SET STATISTICS TIME ON;. Logical reads (8 KB pages touched) is the best metric for comparing two versions of a query, because unlike elapsed time it does not depend on what else the server is doing.

What you want to know Tool
what is running now and what it waits on sys.dm_exec_requests + sys.dm_exec_sessions
the most expensive queries historically sys.dm_exec_query_stats (total_worker_time, total_logical_reads)
suggested indexes sys.dm_db_missing_index_details + _groups + _group_stats
locks and blocking sys.dm_tran_locks, sys.dm_os_waiting_tasks
log space and why it cannot be reused sys.dm_db_log_space_usage, sys.databases.log_reuse_wait_desc
plan history and regressions sys.query_store_query, _plan, _runtime_stats

Query Store is the flight recorder: it stores query text, plans, runtime statistics and waits inside the database itself and survives restarts, unlike the plan cache. Since SQL Server 2022 it is on by default in READ_WRITE mode for new databases.

Enable it with ALTER DATABASE app SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO, MAX_STORAGE_SIZE_MB = 2048); and pin a good plan with sys.sp_query_store_force_plan.

Do not apply "missing index" suggestions blindly

Those DMVs only say "one query would have been better with this index". They know nothing about existing indexes, write cost, disk space or overlap between suggestions, and they overstate impact; teams have created 40 suggested indexes and made their inserts three times slower. The right method: collect the suggestions, merge overlapping ones into one composite index, drop unused indexes found via sys.dm_db_index_usage_stats, and measure every change with Query Store.

"A query got slow in production. Walk me through what you do."

(1) Measure, do not guess: Query Store or sys.dm_exec_query_stats tells me whether it really is expensive and whether the plan regressed. (2) Look at waits: sys.dm_exec_requests.wait_type says whether it is CPU, I/O (PAGEIOLATCH_*), blocking (LCK_M_*) or memory (RESOURCE_SEMAPHORE). (3) Get the actual plan and compare estimated with actual rows. (4) If the gap is large: refresh statistics and investigate parameter sniffing. (5) If the plan is sane but reads are huge: build a covering index or make the predicate SARGable. The key point: measure first, change second — forcing a plan is a bandage, not a cure.


10. Concurrency: locking, isolation, deadlocks

SQL Server is lock-based by default rather than version-based — the biggest behavioural difference from PostgreSQL and Oracle. Under the default READ COMMITTED, readers take shared locks and writers block readers; under MVCC in PostgreSQL and Oracle, reads never block.

Isolation level dirty read non-repeatable phantom Mechanism
READ UNCOMMITTED possible yes yes no shared locks (NOLOCK)
READ COMMITTED (default) no yes yes short-lived shared locks
READ COMMITTED SNAPSHOT no yes yes row versions in tempdb, no read locks
REPEATABLE READ no no yes shared locks held to end of transaction
SNAPSHOT no no no snapshot as of transaction start + conflict detection
SERIALIZABLE no no no key-range locks
SHOW default_transaction_isolation;   -- PostgreSQL is MVCC by nature
RCSI is the most important concurrency switch you own — and it is not free

READ_COMMITTED_SNAPSHOT is off by default (unlike Azure SQL Database). Turn it on and READ COMMITTED reads the pre-change row version from the version store in tempdb instead of taking shared locks: reports stop blocking OLTP and the need for WITH (NOLOCK) largely disappears. The costs: 14 extra bytes per row for the version pointer (page splits, table growth); serious pressure on tempdb, where one long transaction can inflate the version store; and code that relied on blocking may behave differently. The change needs an exclusive database lock, so plan a maintenance window.

And WITH (NOLOCK) is not "make it faster", it is "ignore the rules": besides dirty reads it can return duplicated or missing rows when pages move during a scan.

A deadlock is two transactions each holding a lock the other wants. SQL Server detects the cycle, picks a victim (error 1205) and rolls its transaction back completely. The deadlock graph comes from the system_health extended events session, which is on by default.

Diagram: how a classic deadlock forms | دیاگرام: تشکیل یک deadlock کلاسیک

sequenceDiagram
  participant A as Session A
  participant O as orders row 1
  participant L as order_line row 9
  participant B as Session B
  A->>O: UPDATE, holds X lock
  B->>L: UPDATE, holds X lock
  A->>L: UPDATE, waits for B
  B->>O: UPDATE, waits for A
  Note over A,B: cycle detected, error 1205 kills one victim
"How do you write code that survives deadlocks and blocking?"

A deadlock is a transient error, so first: retry with exponential backoff, only for transient numbers (1205 deadlock, 1222 lock timeout). Then eliminate the cause: (a) every code path takes resources in the same order; (b) transactions stay short, with no HTTP calls or user think-time inside; (c) add the right index so an UPDATE seeks instead of scanning — a large share of deadlocks are really indexing problems; (d) if readers fight writers, enable RCSI; (e) for queue patterns use WITH (UPDLOCK, READPAST, ROWLOCK). On the Java side, retry outside the transaction boundary — see resilience and spring-data-tx.

tempdb is the shared bottleneck of the instance; the classic symptom is PAGELATCH_* waits on allocation pages (2:1:1 is PFS). The cure is multiple data files of equal size and equal autogrowth (start with one per core up to eight), plus, from SQL Server 2019, ALTER SERVER CONFIGURATION SET MEMORY_OPTIMIZED TEMPDB_METADATA = ON;.


11. Columnstore and partitioning

Columnstore stores data column-wise and compressed in row groups of up to 1,048,576 rows, and brings batch mode, where each operator call processes a batch of rows instead of one. On analytical tables it is a 10–100x difference; for single-row OLTP lookups it is the worst choice.

Partitioning is first of all a management tool, not a performance one; its real payoff is instant removal of old data with SWITCH instead of a million-row DELETE.

CREATE TABLE orders (id bigint, created_at date NOT NULL) PARTITION BY RANGE (created_at);
CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
ALTER TABLE orders DETACH PARTITION orders_2026_01;   -- instant removal

12. Backup and restore

RPO and RTO in plain language

RPO is "how many minutes of data I am willing to lose", and it directly sets your log backup interval: a log backup every 15 minutes means a 15-minute RPO. RTO is "how many minutes I am willing to be down", and it decides how many differentials you take and whether you need an availability group at all.

There are three backup types: full (the whole database), differential (only pages changed since the last full — not since the last differential), and log (all log records since the last log backup; only under the full or bulk-logged recovery model).

Recovery model Log backups Point-in-time restore Log growth Use for
SIMPLE none no; only to the last full/diff truncated automatically dev, rebuildable warehouses
FULL required to any moment grows forever without log backups almost every production system
BULK_LOGGED yes only if no bulk operation in that window stays small for bulk loads a temporary heavy-load window
-- PostgreSQL has no in-engine BACKUP statement; base backup + continuous WAL archiving
--   pg_basebackup -D /backup/base -Ft -z -X stream -c fast
ALTER SYSTEM SET archive_mode = on;
ALTER SYSTEM SET archive_command = 'test ! -f /wal/%f && cp %p /wal/%f';

Diagram: the point-in-time restore chain | دیاگرام: زنجیرهٔ بازیابی نقطه‌ای

flowchart LR
  F[Full backup 02:00] --> D[Differential 08:00]
  D --> L1[Log 10:00]
  L1 --> L2[Log 10:15]
  L2 --> T[Tail-log backup NORECOVERY]
  T --> R[RESTORE ... STOPAT 10:19 WITH RECOVERY]
Five backup mistakes that destroy a system
  1. Recovery model is FULL but nobody takes log backups — the log grows until the disk is full and the database stops. Either take log backups or switch to SIMPLE.
  2. A backup that has never been restored is not a backup. Rehearse restores on a separate server and record how long they take; that number is your real RTO.
  3. Skipping WITH CHECKSUM — page corruption gets copied silently into the backup.
  4. Forgetting the tail of the log: if the server still starts, take BACKUP LOG ... WITH NORECOVERY first, otherwise every transaction since the last log backup is gone.
"RPO 15 minutes, RTO one hour, on a one-terabyte database — what do you design?"

Recovery model FULL: a weekly full, a daily differential, and log backups every 15 minutes, which guarantees the RPO. For a one-hour RTO on a terabyte a full restore from disk probably will not fit, so either I take differentials more often to shorten the log chain, or — my preference — add an availability group with a synchronous replica so failover takes seconds and backups cover logical disasters.


13. The transaction log: growth, shrinking and VLFs

Internally the log file is divided into virtual log files (VLFs). Each log backup frees the inactive parts for reuse — a logical truncation, not a shrinking of the file. Inspect it with sys.databases.log_reuse_wait_desc, sys.dm_db_log_space_usage and sys.dm_db_log_info(DB_ID('app')) (VLF count, 2017+).

Shrinking the log is almost always wrong

DBCC SHRINKFILE is tempting, but: (1) if the log grew, that is because it needed to — it will grow again, and during growth transactions wait, because log files do not benefit from instant file initialization and must be zeroed; (2) the shrink/grow cycle creates hundreds of tiny VLFs that slow recovery and log backups.

The right move: read log_reuse_wait_desc first to learn why the log cannot be reused — LOG_BACKUP, ACTIVE_TRANSACTION, AVAILABILITY_REPLICA or REPLICATION. Fix the cause, size the log correctly once, set autogrowth to a fixed amount rather than a percentage, and enable instant file initialization for data files by granting the service account "Perform Volume Maintenance Tasks".


14. Index and statistics maintenance

REINDEX INDEX CONCURRENTLY ix_orders_cust;
ANALYZE orders;

The traditional rule — "under 5% do nothing, 5–30% reorganize, above 30% rebuild" — is simplistic today. On SSDs logical fragmentation matters little and updating statistics usually helps more than a rebuild (REBUILD refreshes statistics with a full scan, REORGANIZE does not touch them). Concentrate maintenance on the genuinely large, volatile tables and use ONLINE = ON with RESUMABLE = ON.


15. Security

A login is the badge that opens the building (the instance), a user is the key to one room (a database), a role is a bundle of keys for a job, and a schema is the shelving inside the room. Restore a database where the target login does not exist and the user is "orphaned": the key is there, the badge is not.

CREATE ROLE app_login LOGIN PASSWORD 'x';
CREATE ROLE app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT app_rw TO app_login;

Never give the application sysadmin or db_owner; create a custom role with schema-level permissions so new tables are covered automatically. The data-protection layers are TDE (transparent encryption at rest), Always Encrypted (client-side encryption), Row-Level Security (CREATE SECURITY POLICY ... ADD FILTER PREDICATE) and Dynamic Data Masking (ADD MASKED WITH (FUNCTION = 'email()')).

TDE and Always Encrypted solve completely different problems

TDE protects data at rest only: useless if somebody steals the .mdf or a backup file, but anyone who can connect still sees plaintext — and you must back up the TDE certificate separately or your backups become unrestorable. Always Encrypted encrypts in the client driver, so the server only ever sees bytes; the price is steep: with ENCRYPTION_TYPE = RANDOMIZED you cannot filter on the column at all, and with DETERMINISTIC only equality and joins work (identical values always produce identical ciphertext, which is open to frequency analysis). Range predicates need the secure enclaves variant (see crypto-foundations).


16. High availability and disaster recovery

Option Unit protected Automatic failover Secondary usable Note
Always On AG a group of databases yes (WSFC, synchronous mode) readable today's default choice
Failover Cluster Instance the whole instance (shared storage) yes no protects the server, not the storage
Log shipping each database separately no (manual) readable with interruptions simple, cheap, minute-level RPO
Replication table/article no writable (depending on type) a data distribution tool, not HA
ALTER SYSTEM SET synchronous_standby_names = 'FIRST 1 (s1, s2)';
SELECT client_addr, state, sync_state, replay_lag FROM pg_stat_replication;

Diagram: availability group topology | دیاگرام: توپولوژی availability group

flowchart TD
  APP[Application] -->|listener DNS + port| LSN[AG Listener]
  LSN -->|ReadWrite intent| P[Primary replica]
  LSN -->|ApplicationIntent=ReadOnly| S1[Secondary sync, readable]
  P -->|log blocks, synchronous| S1
  P -->|log blocks, asynchronous| S2[Secondary in DR site]
  WSFC[WSFC quorum + witness] --- P
  WSFC --- S1

Monitor replica health with sys.dm_hadr_database_replica_states (log_send_queue_size, redo_queue_size).

"Synchronous or asynchronous AG? And does an AG replace backups?"

Synchronous means a commit on the primary does not finish until the log record is hardened on the secondary: zero data loss and automatic failover, but every commit pays the network round trip — so it only makes sense for nearby replicas. Asynchronous is for the remote DR site: no latency penalty, manual failover, possible data loss. The common design is two local synchronous replicas for HA plus one remote asynchronous replica for DR. And no, an AG does not replace backups: logical corruption reaches every replica in milliseconds. An AG protects against hardware failure; backups protect against humans.


17. Baseline configuration and monitoring

The configuration checked in every health review: max server memory set to total RAM minus 4–8 GB; cost threshold for parallelism between 25 and 50 (the default of 5 is far too low); MAXDOP at most 8 and at most the cores per NUMA node; several equally sized tempdb data files; PAGE_VERIFY CHECKSUM; instant file initialization; Query Store everywhere; and a weekly DBCC CHECKDB.

EXEC sys.sp_configure 'cost threshold for parallelism', 50; RECONFIGURE;
ALTER DATABASE SCOPED CONFIGURATION SET LAST_QUERY_PLAN_STATS = ON;   -- 2019+
ALTER DATABASE app SET ACCELERATED_DATABASE_RECOVERY = ON;            -- instant rollback, 2019+
DBCC CHECKDB (app) WITH NO_INFOMSGS, ALL_ERRORMSGS, DATA_PURITY;

Good monitoring is a handful of meaningful signals, not a hundred dashboards: the last successful backup of each database, free space on data/log/tempdb volumes, log fullness and log_reuse_wait_desc, replica lag, blocking longer than n seconds and deadlock counts, per-file I/O latency, and severity 19+ errors alongside DBCC CHECKDB results. Keep data, log, tempdb and backups on separate physical paths.


18. Connecting from Spring Boot: JDBC and JPA

spring.datasource.url=jdbc:sqlserver://sql01.corp.local:1433;databaseName=app;encrypt=true;trustServerCertificate=false;hostNameInCertificate=sql01.corp.local;sendStringParametersAsUnicode=false;applicationName=orders-api;loginTimeout=30;multiSubnetFailover=true
spring.datasource.username=app_login
spring.datasource.password=${DB_PASSWORD}
spring.datasource.hikari.maximum-pool-size=20
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.SQLServerDialect

The Maven dependency is com.microsoft.sqlserver:mssql-jdbc:13.4.0.jre11 (the jre11 build covers Java 11 and later).

`sendStringParametersAsUnicode` is the most expensive default you do not know about

The driver default is true, so every setString is sent as nvarchar. If the column is varchar, data type precedence makes SQL Server convert the column, the index becomes useless, and a single-row lookup turns into a full scan (CONVERT_IMPLICIT in the plan). If the schema uses varchar, set sendStringParametersAsUnicode=false — but then genuinely nvarchar columns must be written with setNString.

Three more notes: (1) since driver 10.2 encrypt defaults to true, so upgrading against a server with no valid certificate breaks connections; the fix is installing a certificate, not trustServerCertificate=true. encrypt=strict (11.2+) means TDS 8.0. (2) To reach an availability group listener set multiSubnetFailover=true, and to route reports to a readable secondary use applicationIntent=ReadOnly (default ReadWrite).

SQL Server-specific JPA/Hibernate notes (fundamentals in jpa-hibernate): GenerationType.IDENTITY destroys insert batching, because Hibernate must send each INSERT separately to retrieve the generated key — for bulk inserts use a SEQUENCE with a sensible allocationSize. For tests, start the official mcr.microsoft.com/mssql/server:2022-latest image with Testcontainers, and in type mapping prefer datetime2 and decimal(19,4).

"Why is a query slow from the application but fast in SSMS?"

The most classic question. Four real causes: (1) different SET options: SSMS connects with ARITHABORT ON and JDBC/ODBC drivers usually do not; because the SET options are part of the plan cache key, there are two separate plans for the same text and the application may be stuck on the bad one. (2) Parameter sniffing: SSMS typically runs with a literal and gets a fresh plan. (3) Implicit conversion from sendStringParametersAsUnicode=true against a varchar column. (4) Result consumption: the time goes into dragging rows over the network, not execution (ASYNC_NETWORK_IO). How to tell: pull the application's actual plan from Query Store rather than re-running it in SSMS.

"When do you put logic in a stored procedure and when in the application?"

My criterion is data volume versus domain complexity. If the logic means "aggregate ten million rows and return ten thousand", write it in the database — moving data is the expensive part. If it means business rules, validation and calls to other services, write it in the application, where Git versioning, unit tests, debugging and horizontal scaling are real. My position: procedures as data-shaped tools (batch loads, heavy reports, archiving) yes; as the home of domain rules, no — the same argument as hexagonal-clean and ddd.

"What is the difference between `TRUNCATE`, `DELETE` and `DROP` in SQL Server?"

DELETE is DML: it logs row by row, fires triggers, accepts WHERE and does not reseed identity. TRUNCATE is a minimally logged DDL operation: it deallocates pages, is far faster, fires no triggers, takes no WHERE and resets identity to its seed — but contrary to popular belief it can be rolled back inside a transaction, and it fails if the table is referenced by a foreign key. DROP removes the object itself along with its permissions and indexes. Senior note: to empty a large table on a live system use none of them blindly — a batched DELETE loop or a partition SWITCH keeps logging and blocking under control.

Wrap-up

Senior-level SQL Server is three interlocking skills. The dialect: TOP and OFFSET-FETCH, IDENTITY versus SEQUENCE, the ISNULL type trap, the cost of FORMAT, set-based triggers, and tools like APPLY, OUTPUT and STRING_AGG — alongside the traps in MERGE, scalar functions and table variables. Performance: let the plan and the numbers talk — check estimated versus actual rows, kill Key Lookup with INCLUDE, keep predicates SARGable, tame parameter sniffing with fresh statistics, RECOMPILE and PSP optimization, and keep Query Store on. Administration: the recovery model and the full/differential/log chain are what actually produce your RPO and RTO, the tail-log backup comes before any restore, shrinking the log is almost always wrong, RCSI is the single most important concurrency switch, and an availability group protects you from hardware failure while backups protect you from humans. One sentence for the interview: measure first, change second, and prove every change with a before-and-after number.