Exchange Spill: When Parallel Streams Write to tempdb

An exchange spill happens when a parallel query writes rows to tempdb because its threads got stuck waiting on each other. No sort ran out of memory, and no hash table was too big. The rows were simply on their way from one thread to another, and the buffers in between were full.

A dustpan collects spilled grain beside one of two intact neighboring strips

The export that got slow for no reason

A nightly export numbers every order line, sorted by customer. Most nights it takes a second. Some nights it takes ten. Memory is fine and the plan has no sort warning, yet tempdb shows writes during the run.

Those writes can come from the Parallelism operators, which move rows between threads in small packets. When the plan needs rows in a fixed order, an exchange must see the next row from every thread before it passes one on. If one thread has nothing ready, the exchange waits. The other threads fill their buffers and wait too. Now everybody waits in a circle, and SQL Server breaks it by writing the buffered rows to tempdb. That write is the exchange spill.

Build a table that spills every time

The demo uses 300 customers with 1,000 orders each, so 300,000 order rows. The notes columns are wide on purpose, because wide rows fill the buffers faster.

DROP TABLE IF EXISTS #Orders;
DROP TABLE IF EXISTS #Customers;

CREATE TABLE #Customers (CustomerId int NOT NULL PRIMARY KEY,
                         CustomerNotes char(400) NOT NULL);
CREATE TABLE #Orders (CustomerId int NOT NULL, OrderId int NOT NULL,
                      OrderNotes char(400) NOT NULL,
                      PRIMARY KEY (CustomerId, OrderId));

INSERT #Customers WITH (TABLOCK) (CustomerId, CustomerNotes)
SELECT value, 'customer' FROM GENERATE_SERIES(1, 300);

INSERT #Orders WITH (TABLOCK) (CustomerId, OrderId, OrderNotes)
SELECT c.value, o.value, 'order'
FROM GENERATE_SERIES(1, 300) AS c
CROSS JOIN GENERATE_SERIES(1, 1000) AS o;

SELECT COUNT(*) AS OrderRows FROM #Orders;

Next, a small event session that listens only for exchange_spill. The demo creates a server-level event session and removes it at the end.

IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'ExchangeSpillDemo')
    DROP EVENT SESSION ExchangeSpillDemo ON SERVER;

CREATE EVENT SESSION ExchangeSpillDemo ON SERVER
    ADD EVENT sqlserver.exchange_spill
    ADD TARGET package0.ring_buffer
    WITH (MAX_DISPATCH_LATENCY = 1 SECONDS);

ALTER EVENT SESSION ExchangeSpillDemo ON SERVER STATE = START;

Run the export query

The export numbers the rows by customer. The variables keep SSMS from drawing 300,000 rows. Two hints force a parallel merge join, a shape the optimizer would not pick for a table this small. MAXDOP 4 needs at least four cores. Give it several seconds.

DECLARE @Rows bigint, @Notes char(400);

SELECT @Rows = MAX(x.RowNum), @Notes = MAX(x.OrderNotes)
FROM (
    SELECT ROW_NUMBER() OVER (ORDER BY o.CustomerId) AS RowNum, o.OrderNotes
    FROM #Orders AS o
    INNER MERGE JOIN #Customers AS c ON c.CustomerId = o.CustomerId
) AS x
OPTION (MAXDOP 4, USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE'));

Read the events and name the operator

Now ask the session what it saw, grouped by the node id of the operator that spilled.

WAITFOR DELAY '00:00:02';
DECLARE @Events xml =
    (SELECT CAST(t.target_data AS xml)
     FROM sys.dm_xe_sessions AS s
     JOIN sys.dm_xe_session_targets AS t ON t.event_session_address = s.address
     WHERE s.name = N'ExchangeSpillDemo');

SELECT v.NodeId, COUNT(*) AS SpillEvents, SUM(v.Pages) AS PagesWritten
FROM @Events.nodes('/RingBufferTarget/event[@name="exchange_spill"]') AS n(e)
CROSS APPLY (SELECT
    n.e.value('(data[@name="query_operation_node_id"]/value)[1]', 'int') AS NodeId,
    n.e.value('(data[@name="worktable_physical_writes"]/value)[1]', 'bigint') AS Pages) AS v
GROUP BY v.NodeId
ORDER BY v.NodeId;

On my server two nodes spilled, 3 and 5, with several hundred pages in total, sometimes over a thousand. The counts change from run to run. Now match the node ids to the plan.

WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT r.value('@NodeId', 'int') AS NodeId,
       r.value('@PhysicalOp', 'varchar(30)') AS PhysicalOp,
       r.value('@LogicalOp', 'varchar(30)') AS LogicalOp,
       CASE WHEN r.exist('Parallelism/OrderBy') = 1 THEN 'yes' ELSE '' END AS KeepsOrder
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS p
CROSS APPLY p.query_plan.nodes('//RelOp') AS x(r)
WHERE t.text LIKE N'%INNER MERGE JOIN #Customers%'
  AND t.text NOT LIKE N'%dm_exec_query_stats%'
ORDER BY NodeId;

Node 3 is a Gather Streams and node 5 is a Repartition Streams. Both are Parallelism operators that keep order. Node 7 keeps order too, but it did not spill in my runs. So the writes came from exchanges, not from a Sort. With the actual plan turned on in SSMS, the spilling operators carry a warning.

Trace an exchange spill to its operator

Take away the order and the spill goes away

Now the same export with a hash join, and once more on a single thread. Then compare the three runs from the plan cache.

DECLARE @Rows bigint, @Notes char(400);

SELECT @Rows = MAX(x.RowNum), @Notes = MAX(x.OrderNotes)
FROM (SELECT ROW_NUMBER() OVER (ORDER BY o.CustomerId) AS RowNum, o.OrderNotes
      FROM #Orders AS o INNER HASH JOIN #Customers AS c ON c.CustomerId = o.CustomerId) AS x
OPTION (MAXDOP 4, USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE'));
GO
DECLARE @Rows bigint, @Notes char(400);

SELECT @Rows = MAX(x.RowNum), @Notes = MAX(x.OrderNotes)
FROM (SELECT ROW_NUMBER() OVER (ORDER BY o.CustomerId) AS RowNum, o.OrderNotes
      FROM #Orders AS o INNER MERGE JOIN #Customers AS c ON c.CustomerId = o.CustomerId) AS x
OPTION (MAXDOP 1);
GO
WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT CASE WHEN t.text LIKE N'%HASH JOIN%' THEN 'hash join, parallel'
            WHEN t.text LIKE N'%MAXDOP 1)%' THEN 'merge join, MAXDOP 1'
            ELSE 'merge join, parallel' END AS Version,
       qs.last_dop AS LastDop, qs.last_spills AS LastSpills,
       qs.last_elapsed_time / 1000 AS LastMs,
       p.query_plan.value('count(//Parallelism/OrderBy)', 'int') AS OrderedExchanges
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS p
WHERE t.text LIKE N'%RowNum%' AND t.text NOT LIKE N'%dm_exec_query_stats%'
ORDER BY Version;

The parallel merge join has three exchanges that keep order, hundreds of spilled pages, and an elapsed time in seconds. The hash join plan sorts inside each thread and gathers rows freely, so no exchange keeps order. It shows zero spills, and so does MAXDOP 1, which has no exchanges at all. Both finished in well under a second on my server.

What to check on your own server

The mistake I see is chasing memory: a bigger grant, faster tempdb disks, and nothing improves. An exchange spill is a plan shape problem, usually a merge join or a stream aggregate above exchanges that keep order.

Check last_spills in sys.dm_exec_query_stats for your slow parallel queries. If it is high and the plan shows no sort or hash warnings, capture exchange_spill for a few minutes and match the node id. Then test a narrow change on that one statement: a hash join, a better index or a lower MAXDOP. Leave the server setting alone. Clean up when you are done.

IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'ExchangeSpillDemo')
    DROP EVENT SESSION ExchangeSpillDemo ON SERVER;
DROP TABLE IF EXISTS #Orders;
DROP TABLE IF EXISTS #Customers;

Next time tempdb lights up during a parallel query, find the node id before you touch any memory setting.

An exchange spill is not a memory shortage, it is threads waiting on each other.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Execution Plan, Parallel, SQL Extended Events, SQL TempDB
Previous Post
SQL SERVER – NTFS File System Performance for SQL Server
Next Post
What Is a Cursor in SQL Server, and When to Avoid One

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.