PowerShell 技能连载 - Windows 更新自动化管理

适用于 PowerShell 5.1 及以上版本

操作系统补丁管理是企业安全运维的基石。未打补丁的系统是勒索软件和漏洞攻击的首要目标,每一次重大的安全事件背后几乎都有”未及时安装更新”的影子。然而,Windows 更新管理远比点击”检查更新”复杂得多——需要控制更新时机、筛选更新类别、处理重启策略、验证安装结果,并在多台服务器上保持一致性。

PowerShell 通过 Microsoft.Update.Session COM 对象和 PSWindowsUpdate 模块提供了完整的 Windows 更新编程接口。本文将介绍如何使用 PowerShell 实现 Windows 更新的查询、安装、回滚和自动化管理。

查询可用的 Windows 更新

在安装更新之前,先了解有哪些可用更新及其分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
function Get-WindowsAvailableUpdate {
<#
.SYNOPSIS
查询可用的 Windows 更新
.PARAMETER Category
更新类别过滤(Security、Important、Optional)
#>
[CmdletBinding()]
param(
[string]$Category
)

# 创建更新会话
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()

# 构建搜索条件
$searchCriteria = 'IsInstalled=0 and Type=''Software'''

Write-Verbose "搜索条件:$searchCriteria"
$searchResult = $updateSearcher.Search($searchCriteria)

Write-Verbose "找到 $($searchResult.Updates.Count) 个可用更新"

$updates = foreach ($update in $searchResult.Updates) {
# 确定更新类别
$categories = $update.Categories | ForEach-Object { $_.Name }
$isSecurity = $categories -contains 'Security Updates'
$isImportant = $categories -contains 'Important Updates'

$updateClass = if ($isSecurity) { 'Security' }
elseif ($isImportant) { 'Important' }
else { 'Optional' }

[PSCustomObject]@{
Title = $update.Title
KB = if ($update.KBArticleIDs.Count -gt 0) { $update.KBArticleIDs[0] } else { 'N/A' }
Category = $updateClass
Categories = ($categories -join ', ')
SizeMB = [math]::Round($update.MaxDownloadSize / 1MB, 2)
IsMandatory = $update.IsMandatory
IsDownloaded = $update.IsDownloaded
Description = $update.Description
Identity = $update.Identity.UpdateID
}
}

# 按类别过滤
if ($Category) {
$updates = $updates | Where-Object { $_.Category -eq $Category }
}

$updates | Sort-Object Category, Title
}

# 查询所有可用更新
$available = Get-WindowsAvailableUpdate -Verbose
$available | Format-Table Title, KB, Category, SizeMB -AutoSize

# 按类别统计
$available | Group-Object Category | Format-Table Name, Count -AutoSize

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
详细: 搜索条件:IsInstalled=0 and Type='Software'
详细: 找到 12 个可用更新

Title KB Category SizeMB
----- -- -------- ------
2025-08 Security Update for Windows Server KB5041234 Security 125.5
2025-08 Cumulative Update for .NET Framework KB5041235 Security 68.2
Windows Malicious Software Removal Tool KB890830 Important 45.0
.NET Runtime 8.0.8 Update KB5041236 Optional 32.1

Name Count
---- -----
Security 2
Important 1
Optional 9

下载并安装 Windows 更新

通过 COM 对象控制更新下载和安装过程,可以精确控制安装哪些更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
function Install-WindowsUpdate {
<#
.SYNOPSIS
下载并安装 Windows 更新
.PARAMETER Category
安装指定类别的更新
.PARAMETER KBArticleIDs
安装指定 KB 编号的更新
.PARAMETER AutoReboot
是否允许自动重启
#>
[CmdletBinding()]
param(
[string]$Category,

[string[]]$KBArticleIDs,

[switch]$AutoReboot
)

$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()

# 搜索可用更新
$searchResult = $updateSearcher.Search('IsInstalled=0 and Type=''Software''')

# 筛选要安装的更新
$updatesToInstall = New-Object -ComObject Microsoft.Update.UpdateColl

foreach ($update in $searchResult.Updates) {
$shouldInstall = $false

# 按 KB 编号筛选
if ($KBArticleIDs) {
foreach ($kb in $KBArticleIDs) {
if ($update.KBArticleIDs.Count -gt 0 -and $update.KBArticleIDs.Contains($kb)) {
$shouldInstall = $true
break
}
}
}
# 按类别筛选(默认安装安全更新)
elseif ($Category -eq 'Security') {
$cats = $update.Categories | ForEach-Object { $_.Name }
if ($cats -contains 'Security Updates') {
$shouldInstall = $true
}
}
else {
$shouldInstall = $true
}

if ($shouldInstall) {
# 接受 EULA
if ($update.EulaAccepted -eq $false) {
$update.AcceptEula()
}
[void]$updatesToInstall.Add($update)
Write-Verbose "添加更新:$($update.Title)"
}
}

if ($updatesToInstall.Count -eq 0) {
Write-Host '没有需要安装的更新' -ForegroundColor Yellow
return
}

Write-Host "准备安装 $($updatesToInstall.Count) 个更新" -ForegroundColor Cyan

# 下载更新
Write-Host '正在下载更新...' -ForegroundColor Yellow
$downloader = $updateSession.CreateUpdateDownloader()
$downloader.Updates = $updatesToInstall
$downloadResult = $downloader.Download()

$downloadStatus = switch ($downloadResult.ResultCode) {
0 { 'NotStarted' }
1 { 'InProgress' }
2 { 'Succeeded' }
3 { 'SucceededWithErrors' }
4 { 'Failed' }
5 { 'Aborted' }
default { 'Unknown' }
}

Write-Host "下载状态:$downloadStatus"

if ($downloadResult.ResultCode -notin @(2, 3)) {
throw "下载失败,状态码:$($downloadResult.ResultCode)"
}

# 安装更新
Write-Host '正在安装更新...' -ForegroundColor Yellow
$installer = $updateSession.CreateUpdateInstaller()
$installer.Updates = $updatesToInstall

if (-not $AutoReboot) {
# 禁止自动重启
$installer.AllowSourcePrompts = $false
}

$installResult = $installer.Install()

# 输出每个更新的安装结果
$results = for ($i = 0; $i -lt $updatesToInstall.Count; $i++) {
$resultCode = $installResult.GetUpdateResult($i).ResultCode
$status = switch ($resultCode) {
0 { 'NotStarted' }
1 { 'InProgress' }
2 { 'Installed' }
3 { 'InstalledWithErrors' }
4 { 'Failed' }
5 { 'Aborted' }
default { 'Unknown' }
}

[PSCustomObject]@{
Title = $updatesToInstall.Item($i).Title
KB = if ($updatesToInstall.Item($i).KBArticleIDs.Count -gt 0) { $updatesToInstall.Item($i).KBArticleIDs[0] } else { 'N/A' }
Status = $status
}
}

$results | Format-Table -AutoSize

# 检查是否需要重启
if ($installResult.RebootRequired) {
if ($AutoReboot) {
Write-Host '更新安装完成,正在重启系统...' -ForegroundColor Yellow
Restart-Computer -Force
} else {
Write-Host '更新安装完成,需要重启系统' -ForegroundColor Yellow
}
} else {
Write-Host '更新安装完成,无需重启' -ForegroundColor Green
}

return $results
}

# 只安装安全更新
$installResults = Install-WindowsUpdate -Category 'Security' -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
详细: 添加更新:2025-08 Security Update for Windows Server
详细: 添加更新:2025-08 Cumulative Update for .NET Framework
准备安装 2 个更新
正在下载更新...
下载状态:Succeeded
正在安装更新...

Title KB Status
----- -- ------
2025-08 Security Update for Windows Server KB5041234 Installed
2025-08 Cumulative Update for .NET Framework KB5041235 Installed
更新安装完成,需要重启系统

使用 PSWindowsUpdate 模块

PSWindowsUpdate 是社区维护的优秀模块,提供了更简洁的 Windows 更新管理接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 安装模块(仅首次需要)
Install-Module -Name PSWindowsUpdate -Force -Scope CurrentUser
Import-Module PSWindowsUpdate

# 查看可用更新
Get-WindowsUpdate -Verbose | Format-Table Title, KB, Size, Status -AutoSize

# 只安装安全更新
Get-WindowsUpdate -Category 'Security Updates' -AcceptAll -Install -AutoReboot -Verbose

# 排除特定 KB
Get-WindowsUpdate -NotKBArticleID 'KB5041234' -AcceptAll -Install -Verbose

# 查看安装历史
Get-WUHistory | Select-Object -First 10 | Format-Table Date, Title, Result -AutoSize

# 远程管理多台服务器的更新
$servers = @('SRV01', 'SRV02', 'SRV03')
Get-WindowsUpdate -ComputerName $servers -Category 'Security Updates' -AcceptAll -Install -Verbose

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Title                                         KB        Size     Status
----- -- ---- ------
2025-08 Security Update for Windows Server KB5041234 125.5 MB Downloaded
2025-08 .NET Framework Cumulative Update KB5041235 68.2 MB Downloaded

ComputerName Result KB Title
------------ ------ -- -----
SRV01 Accepted KB5041234 2025-08 Security Update for Windows Server
SRV01 Accepted KB5041235 2025-08 .NET Framework Cumulative Update
SRV02 Accepted KB5041234 2025-08 Security Update for Windows Server
SRV03 Accepted KB5041234 2025-08 Security Update for Windows Server

Date Title Result
---- ----- ------
2025/8/20 22:00:15 2025-08 Security Update for Windows Server Installed
2025/8/13 22:00:10 2025-08 .NET Framework Cumulative Update Installed
2025/8/6 22:00:08 2025-07 Security Update for Windows Server Installed

查询已安装的更新历史

了解系统上已安装了哪些更新,有助于审计和排查问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
function Get-WindowsUpdateHistory {
<#
.SYNOPSIS
查询 Windows 更新安装历史
.PARAMETER Days
查询最近多少天的记录
.PARAMETER Result
按安装结果过滤(Succeeded、Failed、All)
#>
[CmdletBinding()]
param(
[int]$Days = 30,

[ValidateSet('Succeeded', 'Failed', 'All')]
[string]$Result = 'All'
)

$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()

# 查询历史记录
$cutoffDate = (Get-Date).AddDays(-$Days)
$history = $updateSearcher.QueryHistory(0, $updateSearcher.GetTotalHistoryCount())

$results = foreach ($record in $history) {
if ($record.Date -lt $cutoffDate) { continue }

$operation = switch ($record.Operation) {
1 { 'Install' }
2 { 'Uninstall' }
default { 'Unknown' }
}

$resultCode = switch ($record.ResultCode) {
0 { 'NotStarted' }
1 { 'InProgress' }
2 { 'Succeeded' }
3 { 'SucceededWithErrors' }
4 { 'Failed' }
5 { 'Aborted' }
default { 'Unknown' }
}

# 提取 KB 编号
$kb = if ($record.Title -match 'KB(\d+)') { "KB$($Matches[1])" } else { 'N/A' }

[PSCustomObject]@{
Date = $record.Date
Title = $record.Title
KB = $kb
Operation = $operation
Result = $resultCode
HResult = $record.HResult
}
}

# 按结果过滤
if ($Result -eq 'Succeeded') {
$results = $results | Where-Object { $_.Result -eq 'Succeeded' }
} elseif ($Result -eq 'Failed') {
$results = $results | Where-Object { $_.Result -in @('Failed', 'SucceededWithErrors') }
}

$results | Sort-Object Date -Descending
}

# 查询最近 30 天的更新历史
$history = Get-WindowsUpdateHistory -Days 30
$history | Format-Table Date, KB, Operation, Result -AutoSize

# 只看失败的更新
Get-WindowsUpdateHistory -Days 90 -Result Failed | Format-Table Date, Title, Result -AutoSize

执行结果示例:

1
2
3
4
5
6
7
Date                KB        Operation Result
---- -- --------- ------
2025/8/20 22:00:15 KB5041234 Install Succeeded
2025/8/20 22:00:12 KB5041235 Install Succeeded
2025/8/13 22:00:10 KB5040987 Install Succeeded
2025/8/6 22:00:08 KB5040765 Install Succeeded
2025/7/30 22:00:05 KB5040543 Install Failed

卸载 Windows 更新

某些更新可能导致兼容性问题,需要回滚(卸载)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
function Uninstall-WindowsUpdateByKB {
<#
.SYNOPSIS
卸载指定的 Windows 更新
.PARAMETER KBArticleID
要卸载的 KB 编号
.PARAMETER AutoReboot
是否自动重启
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$KBArticleID,

[switch]$AutoReboot
)

# 使用 wusa 卸载更新
$wusaPath = Join-Path $env:SystemRoot 'System32\wusa.exe'

# 先查找已安装更新的 .msu 文件路径
$updateSession = New-Object -ComObject Microsoft.Update.Session
$updateSearcher = $updateSession.CreateUpdateSearcher()
$searchResult = $updateSearcher.Search("IsInstalled=1 and Type='Software'")

$targetUpdate = $null
foreach ($update in $searchResult.Updates) {
if ($update.KBArticleIDs.Count -gt 0 -and $update.KBArticleIDs.Contains($KBArticleID)) {
$targetUpdate = $update
break
}
}

if (-not $targetUpdate) {
Write-Warning "未找到已安装的更新:$KBArticleID"
return
}

Write-Verbose "找到更新:$($targetUpdate.Title)"
Write-Host "正在卸载:$($targetUpdate.Title)" -ForegroundColor Yellow

# 使用 DISM 卸载(更可靠)
$dismArgs = "/Online /Remove-Package /PackageName:$($targetUpdate.Identity.UpdateID) /NoRestart /Quiet"

$process = Start-Process -FilePath 'dism.exe' `
-ArgumentList $dismArgs `
-Wait `
-PassThru `
-NoNewWindow

$status = if ($process.ExitCode -eq 0) { 'Succeeded' } else { "Failed (Exit: $($process.ExitCode))" }

[PSCustomObject]@{
KB = $KBArticleID
Title = $targetUpdate.Title
Status = $status
RebootRequired = $process.ExitCode -eq 3010
}

if ($process.ExitCode -in @(0, 3010) -and $AutoReboot) {
Write-Host '卸载完成,正在重启...' -ForegroundColor Yellow
Restart-Computer -Force
}
}

# 卸载特定更新
$uninstallResult = Uninstall-WindowsUpdateByKB -KBArticleID 'KB5041234' -Verbose
$uninstallResult | Format-List

执行结果示例:

1
2
3
4
5
6
7
详细: 找到更新:2025-08 Security Update for Windows Server
正在卸载:2025-08 Security Update for Windows Server

KB : KB5041234
Title : 2025-08 Security Update for Windows Server
Status : Succeeded
RebootRequired : True

自动化补丁管理计划

将更新管理流程编排为可定期执行的计划任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
function Register-PatchSchedule {
<#
.SYNOPSIS
注册自动化补丁管理计划任务
.PARAMETER ScheduleDay
执行日期(每周几)
.PARAMETER ScheduleTime
执行时间
.PARAMETER Category
更新类别
.PARAMETER AutoReboot
是否自动重启
#>
[CmdletBinding()]
param(
[DayOfWeek]$ScheduleDay = 'Sunday',

[string]$ScheduleTime = '02:00',

[string]$Category = 'Security',

[switch]$AutoReboot
)

# 创建计划任务动作
$scriptBlock = @"
Import-Module PSWindowsUpdate
Get-WindowsUpdate -Category 'Security Updates' -AcceptAll -Install $(if ($AutoReboot) { '-AutoReboot' })
Write-Output "Patch cycle completed at $(Get-Date)" >> C:\Logs\WindowsUpdate\patch-cycle.log
"@

$scriptPath = 'C:\Scripts\Invoke-PatchCycle.ps1'
$scriptDir = Split-Path $scriptPath
if (-not (Test-Path $scriptDir)) {
New-Item -Path $scriptDir -ItemType Directory -Force | Out-Null
}
$scriptBlock | Set-Content $scriptPath -Encoding UTF8

# 注册计划任务
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $ScheduleDay -At $ScheduleTime
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -DontStopOnIdleEnd -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries

$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest

$taskParams = @{
TaskName = 'AutoPatchCycle'
Action = $action
Trigger = $trigger
Settings = $settings
Principal = $principal
Description = '每周自动安装 Windows 安全更新'
}

Register-ScheduledTask @taskParams -Force

Write-Host "计划任务已注册:AutoPatchCycle" -ForegroundColor Green
Write-Host "执行时间:每周$ScheduleDay $ScheduleTime" -ForegroundColor Cyan
Write-Host "更新类别:$Category" -ForegroundColor Cyan
Write-Host "自动重启:$AutoReboot" -ForegroundColor Cyan
}

# 注册每周日凌晨 2 点执行的安全更新任务
Register-PatchSchedule -ScheduleDay Sunday -ScheduleTime '02:00' -Category Security -AutoReboot

执行结果示例:

1
2
3
4
计划任务已注册:AutoPatchCycle
执行时间:每周Sunday 02:00
更新类别:Security
自动重启:True

注意事项

  • 补丁测试:生产服务器上不要第一时间安装新补丁。建议先在测试环境验证,确认无兼容性问题后再逐步推广到生产环境,形成”测试-预发布-生产”的分阶段部署流程。
  • 重启窗口:安全更新通常需要重启才能生效,应配置在业务低峰期(如凌晨)自动重启,避免影响在线服务。重启前确保所有应用服务已正确停止。
  • WSUS 配置:企业环境中通常使用 WSUS(Windows Server Update Services)或 SCCM 管理更新分发,PowerShell 脚本中的更新源会自动继承系统的 WSUS 配置,无需额外设置。
  • 回滚方案:某些更新可能导致应用程序兼容性问题,安装前应创建系统还原点(Checkpoint-Computer),确保可以快速回滚。
  • 日志审计:每次补丁操作都应记录详细日志,包括安装时间、更新编号、操作结果和操作者信息,以满足安全合规审计要求。
  • 模块兼容性PSWindowsUpdate 模块在不同 Windows 版本上的行为可能有差异,使用前应在目标系统上测试兼容性,必要时回退到 COM 对象方式。

PowerShell 技能连载 - 磁盘清理自动化

适用于 PowerShell 5.1 及以上版本

磁盘空间不足是运维中最常见的问题之一。日志文件堆积、临时文件未清理、回收站膨胀、Windows 更新缓存占用等问题,如果不加以管理,迟早会导致系统异常甚至服务中断。在容器化和虚拟化环境中,磁盘资源更是需要精打细算,自动化的磁盘清理机制必不可少。

手动清理磁盘既低效又容易遗漏,而且无法做到定期执行。通过 PowerShell 脚本可以精确定位各类可清理的目标,量化空间占用,并在安全的前提下自动执行清理操作。本文将介绍如何构建一个全面的磁盘清理自动化方案。

磁盘空间分析

在执行清理之前,需要先了解磁盘空间的使用情况以及哪些目录占用最多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function Get-DiskSpaceInfo {
<#
.SYNOPSIS
获取磁盘空间使用情况
.PARAMETER DriveLetter
盘符(默认获取所有驱动器)
#>
[CmdletBinding()]
param(
[string]$DriveLetter
)

$drives = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3'

if ($DriveLetter) {
$drives = $drives | Where-Object { $_.DeviceID -eq "$($DriveLetter):" }
}

foreach ($drive in $drives) {
$totalGB = [math]::Round($drive.Size / 1GB, 2)
$freeGB = [math]::Round($drive.FreeSpace / 1GB, 2)
$usedGB = [math]::Round(($drive.Size - $drive.FreeSpace) / 1GB, 2)
$usedPercent = [math]::Round(($drive.Size - $drive.FreeSpace) / $drive.Size * 100, 1)

[PSCustomObject]@{
Drive = $drive.DeviceID
TotalGB = $totalGB
UsedGB = $usedGB
FreeGB = $freeGB
UsedPercent = $usedPercent
Status = if ($usedPercent -gt 90) { 'Critical' }
elseif ($usedPercent -gt 80) { 'Warning' }
else { 'OK' }
}
}
}

# 查看所有驱动器空间
Get-DiskSpaceInfo | Format-Table -AutoSize

执行结果示例:

1
2
3
4
Drive TotalGB UsedGB FreeGB UsedPercent Status
----- ------- ------ ------ ----------- ------
C: 100 78.5 21.5 78.5 Warning
D: 500 210.3 289.7 42.1 OK

分析目录空间占用

找出哪些目录占用空间最多,为清理提供依据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function Get-FolderSize {
<#
.SYNOPSIS
计算指定目录的大小
.PARAMETER Path
目录路径
.PARAMETER Depth
遍历深度(默认 1)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,

[int]$Depth = 1
)

if (-not (Test-Path $Path)) {
Write-Warning "路径不存在:$Path"
return
}

$results = [System.Collections.Generic.List[PSObject]]::new()

# 计算根目录总大小
$rootFiles = Get-ChildItem -Path $Path -File -Recurse -Force -ErrorAction SilentlyContinue
$rootSize = ($rootFiles | Measure-Object -Property Length -Sum).Sum

# 计算各子目录大小
$subDirs = Get-ChildItem -Path $Path -Directory -Force -ErrorAction SilentlyContinue

foreach ($dir in $subDirs) {
$files = Get-ChildItem -Path $dir.FullName -File -Recurse -Force -ErrorAction SilentlyContinue
$size = ($files | Measure-Object -Property Length -Sum).Sum
$fileCount = ($files | Measure-Object).Count

$results.Add([PSCustomObject]@{
Folder = $dir.Name
SizeMB = [math]::Round($size / 1MB, 2)
FileCount = $fileCount
FullPath = $dir.FullName
})
}

# 按大小降序排列
$results | Sort-Object SizeMB -Descending
}

# 分析常见空间占用目录
$commonPaths = @(
@{ Name = '临时文件'; Path = $env:TEMP }
@{ Name = 'Windows 临时'; Path = 'C:\Windows\Temp' }
@{ Name = '回收站'; Path = 'C:\`$Recycle.Bin' }
@{ Name = 'Windows 更新'; Path = 'C:\Windows\SoftwareDistribution' }
)

foreach ($item in $commonPaths) {
Write-Host "`n--- $($item.Name) ---" -ForegroundColor Cyan
Get-FolderSize -Path $item.Path | Select-Object -First 5 | Format-Table -AutoSize
}

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--- 临时文件 ---

Folder SizeMB FileCount FullPath
------ ------ --------- --------
Logs 512.3 1245 C:\Users\admin\AppData\Local\Temp\Logs
Cache 256.8 890 C:\Users\admin\AppData\Local\Temp\Cache
Extraction 128.1 45 C:\Users\admin\AppData\Local\Temp\Extraction

--- Windows 临时 ---

Folder SizeMB FileCount FullPath
------ ------ --------- --------
UpdateStaging 890.5 3200 C:\Windows\Temp\UpdateStaging
InstallCache 345.2 678 C:\Windows\Temp\InstallCache

清理临时文件

临时文件是最常见的空间占用来源。以下函数可以安全清理各类临时文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
function Remove-TempFiles {
<#
.SYNOPSIS
清理临时文件
.PARAMETER Path
要清理的目录路径
.PARAMETER MaxAge
文件最大保留天数(默认 7 天)
.PARAMETER Extensions
只清理指定扩展名的文件
.PARAMETER WhatIf
仅显示将删除的文件,不实际删除
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string[]]$Path,

[int]$MaxAge = 7,

[string[]]$Extensions,

[switch]$Force
)

$cutoffDate = (Get-Date).AddDays(-$MaxAge)
$totalSize = 0
$totalFiles = 0

foreach ($targetPath in $Path) {
if (-not (Test-Path $targetPath)) {
Write-Verbose "路径不存在,跳过:$targetPath"
continue
}

Write-Verbose "扫描目录:$targetPath"

$files = Get-ChildItem -Path $targetPath -File -Recurse -Force -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoffDate }

if ($Extensions) {
$files = $files | Where-Object {
$Extensions -contains $_.Extension.ToLower()
}
}

foreach ($file in $files) {
$totalSize += $file.Length
$totalFiles++

if ($PSCmdlet.ShouldProcess($file.FullName, '删除文件')) {
try {
Remove-Item -Path $file.FullName -Force -ErrorAction Stop
Write-Verbose "已删除:$($file.FullName)"
} catch {
Write-Warning "删除失败:$($file.FullName) - $($_.Exception.Message)"
}
}
}
}

[PSCustomObject]@{
FilesDeleted = $totalFiles
SpaceFreedMB = [math]::Round($totalSize / 1MB, 2)
SpaceFreedGB = [math]::Round($totalSize / 1GB, 2)
CutoffDate = $cutoffDate
}
}

# 清理 7 天前的临时文件
$result = Remove-TempFiles -Path @(
$env:TEMP
'C:\Windows\Temp'
) -MaxAge 7 -Verbose

Write-Host "`n清理结果:"
$result | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
详细: 扫描目录:C:\Users\admin\AppData\Local\Temp
详细: 已删除:C:\Users\admin\AppData\Local\Temp\Logs\app-20250810.log
详细: 已删除:C:\Users\admin\AppData\Local\Temp\Cache\data-20250809.tmp
详细: 扫描目录:C:\Windows\Temp
详细: 已删除:C:\Windows\Temp\UpdateStaging\patch-20250811.cab

清理结果:
FilesDeleted : 2456
SpaceFreedMB : 1024.5
SpaceFreedGB : 1.0
CutoffDate : 2025/8/13 8:00:00

清理日志文件

日志文件是另一个重要的空间占用来源,需要按策略进行归档和清理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
function Remove-OldLogFiles {
<#
.SYNOPSIS
清理过期的日志文件,可选压缩归档
.PARAMETER LogPath
日志目录路径
.PARAMETER MaxAge
日志保留天数
.PARAMETER ArchivePath
归档目录(设置后将压缩旧日志移至此处而非直接删除)
.PARAMETER Pattern
文件匹配模式(默认 *.log)
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string]$LogPath,

[int]$MaxAge = 30,

[string]$ArchivePath,

[string]$Pattern = '*.log'
)

if (-not (Test-Path $LogPath)) {
throw "日志目录不存在:$LogPath"
}

$cutoffDate = (Get-Date).AddDays(-$MaxAge)
$files = Get-ChildItem -Path $LogPath -Filter $Pattern -File -Force |
Where-Object { $_.LastWriteTime -lt $cutoffDate }

$stats = @{
Archived = 0
Deleted = 0
TotalSizeMB = 0
}

foreach ($file in $files) {
$stats['TotalSizeMB'] += $file.Length / 1MB

if ($ArchivePath) {
# 归档模式:压缩后移动
if (-not (Test-Path $ArchivePath)) {
New-Item -Path $ArchivePath -ItemType Directory -Force | Out-Null
}

$zipName = "$($file.BaseName)-$(Get-Date -Format 'yyyyMMdd').zip"
$zipPath = Join-Path $ArchivePath $zipName

if ($PSCmdlet.ShouldProcess($file.FullName, "归档到 $zipPath")) {
Compress-Archive -Path $file.FullName -DestinationPath $zipPath -Force
Remove-Item -Path $file.FullName -Force
$stats['Archived']++
Write-Verbose "已归档:$($file.Name) -> $zipName"
}
} else {
# 直接删除模式
if ($PSCmdlet.ShouldProcess($file.FullName, '删除')) {
Remove-Item -Path $file.FullName -Force
$stats['Deleted']++
Write-Verbose "已删除:$($file.Name)"
}
}
}

[PSCustomObject]@{
LogPath = $LogPath
MaxAge = $MaxAge
CutoffDate = $cutoffDate
FilesCount = $files.Count
Archived = $stats['Archived']
Deleted = $stats['Deleted']
SizeMB = [math]::Round($stats['TotalSizeMB'], 2)
}
}

# 清理 30 天前的 IIS 日志(归档模式)
$logResult = Remove-OldLogFiles `
-LogPath 'C:\inetpub\logs\LogFiles\W3SVC1' `
-MaxAge 30 `
-ArchivePath 'D:\Archive\IISLogs' `
-Verbose

$logResult | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
详细: 已归档:u_ex250715.log -> u_ex250715-20250820.zip
详细: 已归档:u_ex250716.log -> u_ex250716-20250820.zip
详细: 已归档:u_ex250717.log -> u_ex250717-20250820.zip

LogPath : C:\inetpub\logs\LogFiles\W3SVC1
MaxAge : 30
CutoffDate : 2025/7/21 8:00:00
FilesCount : 15
Archived : 15
Deleted : 0
SizeMB : 2048.5

清理 Windows 更新缓存和系统文件

Windows 更新下载的文件和系统组件缓存往往占用大量空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function Remove-WindowsUpdateCache {
<#
.SYNOPSIS
清理 Windows 更新下载缓存
.PARAMETER WhatIf
仅显示将执行的操作
#>
[CmdletBinding(SupportsShouldProcess)]
param()

# 需要停止 Windows Update 服务才能清理
$wuService = 'wuauserv'

Write-Verbose "停止 Windows Update 服务..."
Stop-Service -Name $wuService -Force -ErrorAction SilentlyContinue

$cachePaths = @(
'C:\Windows\SoftwareDistribution\Download'
'C:\Windows\SoftwareDistribution\DataStore'
)

$totalFreed = 0

foreach ($path in $cachePaths) {
if (Test-Path $path) {
$files = Get-ChildItem -Path $path -Recurse -Force -ErrorAction SilentlyContinue
$size = ($files | Measure-Object -Property Length -Sum).Sum
$totalFreed += $size

if ($PSCmdlet.ShouldProcess($path, '清理目录内容')) {
Get-ChildItem -Path $path -Force |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Write-Verbose "已清理:$path ($([math]::Round($size / 1MB, 2)) MB)"
}
}
}

# 重新启动服务
Write-Verbose "启动 Windows Update 服务..."
Start-Service -Name $wuService -ErrorAction SilentlyContinue

[PSCustomObject]@{
CacheCleaned = $cachePaths
SpaceFreedMB = [math]::Round($totalFreed / 1MB, 2)
ServiceState = (Get-Service -Name $wuService).Status
}
}

# 清理 Windows 更新缓存
$updateResult = Remove-WindowsUpdateCache -Verbose
$updateResult | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
详细: 停止 Windows Update 服务...
详细: 已清理:C:\Windows\SoftwareDistribution\Download (3456.78 MB)
详细: 已清理:C:\Windows\SoftwareDistribution\DataStore (234.5 MB)
详细: 启动 Windows Update 服务...

CacheCleaned : {C:\Windows\SoftwareDistribution\Download, C:\Windows\SoftwareDistribution\DataStore}
SpaceFreedMB : 3691.28
ServiceState : Running

综合清理工作流

将各类清理操作整合为一个可定期执行的综合脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
function Invoke-DiskCleanup {
<#
.SYNOPSIS
执行综合磁盘清理
.PARAMETER DriveLetter
目标驱动器盘符
.PARAMETER TempFileMaxAge
临时文件保留天数
.PARAMETER LogFileMaxAge
日志文件保留天数
.PARAMETER CleanUpdateCache
是否清理 Windows 更新缓存
#>
[CmdletBinding()]
param(
[string]$DriveLetter = 'C',

[int]$TempFileMaxAge = 7,

[int]$LogFileMaxAge = 30,

[switch]$CleanUpdateCache
)

Write-Host "===== 磁盘清理开始 =====" -ForegroundColor Cyan
Write-Host "目标驱动器:$($DriveLetter):"
Write-Host "执行时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"

# 清理前空间
$before = Get-DiskSpaceInfo -DriveLetter $DriveLetter
Write-Host "`n清理前可用空间:$($before.FreeGB) GB" -ForegroundColor Yellow

$results = [System.Collections.Generic.List[PSObject]]::new()

# 1. 清理临时文件
Write-Host "`n[1/4] 清理临时文件..." -ForegroundColor Cyan
$tempResult = Remove-TempFiles -Path @(
$env:TEMP
'C:\Windows\Temp'
) -MaxAge $TempFileMaxAge
$results.Add($tempResult)
Write-Host " 已删除 $($tempResult.FilesDeleted) 个文件,释放 $($tempResult.SpaceFreedMB) MB"

# 2. 清理日志文件
Write-Host "`n[2/4] 清理日志文件..." -ForegroundColor Cyan
$logPaths = @(
'C:\inetpub\logs\LogFiles'
'C:\Windows\Logs'
)
foreach ($logPath in $logPaths) {
if (Test-Path $logPath) {
$logResult = Remove-OldLogFiles -LogPath $logPath -MaxAge $LogFileMaxAge
$results.Add($logResult)
Write-Host " 清理 $logPath - $($logResult.SizeMB) MB"
}
}

# 3. 清空回收站
Write-Host "`n[3/4] 清空回收站..." -ForegroundColor Cyan
try {
Clear-RecycleBin -DriveLetter $DriveLetter -Force -ErrorAction Stop
Write-Host " 回收站已清空"
} catch {
Write-Warning "清空回收站失败:$_"
}

# 4. 清理 Windows 更新缓存
if ($CleanUpdateCache) {
Write-Host "`n[4/4] 清理 Windows 更新缓存..." -ForegroundColor Cyan
$wuResult = Remove-WindowsUpdateCache
$results.Add($wuResult)
Write-Host " 释放 $($wuResult.SpaceFreedMB) MB"
} else {
Write-Host "`n[4/4] 跳过 Windows 更新缓存清理" -ForegroundColor Gray
}

# 清理后空间
$after = Get-DiskSpaceInfo -DriveLetter $DriveLetter
$freedGB = [math]::Round($after.FreeGB - $before.FreeGB, 2)

Write-Host "`n===== 清理完成 =====" -ForegroundColor Green
Write-Host "清理前可用空间:$($before.FreeGB) GB"
Write-Host "清理后可用空间:$($after.FreeGB) GB"
Write-Host "释放空间:$freedGB GB" -ForegroundColor Green

[PSCustomObject]@{
Drive = "$($DriveLetter):"
BeforeGB = $before.FreeGB
AfterGB = $after.FreeGB
FreedGB = $freedGB
Results = $results
ExecutedAt = Get-Date
}
}

# 执行综合清理
$cleanupReport = Invoke-DiskCleanup -DriveLetter 'C' -TempFileMaxAge 7 -LogFileMaxAge 30 -CleanUpdateCache

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
===== 磁盘清理开始 =====
目标驱动器:C:
执行时间:2025-08-20 08:00:00

清理前可用空间:21.5 GB

[1/4] 清理临时文件...
已删除 2456 个文件,释放 1024.5 MB

[2/4] 清理日志文件...
清理 C:\inetpub\logs\LogFiles - 2048.5 MB
清理 C:\Windows\Logs - 128.3 MB

[3/4] 清空回收站...
回收站已清空

[4/4] 清理 Windows 更新缓存...
释放 3691.28 MB

===== 清理完成 =====
清理前可用空间:21.5 GB
清理后可用空间:27.27 GB
释放空间:5.77 GB

注意事项

  • 备份优先:清理前务必备份重要的日志和配置文件,特别是生产环境的 IIS 日志、应用程序日志等,确认不再需要后再清理。
  • 文件锁定:正在使用的文件无法删除,脚本中应使用 -ErrorAction SilentlyContinue 跳过这些文件,避免因个别文件锁定导致整个脚本中断。
  • 服务依赖:清理 Windows 更新缓存需要停止 wuauserv 服务,如果组织有补丁管理窗口,应在窗口外执行此操作,避免影响正常更新流程。
  • 测试先行:在生产环境运行清理脚本前,先使用 -WhatIf 参数进行干运行(dry run),确认清理范围符合预期后再实际执行。
  • 定期执行:建议将清理脚本配置为计划任务,每周或每月自动执行,避免一次性积累过多文件导致清理时间过长。
  • 监控告警:配合磁盘空间监控(如当可用空间低于 20% 时告警),形成”监控-告警-清理”的闭环管理,避免磁盘空间耗尽导致的系统故障。