How to speed up your Windows Update Server (WSUS) database
Sometimes our WSUS server displays the error – message below while performing some queries within the WSUS console.
Our SUSDB file in C:\Windows\WID\Data grew up to 50 GB during the last few years. Some maintenance tasks would help to speed up WSUS again. We implemented following solution and created a regular maintenance task for the WSUS database.
WSUS database maintenance
1. Create following directory C:\Scripts\WsusDBMaintenance
2. Add following files to the directory:
– msodbcsql_17.3.1.1_x64.msi
– MsSqlCmdLnUtils.msi
– vc_redist.x64.exe
– WsusDBMaintenance.bat
– WsusDBMaintenance.sql
Hint: Download all files in one ZIP archive here: WsusDBMaintenance
3. Install all required tools on your windows host (details see in WsusDBMaintenance.bat).
4. Add the WsusDBMaintenance.bat to the task scheduler and execute the script every month.
The maintenance script will do the following:
— Rebuild or reorganize indexes based on their fragmentation levels
— Select indexes that need to be defragmented based on the following
* Page density is low
* External fragmentation is high in relation to index size
— Update all statistics
Delete obsolete updates from the database
There is a script Microsoft often provides during premium support calls to cleanup this update metadata, however there are a few issues:
- The query can take a *really* long time to run if there are a lot of updates to cleanup. In some cases it can take *days*
- You need to stop all the WSUS services while it runs
- If it fails for whatever reason, it will have to start all over because it doesn’t commit the changes until it completes successfully
- While it runs, the TEMPDB and Transaction logs will grow quite significantly until the data is committed
- It gives no useful information on progress
To find out just how many updates are waiting to be cleaned up, run this stored procedure:
EXEC spGetObsoleteUpdatesToCleanup
Firstly, when the script runs on a default WSUS install it can take over a minute to process *each* record. If there are thousands or tens of thousands or updates to remove this is going to take a while. There is an index you can add to the WSUS table that dramatically improves this so it happens at about 1 second per record. Microsoft confirmed this index is OK, however it is not officially supported (at time of writing)
USE SUSDB
CREATE NONCLUSTERED INDEX [IX_tbRevisionSupersedesUpdate] ON [dbo].[tbRevisionSupersedesUpdate]([SupersededUpdateID])
CREATE NONCLUSTERED INDEX [IX_tbLocalizedPropertyForRevision] ON [dbo].[tbLocalizedPropertyForRevision]([LocalizedPropertyID])
Now to the cleanup script. Simply this script will cleanup obsolete records, provide progress feedback and also allow you to run it in small blocks. This allows you to run in short blocks without needing to stop the WSUS server and avoids generating huge transaction loads on the SQL server.
To “tweak” the script, modify this line with the number of updates you want to do in each block. Start with 50, see how it runs in your environment and increase as needed. Ideally don’t run batches that take more than 5-10 minutes to prevent those SQL transaction logs growing.
IF @curitem < 101
If you do want to run a larger batch that may take hours, you should of course stop the WSUS services to do so. Also, don’t run this script if a WSUS Sync is in progress or scheduled to start.
USE SUSDB
DECLARE @var1 INT, @curitem INT, @totaltodelete INT
DECLARE @msg nvarchar(200)
CREATE TABLE #results (Col1 INT) INSERT INTO #results(Col1)
EXEC spGetObsoleteUpdatesToCleanup
SET @totaltodelete = (SELECT COUNT(*) FROM #results)
SELECT @curitem=1
DECLARE WC Cursor FOR SELECT Col1 FROM #results
OPEN WC
FETCH NEXT FROM WC INTO @var1 WHILE (@@FETCH_STATUS > -1)
BEGIN SET @msg = cast(@curitem as varchar(5)) + '/' + cast(@totaltodelete as varchar(5)) + ': Deleting ' + CONVERT(varchar(10), @var1) + ' ' + cast(getdate() as varchar(30))
RAISERROR(@msg,0,1) WITH NOWAIT
EXEC spDeleteUpdate @localUpdateID=@var1
SET @curitem = @curitem +1
IF @curitem < 101
FETCH NEXT FROM WC INTO @var1
END
CLOSE WC
DEALLOCATE WC
DROP TABLE #results
If for any reason the script is interrupted, you will find SQL still has the transaction table open and won’t let you run again (There is already an object named ‘#results’ in the table). To resolve this highlight and execute the last line to drop the table. If this still doesn’t help, close the SQL Studio Manager session and you should be prompted with a warning about uncommitted transactions. Select Yes to commit then reopen and start the query again. If for any reason the query is not properly closed there may be locks held on the SQL database that will prevent the normal WSUS service functioning resulting in failure of service.
Shrink the database
Check if you see some tables with unused space. Run the query below to get a list of tables and its sizes – you should see the table tbXml with a lot of unused space.
USE SUSDB
SELECT
t.NAME AS TableName,
s.Name AS SchemaName,
p.rows, SUM(a.total_pages) * 8 AS TotalSpaceKB,
CAST(ROUND(((SUM(a.total_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS TotalSpaceMB,
SUM(a.used_pages) * 8 AS UsedSpaceKB,
CAST(ROUND(((SUM(a.used_pages) * 8) / 1024.00), 2) AS NUMERIC(36, 2)) AS UsedSpaceMB,
(SUM(a.total_pages) - SUM(a.used_pages)) * 8 AS UnusedSpaceKB,
CAST(ROUND(((SUM(a.total_pages) - SUM(a.used_pages)) * 8) / 1024.00, 2) AS NUMERIC(36, 2)) AS UnusedSpaceMB
FROM
sys.tables t
INNER JOIN
sys.indexes i ON t.OBJECT_ID = i.object_id
INNER JOIN
sys.partitions p ON i.object_id = p.OBJECT_ID AND i.index_id = p.index_id
INNER JOIN
sys.allocation_units a ON p.partition_id = a.container_id
LEFT OUTER JOIN
sys.schemas s ON t.schema_id = s.schema_id
WHERE
t.NAME NOT LIKE 'dt%' AND
t.is_ms_shipped = 0 AND
i.OBJECT_ID > 255
GROUP BY
t.Name,
s.Name,
p.Rows
ORDER BY
TotalSpaceMB DESC,
t.Name
After we have release some space in the database, we can go ahead and shrink our database – please be aware, this command takes a long time to run.
DBCC SHRINKDATABASE(SUSDB)
See the progress of the shrink process by using the query below.
SELECT
[status], start_time,
CONVERT(varchar,(total_elapsed_time/(1000))/60) + 'M ' + CONVERT(VARCHAR,(total_elapsed_time/(1000))%60) + 'S' AS [Elapsed],
CONVERT(varchar,(estimated_completion_time/(1000))/60) + 'M ' + CONVERT(VARCHAR,(estimated_completion_time/(1000))%60) + 'S' as [ETA],
command, [sql_handle], database_id, connection_id, blocking_session_id, percent_complete
FROM
sys.dm_exec_requests
WHERE
estimated_completion_time > 1
ORDER
by total_elapsed_time desc
After the database was shrinked we would like to shrink our database files as well. A clever way to do is documentet here.
USE SUSDB
DECLARE @FileName sysname = N'SQLShack';
DECLARE @TargetSize INT = (SELECT 1 + size*8./1024 FROM sys.database_files WHERE name = @FileName);
DECLARE @Factor FLOAT = .999;
WHILE @TargetSize > 0
BEGIN
SET @TargetSize *= @Factor;
DBCC SHRINKFILE(@FileName, @TargetSize);
DECLARE @msg VARCHAR(200) = CONCAT('Shrink file completed. Target Size: ',
@TargetSize, ' MB. Timestamp: ', CURRENT_TIMESTAMP);
RAISERROR(@msg, 1, 1) WITH NOWAIT;
WAITFOR DELAY '00:00:01';
END