Execution Plan: Adaptive Joins

How they work…

“I have a transactions table (~10M rows) and a card_banlist table (~1M rows).
I want every transaction that happened on a banned card after that card’s ban date.”

Setup some objects.

CREATE TABLE dbo.card_banlist
(
    ban_id       BIGINT       IDENTITY(1,1) PRIMARY KEY CLUSTERED,
    card_no      CHAR(16)     NOT NULL,
    denied_date  DATETIME2(0) NOT NULL,
    reason       VARCHAR(50)  NOT NULL
);

CREATE TABLE dbo.transactions
(
    transaction_id   BIGINT        IDENTITY(1,1) PRIMARY KEY CLUSTERED,
    card_no          CHAR(16)      NOT NULL,
    transaction_date DATETIME2(0)  NOT NULL,
    amount           DECIMAL(10,2) NOT NULL,
    status           VARCHAR(20)   NOT NULL
);

CREATE NONCLUSTERED INDEX IX_transactions_cardno_date
    ON dbo.transactions (card_no, transaction_date);

CREATE NONCLUSTERED INDEX IX_banlist_cardno_date
    ON dbo.card_banlist (card_no, denied_date);

The query we are using.

SELECT t.card_no, b.denied_date, t.transaction_date
FROM dbo.transactions t 
JOIN dbo.card_banlist b ON t.card_no = b.card_no
WHERE t.transaction_date > b.denied_date

The Execution Plan supplied by Erik Darling ( erikdarlingdata/PerformanceStudio: Free, open-source SQL Server execution plan analyzer — cross-platform GUI + CLI with 30 analysis rules, missing index detection, SSMS extension. Built-in MCP server for AI-assisted plan review. )

The way the operators flow and the decision to go left or right (Nested Loop or Hash Join).

Bitmap Arrays, why they are so handy to have and how the look.

Show me a bitmap! SQL Server gives no way to look inside its own bitmaps: no DMV, no trace flag. So I built a small one in T-SQL from your data: 32 bits, from the first 8 banned cards. The idea is the same as Opt_Bitmap1003, but it’s small enough to read, and SQL Server uses a different hash function.

1. Build side: each banned card sets one bit

The bit position is ABS(CHECKSUM(card_no)) % 32:

card_no           bit
4000000000431757    1
4000000001337565   19
4000000000909369   23
4000000000704448   24   ┐
4000000000425988   24   ├ three cards land on the same bit
4000000001183068   24   ┘
4000000002133303   29
4000000001812762   30

2. The bitmap itself

position  00000000001111111111222222222233
          01234567890123456789012345678901
bitmap    01000000000000000001000110000110

That’s the whole thing: 32 bits (4 bytes), with 6 bits set by 8 cards. It holds no card numbers, only “something hashed here”.

3. Probe side: each transaction checks one bit

card_no           bit  outcome
4000000000000012   14  bit 0: dropped
4000000000000031   15  bit 0: dropped
4000000000425988   24  bit 1: passes, real match
4000000000431757    1  bit 1: passes, real match
4000000000000108   24  bit 1: passes, FALSE POSITIVE  (shares bit 24)
4000000000000163   29  bit 1: passes, FALSE POSITIVE  (shares bit 29)

Across 200,020 transactions (the first 200,000, plus the 20 that belong to the 8 banned cards):

CheckedDroppedPassedReal matchesFalse positives
200,020140,278 (70%)59,7422059,722

With only 32 bits, almost everything that passes is a false positive. The hash join would then reject those 59,722 rows with a real card_no comparison, so the result is still correct, just with less work saved.

Size matters: compared with q1

  • Build keys: 1,000,000 banned cards.
  • False positives: 1,860 out of about 6,669,361 transactions on cards that aren’t banned, a rate of 0.028%. That’s the rows passing the Filter minus the 3,330,639 transactions on banned cards.
  • With my toy method: one hash and one bit per card would need about 3.6 billion bits (≈450 MB) to get that low. That’s more than the whole 362 MB memory grant.

So SQL Server’s bitmap must be built more cleverly than this demo. For example, a Bloom filter sets several bits per key and needs roughly 17 bits per key (≈2 MB) for that rate. I don’t know SQL Server’s exact internal design, so treat that as an explanation of why the rate is possible, not as how SQL Server does it.

Generate some sample data for both Tables.

/* Sample data for the hash-vs-merge join repro in database [issues].
   Deterministic (MD5 of a row number), so a reload gives identical data.
   - 3,000,000 distinct cards  : '4000' + 12-digit card id
   - dbo.transactions  10,000,000 rows, random card, random time 2024-09-01 .. 2026-09-01,
                        transaction_id assigned in date order
   - dbo.card_banlist   1,000,000 rows, every 3rd card (card_no unique BY DATA, not declared),
                        random denied_date in the same window
*/
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;
USE issues;

IF EXISTS (SELECT 1 FROM dbo.transactions) OR EXISTS (SELECT 1 FROM dbo.card_banlist)
BEGIN
    RAISERROR('Tables are not empty - load skipped.', 16, 1);
    RETURN;
END;

ALTER INDEX IX_transactions_cardno_date ON dbo.transactions DISABLE;
ALTER INDEX IX_banlist_cardno_date      ON dbo.card_banlist DISABLE;

DECLARE @t0 datetime2 = SYSDATETIME();

INSERT dbo.transactions WITH (TABLOCK) (card_no, transaction_date, amount, status)
SELECT card_no, transaction_date, amount, status
FROM (
    SELECT
        card_no = CAST('4000' + RIGHT(CONCAT('000000000000',
                    (CAST(SUBSTRING(h, 1, 4) AS int) & 2147483647) % 3000000 + 1), 12) AS char(16)),
        transaction_date = DATEADD(SECOND,
                    (CAST(SUBSTRING(h, 5, 4) AS int) & 2147483647) % 63072000,
                    CAST('2024-09-01' AS datetime2(0))),
        amount = CAST(((CAST(SUBSTRING(h, 9, 4) AS int) & 2147483647) % 50000) / 100.0 + 1 AS decimal(10,2)),
        status = CASE WHEN CAST(SUBSTRING(h, 13, 1) AS int) % 100 < 90 THEN 'approved'
                      WHEN CAST(SUBSTRING(h, 13, 1) AS int) % 100 < 98 THEN 'declined'
                      ELSE 'reversed' END
    FROM (SELECT h = HASHBYTES('MD5', CAST(value AS binary(8)))
          FROM GENERATE_SERIES(CAST(1 AS bigint), CAST(10000000 AS bigint))) s
) x
ORDER BY transaction_date;

PRINT CONCAT('transactions loaded in ', DATEDIFF(SECOND, @t0, SYSDATETIME()), ' s');
SET @t0 = SYSDATETIME();

INSERT dbo.card_banlist WITH (TABLOCK) (card_no, denied_date, reason)
SELECT card_no, denied_date, reason
FROM (
    SELECT
        card_no = CAST('4000' + RIGHT(CONCAT('000000000000', value), 12) AS char(16)),
        denied_date = DATEADD(SECOND,
                    (CAST(SUBSTRING(h, 1, 4) AS int) & 2147483647) % 63072000,
                    CAST('2024-09-01' AS datetime2(0))),
        reason = CASE CAST(SUBSTRING(h, 5, 1) AS int) % 4
                      WHEN 0 THEN 'reported stolen'
                      WHEN 1 THEN 'reported lost'
                      WHEN 2 THEN 'suspected fraud'
                      ELSE 'chargeback limit exceeded' END
    FROM (SELECT value, h = HASHBYTES('MD5', CAST(value AS binary(8)) + 0x01)
          FROM GENERATE_SERIES(CAST(3 AS bigint), CAST(3000000 AS bigint), CAST(3 AS bigint))) s
) x
ORDER BY denied_date;

PRINT CONCAT('card_banlist loaded in ', DATEDIFF(SECOND, @t0, SYSDATETIME()), ' s');
SET @t0 = SYSDATETIME();

ALTER INDEX IX_transactions_cardno_date ON dbo.transactions REBUILD;
ALTER INDEX IX_banlist_cardno_date      ON dbo.card_banlist REBUILD;

PRINT CONCAT('indexes rebuilt in ', DATEDIFF(SECOND, @t0, SYSDATETIME()), ' s');
SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;
USE issues;

SELECT tbl = 'transactions', rows = COUNT_BIG(*), cards = COUNT(DISTINCT card_no),
       min_date = MIN(transaction_date), max_date = MAX(transaction_date)
FROM dbo.transactions
UNION ALL
SELECT 'card_banlist', COUNT_BIG(*), COUNT(DISTINCT card_no), MIN(denied_date), MAX(denied_date)
FROM dbo.card_banlist;

SELECT status, n = COUNT_BIG(*) FROM dbo.transactions GROUP BY status ORDER BY status;
SELECT reason, n = COUNT_BIG(*) FROM dbo.card_banlist GROUP BY reason ORDER BY reason;

SELECT txns_on_banned_cards = COUNT_BIG(*),
       txns_after_ban       = SUM(CASE WHEN t.transaction_date > b.denied_date THEN 1 ELSE 0 END)
FROM dbo.transactions t JOIN dbo.card_banlist b ON t.card_no = b.card_no;

SELECT TOP (3) * FROM dbo.transactions ORDER BY transaction_id;
SELECT TOP (3) * FROM dbo.card_banlist ORDER BY ban_id;

SELECT tbl = OBJECT_NAME(ps.object_id), idx = i.name, ps.row_count,
       used_mb = ps.used_page_count * 8 / 1024
FROM sys.dm_db_partition_stats ps
JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
WHERE ps.object_id IN (OBJECT_ID('dbo.transactions'), OBJECT_ID('dbo.card_banlist'))
ORDER BY 1, i.index_id;

SELECT file_name = name, size_mb = size * 8 / 1024 FROM sys.database_files;

Over en sluiten..

Geef een reactie

Je e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *