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 ثانویه |
وقتی یک database را از ۲۰۱۶ روی ۲۰۲۲ بازیابی میکنی، موتور جدید است ولی سطح سازگاری همان ۱۳۰ میماند و بخش بزرگی از قابلیتهای optimizer خاموش است.
-- PostgreSQL معادلی ندارد؛ رفتار optimizer با نسخهٔ موتور میآید
SHOW server_version;SELECT name, compatibility_level FROM sys.databases;
ALTER DATABASE app SET COMPATIBILITY_LEVEL = 160;قاعدهٔ 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;SELECT TOP (10) id, total FROM orders ORDER BY total DESC; -- مخصوص T-SQL
SELECT id, total FROM orders ORDER BY id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY; -- ANSI، از 2012TOP دو قابلیت اضافه دارد: TOP (10) PERCENT و TOP (10) WITH TIES. معادل Oracle: FETCH FIRST 10 ROWS ONLY.
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');CREATE TABLE customer (id bigint IDENTITY(1,1) PRIMARY KEY, full_name nvarchar(200));
CREATE SEQUENCE order_no_seq AS bigint START WITH 1000 INCREMENT BY 1 CACHE 50;
SELECT NEXT VALUE FOR 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;SELECT ISNULL(nickname, N'unknown') FROM customer; -- دقیقاً دو آرگومان، غیر ANSI
SELECT COALESCE(nickname, full_name, N'unknown') FROM customer; -- ANSIنوع خروجی 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 باشد. قالببندی نمایشی کارِ لایهٔ اپلیکیشن است.
(۱) صفحهبندی: 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 میشود | خوانایی، بازگشتی | ارسال دستهای سطر از اپلیکیشن |
دو باور غلط: «@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;SELECT c.id, o.id AS order_id FROM customer c
CROSS APPLY (SELECT TOP (3) id FROM orders o WHERE o.customer_id = c.id
ORDER BY o.created_at DESC) 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 عمیق پوشش داده شده؛ اینجا فقط تلهٔ مخصوص:
اگر 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، قابلحملتر و اغلب سریعتر: conditional aggregation
SELECT customer_id,
COUNT(CASE WHEN status = 'NEW' THEN 1 END) AS [NEW],
COUNT(CASE WHEN status = 'PAID' THEN 1 END) 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 INTO dbo.product WITH (HOLDLOCK) AS tgt
USING (SELECT sku, name, price FROM dbo.staging_product) AS src
ON tgt.sku = src.sku
WHEN MATCHED AND (tgt.name <> src.name OR tgt.price <> src.price)
THEN UPDATE SET tgt.name = src.name, tgt.price = src.price
WHEN NOT MATCHED BY TARGET
THEN INSERT (sku, name, price) VALUES (src.sku, src.name, src.price)
WHEN NOT MATCHED BY SOURCE AND tgt.is_active = 1
THEN UPDATE SET tgt.is_active = 0;۱. سمیکالن اجباری: 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;UPDATE dbo.orders SET status = N'PAID', paid_at = SYSUTCDATETIME()
OUTPUT inserted.id, deleted.status, inserted.status, SYSUTCDATETIME()
INTO dbo.order_audit (order_id, old_status, new_status, changed_at)
WHERE id = @id AND status = N'NEW';SCOPE_IDENTITY() فقط یک مقدار میدهد. اگر ۵۰۰ سطر را با یک INSERT ... SELECT درج کنی و کلیدهای تولیدشده را بخواهی، تنها راه تمیز OUTPUT inserted.id INTO @newIds است — دقیقاً همان چیزی که در batch insert لازم میشود. نکتهٔ ریز: OUTPUT ... INTO روی جدول دارای trigger یا foreign key محدودیت دارد و مقصد باید جدول موقت باشد.
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"}';CREATE TABLE event (
id bigint IDENTITY PRIMARY KEY,
payload nvarchar(max) NOT NULL CONSTRAINT ck_event_json CHECK (ISJSON(payload) = 1));
-- تنها راه seek کردن تا قبل از 2025: ستون محاسبهشدهٔ PERSISTED + ایندکس معمولی
ALTER TABLE event ADD event_type AS JSON_VALUE(payload, '$.type') PERSISTED;
CREATE INDEX ix_event_type ON event(event_type);
SELECT JSON_VALUE(payload, '$.orderId') AS order_id, -- اسکالر
JSON_QUERY(payload, '$.customer') AS customer -- شیء/آرایه
FROM event WHERE JSON_VALUE(payload, '$.type') = N'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 $$;CREATE OR ALTER PROCEDURE app.place_order
@customer_id bigint, @total decimal(12,2), @order_id bigint OUTPUT
AS
BEGIN
SET NOCOUNT ON; -- جلوی پیامهای "n rows affected"
SET XACT_ABORT ON; -- هر خطای زمان اجرا کل تراکنش را rollback میکند
INSERT INTO dbo.orders (customer_id, total, status)
VALUES (@customer_id, @total, N'NEW');
SET @order_id = SCOPE_IDENTITY();
RETURN 0; -- کد بازگشتی فقط int و فقط برای وضعیت
END;
GO
DECLARE @id bigint;
EXEC app.place_order @customer_id = 42, @total = 199.90, @order_id = @id OUTPUT;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 $$;SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.orders (customer_id, total, status) VALUES (42, 100, N'NEW');
INSERT INTO dbo.order_line (order_id, sku, qty) VALUES (SCOPE_IDENTITY(), N'A', 1);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; -- -1 یعنی doomed، 1 یعنی قابل commit
INSERT INTO dbo.error_log (error_number, message, proc_name, occurred_at)
VALUES (ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_PROCEDURE(), SYSUTCDATETIME());
THROW; -- خطای اصلی را با همان شماره و شدت دوباره پرتاب میکند
END CATCH;دیاگرام: چرخهٔ عمر تراکنش و حالت 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
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 سازگار نیست.
بهطور پیشفرض بسیاری از خطاهای زمان اجرا فقط همان جمله را لغو میکنند و 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;DECLARE @sql nvarchar(max), @cnt int;
SET @sql = N'SELECT @cnt_out = COUNT(*) FROM ' + QUOTENAME(@table_name)
+ N' WHERE status = @status'; -- مقدار پارامتر است، فقط شناسه الحاق میشود
EXEC sys.sp_executesql @sql,
N'@status nvarchar(20), @cnt_out int OUTPUT',
@status = @status, @cnt_out = @cnt OUTPUT;سه قانون آهنین 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;
پرتکرارترین باگ: SELECT @id = id FROM inserted; — فقط یک سطر تصادفی را میگیرد و بقیه را بیصدا نادیده میگیرد، تا روزی که کسی یک UPDATE دستهای بزند. سه هشدار دیگر: trigger داخل تراکنش کاربر اجرا میشود و کندیاش مستقیماً blocking میسازد؛ ROLLBACK داخل trigger کل تراکنش کاربر را برمیگرداند؛ و برای audit، temporal table (SYSTEM_VERSIONING = ON) یا Change Data Capture جایگزین تمیزتری است.
چون تا قبل از ۲۰۱۹ برای هر سطر یک بار در 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 indexCREATE CLUSTERED INDEX cx_orders ON dbo.orders (id);
CREATE NONCLUSTERED INDEX ix_orders_cust ON dbo.orders (customer_id, created_at DESC)
INCLUDE (total, status)
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, FILLFACTOR = 90,
OPTIMIZE_FOR_SEQUENTIAL_KEY = ON); -- آخری از 2019
CREATE NONCLUSTERED INDEX ix_orders_open ON dbo.orders (created_at) WHERE status = N'NEW';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 یعنی موتور در اولین اجرا مقدار پارامتر را بو میکشد، پلن بهینه برای آن مقدار میسازد و کش میکند؛ اگر توزیع نامتوازن باشد (یک مشتری ۲ سفارش، یکی ۲ میلیون)، همان پلن به همه تحمیل میشود.
علائم: همان کوئری با پارامتر 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.
این DMVها فقط میگویند «اگر این ایندکس بود، این یک کوئری بهتر میشد». هیچ اطلاعی از ایندکسهای موجود، هزینهٔ نوشتن، فضای دیسک یا همپوشانی پیشنهادها ندارند و impact را اغراقآمیز گزارش میکنند؛ تیمهایی ۴۰ ایندکس پیشنهادی ساختهاند و INSERTشان سه برابر کند شده. روش درست: پیشنهادها را جمع کن، همپوشانها را در یک ایندکس مرکب ادغام کن، با sys.dm_db_index_usage_stats بیمصرفها را حذف کن، و هر تغییر را با Query Store قبل/بعد بسنج.
(۱) اندازهگیری نه حدس: از 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 استALTER DATABASE app SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
ALTER DATABASE app SET ALLOW_SNAPSHOT_ISOLATION ON; -- برای SNAPSHOT صریح
SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = 'app';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 خطای گذرا است، پس اول 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; -- حذف آنیCREATE CLUSTERED COLUMNSTORE INDEX ccx_fact_sales ON dbo.fact_sales;
CREATE PARTITION FUNCTION pf_month (date) AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01');
CREATE PARTITION SCHEME ps_month AS PARTITION pf_month ALL TO ([PRIMARY]);
CREATE TABLE dbo.orders (id bigint, created_at date NOT NULL) ON ps_month (created_at);
ALTER TABLE dbo.orders SWITCH PARTITION 2 TO dbo.orders_archive PARTITION 2; -- فراداده، آنی
ALTER PARTITION FUNCTION pf_month() SPLIT RANGE ('2026-04-01');
TRUNCATE TABLE dbo.orders WITH (PARTITIONS (2)); -- 2016+۱۲. پشتیبانگیری و بازیابی
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';ALTER DATABASE app SET RECOVERY FULL;
BACKUP DATABASE app TO DISK = 'B:\app_full.bak' WITH INIT, CHECKSUM, COMPRESSION, STATS = 5;
BACKUP DATABASE app TO DISK = 'B:\app_diff.bak' WITH DIFFERENTIAL, CHECKSUM, COMPRESSION;
BACKUP LOG app TO DISK = 'B:\app_log_1015.trn' WITH CHECKSUM, COMPRESSION;
-- بازیابی نقطهای: «ساعت ۱۰:۲۰ کسی WHERE را جا انداخت و کل جدول را UPDATE کرد»
BACKUP LOG app TO DISK = 'B:\app_tail.trn' WITH NORECOVERY, CHECKSUM; -- ۱) دم لاگ
RESTORE DATABASE app FROM DISK = 'B:\app_full.bak' WITH NORECOVERY, REPLACE;
RESTORE DATABASE app FROM DISK = 'B:\app_diff.bak' WITH NORECOVERY;
RESTORE LOG app FROM DISK = 'B:\app_log_1015.trn' WITH NORECOVERY;
RESTORE LOG app FROM DISK = 'B:\app_tail.trn'
WITH STOPAT = '2026-08-13T10:19:00', RECOVERY; -- ۲) قبل از فاجعهدیاگرام: زنجیرهٔ بازیابی نقطهای | 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]
۱. recovery model روی FULL است ولی backup لاگ گرفته نمیشود — لاگ تا پر شدن دیسک رشد میکند و database متوقف میشود؛ یا لاگ بگیر یا مدل را SIMPLE کن.
۲. backupی که هیچوقت restore نشده، backup نیست؛ بازیابی را دورهای روی سرور جدا تمرین کن و زمانش را ثبت کن — همان زمان، RTO واقعی توست.
۳. WITH CHECKSUM را جا انداختن؛ بدون آن خرابی صفحه بیصدا داخل backup کپی میشود. کنارش PAGE_VERIFY CHECKSUM و DBCC CHECKDB هفتگی.
۴. نگهداری همهٔ backupها روی همان دیسک و همان سرور؛ قاعدهٔ ۳-۲-۱ را رعایت کن.
۵. فراموش کردن دم لاگ: اگر سرور هنوز بالا میآید، اول BACKUP LOG ... WITH NORECOVERY بگیر، وگرنه همهٔ تراکنشهای بعد از آخرین backup لاگ رفتهاند.
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، از ۲۰۱۷) ببین.
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;ALTER INDEX ix_orders_cust ON dbo.orders REORGANIZE; -- سبک، آنلاین، قابل توقف
ALTER INDEX ALL ON dbo.orders REBUILD
WITH (ONLINE = ON, RESUMABLE = ON, MAXDOP = 4, DATA_COMPRESSION = PAGE);
UPDATE STATISTICS dbo.orders WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = ON;قاعدهٔ سنتی «زیر ۵٪ هیچ، ۵ تا ۳۰٪ 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;CREATE LOGIN app_login WITH PASSWORD = 'S3cure!Pass', CHECK_POLICY = ON;
CREATE USER app_user FOR LOGIN app_login WITH DEFAULT_SCHEMA = app;
CREATE ROLE app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::app TO app_rw;
ALTER ROLE app_rw ADD MEMBER app_user;
ALTER USER app_user WITH LOGIN = app_login; -- علاج user یتیم بعد از restoreبه اپلیکیشن هرگز 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 فقط دادهٔ در حالت سکون را محافظت میکند: اگر کسی فایل .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;CREATE AVAILABILITY GROUP ag_app
WITH (CLUSTER_TYPE = WSFC, CONTAINED) -- CONTAINED از SQL Server 2022
FOR DATABASE app
REPLICA ON
N'NODE1' WITH (ENDPOINT_URL = N'TCP://node1.corp.local:5022',
AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
SEEDING_MODE = AUTOMATIC, SECONDARY_ROLE (ALLOW_CONNECTIONS = READ_ONLY)); -- (replica دوم با همین الگو)دیاگرام: توپولوژی 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 مانیتور کن.
همگام یعنی 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 به بعد).
پیشفرض درایور 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) را انتخاب کن.
کلاسیکترین سؤال. چهار علت واقعی: (۱) تفاوت SET options: SSMS با ARITHABORT ON وصل میشود و درایورهای JDBC/ODBC معمولاً نه؛ چون مجموعهٔ SET بخشی از کلید کش پلن است، دو پلن جداگانه برای همان متن وجود دارد و اپلیکیشن ممکن است به پلن بد گیر کرده باشد. (۲) parameter sniffing: SSMS معمولاً با مقدار ثابت پلن تازه میگیرد. (۳) تبدیل ضمنی ناشی از sendStringParametersAsUnicode=true روی ستون varchar. (۴) مصرف نتیجه: زمان صرف کشیدن سطرها روی شبکه میشود نه اجرا (ASYNC_NETWORK_IO). روش تشخیص: پلن واقعی اپلیکیشن را از Query Store بگیر، نه اینکه در SSMS دوباره اجرا کنی.
معیار من حجم داده در برابر پیچیدگی دامنه است. اگر منطق یعنی «ده میلیون سطر را تجمیع کن و ده هزار سطر برگردان»، در دیتابیس بنویسش — انتقال داده گرانترین بخش است. اگر منطق یعنی قواعد کسبوکار، اعتبارسنجی و فراخوانی سرویسهای دیگر، در اپلیکیشن بنویسش: نسخهبندی با Git، تست واحد، دیباگ و مقیاس افقی آنجا واقعی است. رویهها هزینهٔ پنهان دارند: تست خودکارشان سخت است و مهاجرتشان باید با Flyway/Liquibase و CREATE OR ALTER مدیریت شود.
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.
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
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 |
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;SELECT name, compatibility_level FROM sys.databases;
ALTER DATABASE app SET COMPATIBILITY_LEVEL = 160;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;SELECT TOP (10) id, total FROM orders ORDER BY total DESC; -- T-SQL specific
SELECT id, total FROM orders ORDER BY id
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY; -- ANSI, since 2012TOP has two extras LIMIT lacks: TOP (10) PERCENT and TOP (10) WITH TIES. Oracle's equivalent is FETCH FIRST 10 ROWS ONLY.
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');CREATE TABLE customer (id bigint IDENTITY(1,1) PRIMARY KEY, full_name nvarchar(200));
CREATE SEQUENCE order_no_seq AS bigint START WITH 1000 INCREMENT BY 1 CACHE 50;
SELECT NEXT VALUE FOR 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.
@@IDENTITYreturns the last identity generated in the session — even one produced by a trigger on a different table, i.e. the wrong key. Always useSCOPE_IDENTITY(), or better,OUTPUT.- Identity is not transactional; a rollback does not give the number back. If invoice numbers must be gap-free, identity is the wrong tool.
- After an abrupt shutdown the identity cache can jump by a thousand. If that matters, create a
SEQUENCEwithNO CACHE.
2.3 NULL, strings and dates
SELECT COALESCE(nickname, full_name, 'unknown') FROM customer;SELECT ISNULL(nickname, N'unknown') FROM customer; -- exactly two arguments, non-ANSI
SELECT COALESCE(nickname, full_name, N'unknown') FROM customer; -- ANSIISNULL 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.
(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 |
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;SELECT c.id, o.id AS order_id FROM customer c
CROSS APPLY (SELECT TOP (3) id FROM orders o WHERE o.customer_id = c.id
ORDER BY o.created_at DESC) 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.
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;-- T-SQL PIVOT: SELECT ... FROM src PIVOT (COUNT(status) FOR status IN ([NEW],[PAID])) p;
-- the more portable and often faster equivalent, conditional aggregation:
SELECT customer_id,
COUNT(CASE WHEN status = 'NEW' THEN 1 END) AS [NEW],
COUNT(CASE WHEN status = 'PAID' THEN 1 END) 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;MERGE INTO dbo.product WITH (HOLDLOCK) AS tgt
USING (SELECT sku, name, price FROM dbo.staging_product) AS src
ON tgt.sku = src.sku
WHEN MATCHED AND (tgt.name <> src.name OR tgt.price <> src.price)
THEN UPDATE SET tgt.name = src.name, tgt.price = src.price
WHEN NOT MATCHED BY TARGET
THEN INSERT (sku, name, price) VALUES (src.sku, src.name, src.price)
WHEN NOT MATCHED BY SOURCE AND tgt.is_active = 1
THEN UPDATE SET tgt.is_active = 0;- The semicolon is mandatory: a
MERGEwithout a trailing;raises error 10713. - Without
HOLDLOCKyou have a race: the documentation states that when unique keys are both inserted and updated concurrently,HOLDLOCK(a synonym forSERIALIZABLE) 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;UPDATE dbo.orders SET status = N'PAID', paid_at = SYSUTCDATETIME()
OUTPUT inserted.id, deleted.status, inserted.status, SYSUTCDATETIME()
INTO dbo.order_audit (order_id, old_status, new_status, changed_at)
WHERE id = @id AND status = N'NEW';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 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"}';CREATE TABLE event (
id bigint IDENTITY PRIMARY KEY,
payload nvarchar(max) NOT NULL CONSTRAINT ck_event_json CHECK (ISJSON(payload) = 1));
-- before 2025 the only way to seek: a PERSISTED computed column plus a normal index
ALTER TABLE event ADD event_type AS JSON_VALUE(payload, '$.type') PERSISTED;
CREATE INDEX ix_event_type ON event(event_type);
SELECT JSON_VALUE(payload, '$.orderId') AS order_id -- scalar; JSON_QUERY returns objects/arrays
FROM event WHERE JSON_VALUE(payload, '$.type') = N'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 $$;CREATE OR ALTER PROCEDURE app.place_order
@customer_id bigint, @total decimal(12,2), @order_id bigint OUTPUT
AS
BEGIN
SET NOCOUNT ON; -- suppresses the "n rows affected" messages
SET XACT_ABORT ON; -- any runtime error rolls the whole transaction back
INSERT INTO dbo.orders (customer_id, total, status)
VALUES (@customer_id, @total, N'NEW');
SET @order_id = SCOPE_IDENTITY();
RETURN 0; -- the return code is an int and is only for status
END;
GO
DECLARE @id bigint;
EXEC app.place_order @customer_id = 42, @total = 199.90, @order_id = @id OUTPUT;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 $$;SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO dbo.orders (customer_id, total, status) VALUES (42, 100, N'NEW');
INSERT INTO dbo.order_line (order_id, sku, qty) VALUES (SCOPE_IDENTITY(), N'A', 1);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; -- -1 means doomed, 1 means committable
INSERT INTO dbo.error_log (error_number, message, proc_name, occurred_at)
VALUES (ERROR_NUMBER(), ERROR_MESSAGE(), ERROR_PROCEDURE(), SYSUTCDATETIME());
THROW; -- rethrows the original error with its original number and severity
END CATCH;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
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.
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;DECLARE @sql nvarchar(max), @cnt int;
SET @sql = N'SELECT @cnt_out = COUNT(*) FROM ' + QUOTENAME(@table_name)
+ N' WHERE status = @status'; -- the value is a parameter; only the identifier is concatenated
EXEC sys.sp_executesql @sql,
N'@status nvarchar(20), @cnt_out int OUTPUT',
@status = @status, @cnt_out = @cnt OUTPUT;Three iron rules of dynamic SQL.
- Never concatenate a value; values are always
sp_executesqlparameters.EXEC('...' + @x)is an open door for injection (appsec-owasp). 2. Wrap identifiers inQUOTENAME()and pass them through an allow-list. 3. ReplaceEXEC(@sql)withsp_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
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;
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.
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:
- 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.
- Expensive operators:
Key Lookup,Sort,Hash Match,Table Spool, and anyScanon a large table. - Warnings (the yellow triangle):
Implicit Conversion,No Join Predicate,Spill to tempdb(the memory grant was too small and the sort went to disk).
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 indexCREATE CLUSTERED INDEX cx_orders ON dbo.orders (id);
CREATE NONCLUSTERED INDEX ix_orders_cust ON dbo.orders (customer_id, created_at DESC)
INCLUDE (total, status)
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE, FILLFACTOR = 90,
OPTIMIZE_FOR_SEQUENTIAL_KEY = ON); -- the last one is 2019+
CREATE NONCLUSTERED INDEX ix_orders_open ON dbo.orders (created_at) WHERE status = N'NEW';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.
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.
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.
(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 natureALTER DATABASE app SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
ALTER DATABASE app SET ALLOW_SNAPSHOT_ISOLATION ON; -- for explicit SNAPSHOT
SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = 'app';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
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 removalCREATE CLUSTERED COLUMNSTORE INDEX ccx_fact_sales ON dbo.fact_sales;
CREATE PARTITION FUNCTION pf_month (date) AS RANGE RIGHT FOR VALUES ('2026-01-01', '2026-02-01');
CREATE PARTITION SCHEME ps_month AS PARTITION pf_month ALL TO ([PRIMARY]);
CREATE TABLE dbo.orders (id bigint, created_at date NOT NULL) ON ps_month (created_at);
ALTER TABLE dbo.orders SWITCH PARTITION 2 TO dbo.orders_archive PARTITION 2; -- metadata only
ALTER PARTITION FUNCTION pf_month() SPLIT RANGE ('2026-04-01');
TRUNCATE TABLE dbo.orders WITH (PARTITIONS (2)); -- 2016+12. Backup and restore
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';ALTER DATABASE app SET RECOVERY FULL;
BACKUP DATABASE app TO DISK = 'B:\app_full.bak' WITH INIT, CHECKSUM, COMPRESSION, STATS = 5;
BACKUP DATABASE app TO DISK = 'B:\app_diff.bak' WITH DIFFERENTIAL, CHECKSUM, COMPRESSION;
BACKUP LOG app TO DISK = 'B:\app_log_1015.trn' WITH CHECKSUM, COMPRESSION;
-- point-in-time restore: "at 10:20 somebody forgot the WHERE and updated the whole table"
BACKUP LOG app TO DISK = 'B:\app_tail.trn' WITH NORECOVERY, CHECKSUM; -- 1) tail of the log
RESTORE DATABASE app FROM DISK = 'B:\app_full.bak' WITH NORECOVERY, REPLACE;
RESTORE DATABASE app FROM DISK = 'B:\app_diff.bak' WITH NORECOVERY;
RESTORE LOG app FROM DISK = 'B:\app_log_1015.trn' WITH NORECOVERY;
RESTORE LOG app FROM DISK = 'B:\app_tail.trn'
WITH STOPAT = '2026-08-13T10:19:00', RECOVERY; -- 2) just before the disasterDiagram: 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]
- Recovery model is
FULLbut nobody takes log backups — the log grows until the disk is full and the database stops. Either take log backups or switch toSIMPLE. - 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.
- Skipping
WITH CHECKSUM— page corruption gets copied silently into the backup. - Forgetting the tail of the log: if the server still starts, take
BACKUP LOG ... WITH NORECOVERYfirst, otherwise every transaction since the last log backup is gone.
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+).
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;ALTER INDEX ix_orders_cust ON dbo.orders REORGANIZE; -- light, online, interruptible
ALTER INDEX ALL ON dbo.orders REBUILD
WITH (ONLINE = ON, RESUMABLE = ON, MAXDOP = 4, DATA_COMPRESSION = PAGE);
UPDATE STATISTICS dbo.orders WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = ON;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;CREATE LOGIN app_login WITH PASSWORD = 'S3cure!Pass', CHECK_POLICY = ON;
CREATE USER app_user FOR LOGIN app_login WITH DEFAULT_SCHEMA = app;
CREATE ROLE app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::app TO app_rw;
ALTER ROLE app_rw ADD MEMBER app_user;
ALTER USER app_user WITH LOGIN = app_login; -- fixes an orphaned user after a restoreNever 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 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;CREATE AVAILABILITY GROUP ag_app
WITH (CLUSTER_TYPE = WSFC, CONTAINED) -- CONTAINED is SQL Server 2022+
FOR DATABASE app
REPLICA ON
N'NODE1' WITH (ENDPOINT_URL = N'TCP://node1.corp.local:5022',
AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
SEEDING_MODE = AUTOMATIC, SECONDARY_ROLE (ALLOW_CONNECTIONS = READ_ONLY));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 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).
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).
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.
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.
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.
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.