SQL Server
第一次使用 SQL Server,读者面对的并不只是 T-SQL。连接地址指向实例,login 负责进入实例,database user 决定进入某个数据库后是谁,schema 再决定对象名称和授权边界。一次提交先让事务日志达到持久化要求,数据页稍后由 Buffer Pool 与检查点协调落盘。把这条链连起来,才能解释“能登录却查不了表”“语句已经提交但数据文件还没刷新”“有备份文件却恢复不到目标时刻”等常见困惑。
证书轮换、恢复、故障转移和补丁升级会改变服务可用性或数据状态,相关命令只应在隔离环境练习。生产操作要使用本团队已经演练过的变更和回退流程。
一、是什么
关系模型与产品边界
关系模型把事实放在行中,把同类事实放在表中,用主键标识实体、外键维护引用、约束拒绝非法状态,再由事务保证一组变化要么全部提交,要么全部回滚。SQL Server 在此之上提供存储引擎、查询优化器、事务日志、安全、备份恢复、作业调度与高可用能力。
| 形态 | 管理边界 | 典型选择 | 不能误判的边界 |
|---|---|---|---|
| SQL Server | 自管操作系统、实例、补丁、备份与高可用 | 机房、边缘、任意云主机 | 团队承担完整平台责任 |
| Azure SQL Database | 数据库级 PaaS,逻辑服务器承载连接和策略 | 云原生单库、弹性池 | 不是可登录的 SQL Server 主机,实例级能力受限 |
| Azure SQL Managed Instance | 接近实例语义的 PaaS | 依赖实例级功能的云迁移 | 平台仍代管操作系统与服务,不等于 Azure VM |
| Azure SQL VM | Azure 虚拟机中的 SQL Server,IaaS | 需要操作系统、实例和第三方代理完整控制 | 补丁、容量、备份和 HA 责任仍主要在用户 |
功能适配不能只看“语法能否运行”,还要核对跨库查询、SQL Server Agent、CLR、链接服务器、网络拓扑、恢复目标和运维责任。正式选型以 Azure SQL PaaS 与 IaaS 比较 和 SQL Database 与 Managed Instance 功能比较 为准。
实例、数据库与存储结构
Database Engine 是服务进程;实例是独立的系统数据库、登录、配置、端点与资源边界。一个实例含 master、model、msdb、tempdb:master 保存实例级元数据,model 是新数据库模板,msdb 保存 Agent、备份历史等信息,tempdb 每次启动重建并承载临时对象、排序、哈希和行版本。
数据库是恢复、备份和兼容级别的重要边界;schema 是数据库内对象的命名与授权容器,不是用户的同义词。数据文件属于 filegroup;页通常为 8 KiB,extent 由 8 个连续页组成;allocation unit 区分行内、LOB 和行溢出分配。Buffer Pool 缓存数据页,脏页可在提交后才刷入数据文件;I/O 延迟、内存压力与访问路径共同决定读写成本。
以下实验前置条件是隔离实例、sysadmin 实验账号以及数据、日志目录已存在且服务账号可写。Linux 应替换为 /var/opt/mssql/data/...。先核对 SERVERPROPERTY('InstanceDefaultDataPath'),不得照抄未知路径。
IF DB_ID(N'sqlserver_lab') IS NOT NULL
THROW 50000, N'sqlserver_lab 已存在,请先确认是否可删除', 1;
GO
CREATE DATABASE sqlserver_lab
ON PRIMARY
(
NAME = N'sqlserver_lab_data',
FILENAME = N'/var/opt/mssql/data/sqlserver_lab.mdf',
SIZE = 256MB,
FILEGROWTH = 64MB
)
LOG ON
(
NAME = N'sqlserver_lab_log',
FILENAME = N'/var/opt/mssql/data/sqlserver_lab.ldf',
SIZE = 128MB,
FILEGROWTH = 64MB
);
GO
USE sqlserver_lab;
SELECT file_id, name, type_desc, physical_name,
size * 8.0 / 1024 AS size_mb,
growth * 8.0 / 1024 AS growth_mb
FROM sys.database_files;
SELECT type_desc, total_pages, used_pages, data_pages
FROM sys.allocation_units;期望看到一个 ROWS 文件和一个 LOG 文件,增长量是固定 MB 而非百分比。目录或权限错误应先修复服务账号 ACL;数据库已存在时不得直接删除。验证要记录文件位置、初始大小、增长步长和磁盘余量。全文完成后可将实验库切为单用户再删除,但只能在确认无保留价值后清理。
事务日志、LSN、VLF 与崩溃恢复
事务日志是顺序日志记录,不是“变更后的数据副本”。每条日志记录有 LSN(Log Sequence Number,日志序列号);VLF(Virtual Log File,虚拟日志文件)是物理日志内部管理单元。WAL 要求相关日志先持久化,脏数据页才能写入数据文件。检查点推进恢复起点并刷新符合条件的脏页,但不会替代日志备份,也不保证日志文件缩小。
异常重启后的恢复通常经历 analysis、redo、undo:分析确定活动事务和起点,redo 重放应持久化的变化,undo 撤销未提交事务。ADR(Accelerated Database Recovery,加速数据库恢复)通过持久版本存储、SLOG、逻辑回滚和异步 cleaner 缩短长事务回滚与恢复时间,但不免除日志治理。SQL Server 中 ADR 关闭时,RCSI/SNAPSHOT 行版本位于 tempdb;ADR 开启后,该数据库的相关行版本位于用户库 PVS。PVS 默认在 PRIMARY filegroup,也可在关闭 ADR、清空旧 PVS 后迁到专用 filegroup;容量和 cleaner 停滞要在用户库观察,不能仍只盯 tempdb。
USE sqlserver_lab;
SELECT recovery_model_desc, log_reuse_wait_desc,
is_accelerated_database_recovery_on
FROM sys.databases WHERE name = DB_NAME();
SELECT total_log_size_in_bytes / 1048576.0 AS total_log_mb,
used_log_space_in_bytes / 1048576.0 AS used_log_mb,
used_log_space_in_percent
FROM sys.dm_db_log_space_usage;
SELECT file_id, vlf_begin_offset, vlf_size_mb, vlf_sequence_number,
vlf_active, vlf_status
FROM sys.dm_db_log_info(DB_ID());
IF (SELECT is_accelerated_database_recovery_on
FROM sys.databases WHERE database_id=DB_ID()) = 0
BEGIN
SELECT DB_NAME(database_id) AS database_name,
reserved_space_kb / 1024.0 AS tempdb_version_store_mb
FROM sys.dm_tran_version_store_space_usage
WHERE database_id=DB_ID();
END
ELSE
BEGIN
SELECT DB_NAME(p.database_id) AS database_name,
fg.name AS pvs_filegroup,
p.persistent_version_store_size_kb / 1024.0 AS off_row_pvs_mb,
p.online_index_version_store_size_kb / 1024.0 AS online_index_pvs_mb,
p.current_aborted_transaction_count,
p.aborted_version_cleaner_start_time,
p.aborted_version_cleaner_end_time
FROM sys.dm_tran_persistent_version_store_stats AS p
LEFT JOIN sys.filegroups AS fg ON fg.data_space_id=p.pvs_filegroup_id
WHERE p.database_id=DB_ID();
END;
CHECKPOINT;期望能读到恢复模型、复用等待、日志占比和 VLF 状态,并且版本存储查询只走与 ADR 状态一致的分支。PVS 大小只统计 off-row 版本,不能把零误读为完全没有行版本;cleaner 长时间无推进时应先查长快照和中止事务,再核 PVS filegroup 空间。目标版本 DMV 列名不同应查该版本官方文档,不能误诊为损坏。ACTIVE_TRANSACTION 指向长事务,LOG_BACKUP 表示 Full/Bulk-logged 模型缺日志备份。止血应恢复日志备份、扩大已验证日志卷或结束确认可回滚的异常事务;直接收缩或切 Simple 会破坏恢复目标。验证标准是复用等待恢复正常、日志备份连续、对应版本存储不再异常增长、磁盘余量回到阈值。
关系建模与 XACT_STATE
前置条件是实验库存在且当前账号可建对象。下面用主键、外键和 CHECK 约束拒绝非法状态,用事务绑定库存扣减与订单写入。
USE sqlserver_lab;
GO
CREATE SCHEMA sales AUTHORIZATION dbo;
GO
CREATE TABLE sales.customer
(
customer_id bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_customer PRIMARY KEY,
customer_name nvarchar(100) NOT NULL,
created_at datetime2(3) NOT NULL
CONSTRAINT DF_customer_created_at DEFAULT sysutcdatetime()
);
CREATE TABLE sales.product
(
product_id bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_product PRIMARY KEY,
sku varchar(40) NOT NULL CONSTRAINT UQ_product_sku UNIQUE,
stock int NOT NULL CONSTRAINT CK_product_stock CHECK (stock >= 0),
unit_price decimal(19,4) NOT NULL
CONSTRAINT CK_product_price CHECK (unit_price > 0)
);
CREATE TABLE sales.app_order
(
order_id bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_app_order PRIMARY KEY,
order_no varchar(40) NOT NULL CONSTRAINT UQ_app_order_no UNIQUE,
customer_id bigint NOT NULL,
status varchar(20) NOT NULL CONSTRAINT DF_app_order_status DEFAULT 'CREATED'
CONSTRAINT CK_app_order_status CHECK (status IN ('CREATED','PAID','CANCELLED')),
amount decimal(19,4) NOT NULL CONSTRAINT CK_order_amount CHECK (amount > 0),
created_at datetime2(3) NOT NULL DEFAULT sysutcdatetime(),
CONSTRAINT FK_order_customer FOREIGN KEY (customer_id)
REFERENCES sales.customer(customer_id)
);
INSERT sales.customer(customer_name) VALUES (N'实验客户');
INSERT sales.product(sku, stock, unit_price) VALUES ('LAB-001', 10, 19.9000);
GO
SET XACT_ABORT ON;
BEGIN TRY
BEGIN TRANSACTION;
UPDATE sales.product SET stock = stock - 1
WHERE sku = 'LAB-001' AND stock >= 1;
IF @@ROWCOUNT <> 1 THROW 50001, N'库存不足或商品不存在', 1;
INSERT sales.app_order(order_no,customer_id,status,amount)
VALUES ('ORD-XACT-1001',1,'CREATED',19.9000);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
SELECT ERROR_NUMBER() AS error_number, ERROR_MESSAGE() AS error_message,
XACT_STATE() AS xact_state;
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
SELECT stock FROM sales.product WHERE sku = 'LAB-001';
SELECT order_no,status,amount FROM sales.app_order;期望库存从 10 变 9,并产生一笔订单。另开事务把金额改成负数、把 customer_id 改成不存在值,应该分别收到 CHECK 和外键错误且不留半成品。XACT_STATE()=1 可提交,-1 只能回滚,0 表示无事务。失败时保留原始错误和事务状态,禁止在不可提交事务中继续写。
Login、User、Role 与 Schema
login 是实例级认证主体,user 是数据库级身份,role 聚合权限,schema 承载对象和授权边界。SQL authentication 由 SQL Server 校验密码;Microsoft Entra 认证用于受支持的 Azure SQL 与已配置的 SQL Server 场景;contained user 可减少对实例 login 的依赖,但必须核对客户端连接目标和 contained database 配置。认证方式改变不了最小授权原则。
下面的最小权限实验前置条件是隔离实例启用 Mixed Mode,执行者为 sysadmin,密码来自临时密钥存储。创建 runtime login/user,只授予 schema 上的 SELECT 和指定存储过程 EXECUTE。
USE master;
CREATE LOGIN lab_runtime WITH PASSWORD = '<TEMP_STRONG_SECRET>',
CHECK_POLICY = ON, CHECK_EXPIRATION = ON;
GO
USE sqlserver_lab;
CREATE USER lab_runtime FOR LOGIN lab_runtime;
CREATE ROLE app_runtime;
ALTER ROLE app_runtime ADD MEMBER lab_runtime;
GRANT SELECT ON SCHEMA::sales TO app_runtime;
GO
CREATE OR ALTER PROCEDURE sales.create_order
@order_no varchar(40),
@customer_id bigint,
@amount decimal(19,4)
AS
BEGIN
SET NOCOUNT ON;
INSERT sales.app_order(order_no,customer_id,status,amount)
VALUES (@order_no,@customer_id,'CREATED',@amount);
END;
GO
GRANT EXECUTE ON OBJECT::sales.create_order TO app_runtime;
DENY ALTER ON SCHEMA::sales TO app_runtime;
GO
EXECUTE AS USER = 'lab_runtime';
SELECT TOP (1) * FROM sales.product; -- 应成功
EXEC sales.create_order @order_no='ORD-PROC-1001',@customer_id=1,@amount=9.9000; -- 应成功
BEGIN TRY
DROP TABLE sales.product; -- 应失败
END TRY
BEGIN CATCH
SELECT ERROR_NUMBER() AS error_number, ERROR_MESSAGE() AS error_message;
END CATCH;
REVERT;期望两次正向操作成功,DDL 反向测试返回 15151 或对应权限错误。若 runtime 能删表,立即停止应用登录,检查直接授权、嵌套角色、db_owner 与 sysadmin;修复为显式最小授权后重测。清理时先 DROP USER lab_runtime,再到 master 执行 DROP LOGIN lab_runtime;有会话或对象所有权时应先迁移所有权,不得强删。
18456 表示登录失败,错误日志中的 state 才能区分账号不存在、密码错误、禁用、默认数据库不可用等原因。数据库迁移后 login SID 不匹配会形成 orphan user,可用 ALTER USER <user> WITH LOGIN=<login> 重映射;不得用反复改密码掩盖 SID 问题。
contained user 必须与实例 login 分开审计。只有 authentication_type_desc=INSTANCE 且 SID 无法映射到兼容 login 的数据库 user 才属于 orphan;DATABASE、EXTERNAL 等身份不能批量执行 ALTER USER ... WITH LOGIN。启用 contained database authentication 会改变实例安全面,必须单独审批、记录原值,并用目标数据库的正向连接和访问其他数据库的反向连接验证边界。
锁、阻塞、死锁与行版本
锁保护并发一致性;blocking 是一个会话等待另一个会话释放不兼容锁;deadlock 是等待环,SQL Server 选择牺牲者并返回 1205。RCSI 把 READ COMMITTED 的一致读改为行版本,SNAPSHOT 是显式事务级快照;ADR 关闭时版本位于 tempdb,ADR 开启时相关版本位于用户库 PVS。行版本减少读写阻塞,但不能消灭写写冲突。
阻塞正反实验
前置条件是两个连接都指向实验库。会话 A 保持事务不提交。
-- 会话 A
USE sqlserver_lab;
BEGIN TRANSACTION;
UPDATE sales.product SET unit_price = unit_price + 1 WHERE sku='LAB-001';
SELECT @@SPID AS blocker_spid;
WAITFOR DELAY '00:01:00';
ROLLBACK TRANSACTION;在等待期间执行会话 B。
-- 会话 B
USE sqlserver_lab;
SET LOCK_TIMEOUT 10000;
UPDATE sales.product SET unit_price = unit_price + 2 WHERE sku='LAB-001';第三个管理员会话定位等待链。
SELECT r.session_id, r.status, r.wait_type, r.wait_time,
r.blocking_session_id, t.text
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.session_id > 50;期望 B 等待 LCK_M_... 或超时,DMV 指向 A。止血优先联系事务拥有者提交/回滚;只有确认业务影响与回滚成本后才 KILL <blocker_spid>。验证为等待链消失、双方数据符合预期;预防是缩短事务、稳定访问顺序、补访问路径并监控长事务。实验由 A 的 ROLLBACK 自动清理。
1205 与死锁图实验
前置条件是两个产品行存在;先插入第二行。两个会话以相反顺序更新,构造等待环。
INSERT sales.product(sku,stock,unit_price)
SELECT 'LAB-002',10,29.9000
WHERE NOT EXISTS (SELECT 1 FROM sales.product WHERE sku='LAB-002');-- 会话 A
BEGIN TRANSACTION;
UPDATE sales.product SET stock=stock-1 WHERE sku='LAB-001';
WAITFOR DELAY '00:00:05';
UPDATE sales.product SET stock=stock-1 WHERE sku='LAB-002';
COMMIT;-- 会话 B,在 A 第一次 UPDATE 后立即执行
BEGIN TRANSACTION;
UPDATE sales.product SET stock=stock-1 WHERE sku='LAB-002';
WAITFOR DELAY '00:00:05';
UPDATE sales.product SET stock=stock-1 WHERE sku='LAB-001';
COMMIT;一个会话应收到 1205。死锁图默认可从 system_health 扩展事件读取。
SELECT CAST(event_data AS xml) AS deadlock_xml
FROM sys.fn_xe_file_target_read_file(
(SELECT CAST(t.target_data AS xml).value(
'(EventFileTarget/File/@name)[1]', 'nvarchar(260)')
FROM sys.dm_xe_sessions s
JOIN sys.dm_xe_session_targets t ON s.address=t.event_session_address
WHERE s.name='system_health' AND t.target_name='event_file'),
NULL, NULL, NULL)
WHERE object_name='xml_deadlock_report';期望 XML 中出现 victim、process-list、resource-list 与持锁/等待关系。失败处理是两会话都执行 IF @@TRANCOUNT>0 ROLLBACK。根治是统一对象访问顺序、缩短事务并提供合适索引;应用仅在整个事务可幂等时有限重试 1205。验证是相同并发压力不再出现等待环,而不是只看一次成功。
RCSI 与 SNAPSHOT
前置条件是 SQL Server 2019 或更高版本、隔离且可重建的实验库、无长事务阻塞状态切换。先把原始 RCSI、SNAPSHOT、ADR 和 PVS filegroup 持久保存在实验库;基线表已存在表示上次实验未清理,应先调查而不是覆盖。
USE sqlserver_lab;
IF OBJECT_ID(N'dbo.lab_database_option_baseline',N'U') IS NOT NULL
THROW 51000,N'基线表已存在,请先恢复上次实验',1;
IF EXISTS (SELECT 1 FROM sys.databases
WHERE name=N'sqlserver_lab' AND snapshot_isolation_state NOT IN (0,1))
THROW 51007,N'SNAPSHOT 选项正在转换,等待稳定后再建立基线',1;
CREATE TABLE dbo.lab_database_option_baseline
(
id tinyint NOT NULL CONSTRAINT PK_lab_database_option_baseline PRIMARY KEY,
rcsi_on bit NOT NULL,
snapshot_on bit NOT NULL,
adr_on bit NOT NULL,
pvs_filegroup sysname NULL,
CONSTRAINT CK_lab_database_option_baseline_id CHECK(id=1)
);
INSERT dbo.lab_database_option_baseline(id,rcsi_on,snapshot_on,adr_on,pvs_filegroup)
SELECT 1,d.is_read_committed_snapshot_on,
CONVERT(bit,CASE WHEN d.snapshot_isolation_state=1 THEN 1 ELSE 0 END),
d.is_accelerated_database_recovery_on,fg.name
FROM sys.databases AS d
LEFT JOIN sys.dm_tran_persistent_version_store_stats AS p
ON p.database_id=d.database_id
LEFT JOIN sys.filegroups AS fg ON fg.data_space_id=p.pvs_filegroup_id
WHERE d.name=N'sqlserver_lab';
USE master;
ALTER DATABASE sqlserver_lab SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
ALTER DATABASE sqlserver_lab SET ALLOW_SNAPSHOT_ISOLATION ON;
ALTER DATABASE sqlserver_lab SET ACCELERATED_DATABASE_RECOVERY=OFF WITH ROLLBACK IMMEDIATE;先在 ADR OFF 分支验证 RCSI。会话 A 更新但不提交;等待期间会话 B 应立即读到提交前版本,管理员会话应看到实验库占用 tempdb version store。
-- 会话 A
USE sqlserver_lab;
BEGIN TRANSACTION;
UPDATE sales.product SET unit_price=88.8800 WHERE sku='LAB-001';
WAITFOR DELAY '00:00:30';
ROLLBACK;-- 会话 B,在 A 等待期间执行;RCSI 下不等待且读到提交前值
USE sqlserver_lab;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT unit_price FROM sales.product WHERE sku='LAB-001';-- 管理员会话:ADR OFF 必须观察 tempdb 分支
SELECT DB_NAME(database_id) AS database_name,
reserved_space_kb/1024.0 AS tempdb_version_store_mb
FROM sys.dm_tran_version_store_space_usage
WHERE database_id=DB_ID(N'sqlserver_lab');等待会话 A 回滚并确认 @@TRANCOUNT=0 后,再用 SNAPSHOT 做“事务中两次读稳定、另一事务中间提交、随后写写冲突”的完整反向测试。先启动会话 B;它进入等待后立即运行会话 C。
-- 会话 B
USE sqlserver_lab;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRY
BEGIN TRANSACTION;
SELECT unit_price AS snapshot_read_1 FROM sales.product WHERE sku='LAB-001';
WAITFOR DELAY '00:00:20';
SELECT unit_price AS snapshot_read_2 FROM sales.product WHERE sku='LAB-001';
UPDATE sales.product SET unit_price=unit_price+1 WHERE sku='LAB-001';
COMMIT;
THROW 51001,N'预期的 SNAPSHOT 写冲突没有发生',1;
END TRY
BEGIN CATCH
DECLARE @error_number int=ERROR_NUMBER(),@error_message nvarchar(4000)=ERROR_MESSAGE();
IF XACT_STATE()<>0 ROLLBACK;
SELECT @error_number AS error_number,@error_message AS error_message;
IF @error_number<>3960 THROW;
END CATCH;-- 会话 C,在 B 的 WAITFOR 期间提交
USE sqlserver_lab;
UPDATE sales.product SET unit_price=unit_price+10 WHERE sku='LAB-001';
SELECT unit_price AS committed_between_snapshot_reads
FROM sales.product WHERE sku='LAB-001';会话 B 的两次读应相同,随后 UPDATE 返回 3960;如果第二次读变化、UPDATE 成功或出现其他错误,实验不通过。重复实验前先让所有会话结束,再切到 ADR ON;该分支应在用户库 PVS 观察到 filegroup、空间和 cleaner 状态,而不是把 tempdb 当唯一容量来源。
USE master;
ALTER DATABASE sqlserver_lab SET ACCELERATED_DATABASE_RECOVERY=ON WITH ROLLBACK IMMEDIATE;
GO
USE sqlserver_lab;
SELECT DB_NAME(p.database_id) AS database_name,fg.name AS pvs_filegroup,
p.persistent_version_store_size_kb/1024.0 AS off_row_pvs_mb,
p.current_aborted_transaction_count,
p.aborted_version_cleaner_start_time,p.aborted_version_cleaner_end_time
FROM sys.dm_tran_persistent_version_store_stats AS p
LEFT JOIN sys.filegroups AS fg ON fg.data_space_id=p.pvs_filegroup_id
WHERE p.database_id=DB_ID();保持 ADR ON,重新运行会话 A/B/C;在事务等待期间重复 PVS 查询。期望 pvs_filegroup 为 PRIMARY 或批准的专用 filegroup,PVS 指标随版本产生并在长快照结束后由 cleaner 回落。若 tempdb 或 PVS 增长失控,先定位长快照、大批量更新和 cleaner 阻塞,必要时隔离入口和扩展已批准卷;不能把关闭 RCSI/ADR 当默认止血。
最后按持久基线精确恢复三个开关和原 PVS filegroup。清理只允许在所有实验会话结束后执行;任一 ALTER 或 PVS cleanup 失败都保留基线表并停止,禁止猜测原配置。
USE master;
DECLARE @rcsi bit,@snapshot bit,@adr bit,@pvs sysname,@sql nvarchar(max);
SELECT @rcsi=rcsi_on,@snapshot=snapshot_on,@adr=adr_on,@pvs=pvs_filegroup
FROM sqlserver_lab.dbo.lab_database_option_baseline WHERE id=1;
IF @@ROWCOUNT<>1 THROW 51002,N'缺少唯一配置基线',1;
ALTER DATABASE sqlserver_lab SET ACCELERATED_DATABASE_RECOVERY=OFF WITH ROLLBACK IMMEDIATE;
EXEC sys.sp_persistent_version_cleanup N'sqlserver_lab';
SET @sql=N'ALTER DATABASE sqlserver_lab SET READ_COMMITTED_SNAPSHOT '
+CASE WHEN @rcsi=1 THEN N'ON' ELSE N'OFF' END+N' WITH ROLLBACK IMMEDIATE;';
EXEC sys.sp_executesql @sql;
SET @sql=N'ALTER DATABASE sqlserver_lab SET ALLOW_SNAPSHOT_ISOLATION '
+CASE WHEN @snapshot=1 THEN N'ON;' ELSE N'OFF;' END;
EXEC sys.sp_executesql @sql;
IF @adr=1
BEGIN
IF @pvs IS NULL THROW 51003,N'ADR 基线缺少 PVS filegroup',1;
SET @sql=N'ALTER DATABASE sqlserver_lab SET ACCELERATED_DATABASE_RECOVERY=ON '
+N'(PERSISTENT_VERSION_STORE_FILEGROUP='+QUOTENAME(@pvs)+N') WITH ROLLBACK IMMEDIATE;';
EXEC sys.sp_executesql @sql;
END;
SELECT name,is_read_committed_snapshot_on,snapshot_isolation_state_desc,
is_accelerated_database_recovery_on
FROM sys.databases WHERE name=N'sqlserver_lab';
DROP TABLE sqlserver_lab.dbo.lab_database_option_baseline;堆、索引、统计、基数与 Query Store
heap 没有聚集索引;clustered index 的叶级就是数据页,一表最多一个;nonclustered index 有独立 B-tree,叶级保存键、包含列和行定位器。统计信息描述数据分布,基数估算驱动连接方式、内存授予和并行度;索引存在不保证被采用。
USE sqlserver_lab;
IF OBJECT_ID('sales.order_probe') IS NOT NULL DROP TABLE sales.order_probe;
CREATE TABLE sales.order_probe
(
order_id bigint IDENTITY(1,1) NOT NULL,
customer_id bigint NOT NULL,
status tinyint NOT NULL,
amount decimal(19,4) NOT NULL,
created_at datetime2(3) NOT NULL
);
WITH n AS
(
SELECT TOP (20000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT sales.order_probe(customer_id,status,amount,created_at)
SELECT n % 1000, CASE WHEN n % 100 = 0 THEN 9 ELSE 1 END,
10 + n % 500, DATEADD(minute,-n,sysutcdatetime())
FROM n;
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SET STATISTICS XML ON;
SELECT customer_id, amount, created_at
FROM sales.order_probe WHERE status=9
ORDER BY created_at DESC;
GO
CREATE CLUSTERED INDEX CX_order_probe ON sales.order_probe(order_id);
CREATE NONCLUSTERED INDEX IX_order_probe_status_created
ON sales.order_probe(status,created_at DESC)
INCLUDE(customer_id,amount);
UPDATE STATISTICS sales.order_probe WITH FULLSCAN;
SELECT customer_id, amount, created_at
FROM sales.order_probe WHERE status=9
ORDER BY created_at DESC;
SET STATISTICS XML OFF;
SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;
GO
ALTER DATABASE sqlserver_lab SET QUERY_STORE = ON
(OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO);
SELECT actual_state_desc, readonly_reason
FROM sys.database_query_store_options;预期第一次计划为 heap scan 并有较多逻辑读,第二次可用覆盖非聚集索引且逻辑读下降;实际选择仍由数据分布与成本决定。估算行数与实际行数明显偏离时先核统计新鲜度、参数值、隐式转换和兼容级别,不能先强制计划。Query Store 应为 READ_WRITE;只读要查配额或错误。计划回归可临时强制已验证计划止血,但根治需修统计、查询、索引或参数敏感问题,并在验证新计划后解除强制。实验清理用 DROP TABLE sales.order_probe,关闭 Query Store 前先确认不再需要历史证据。
TLS、TDS 8 与三类数据加密
TLS 保护客户端到服务端的传输;TDE 加密静态数据文件、日志和随之生成的备份,但拥有数据库访问权的引擎仍能读明文;备份加密只保护指定备份介质;Always Encrypted 让敏感列在客户端驱动侧加解密,使服务端通常只见密文。四者威胁模型不同,不能互相替代。
Encrypt=mandatory(或 true)仍是 TDS 7.x 加 TLS;Encrypt=strict 才强制 TDS 8,在任何 TDS 预登录字节前完成 TLS。strict 要求 SQL Server 2022 或更高版本和支持该模式的客户端,且不能用 TrustServerCertificate=true 绕过证书验证。协议、驱动版本和加密策略必须分别留证,看到 encrypt_option=TRUE 不能单独证明使用了 TDS 8。
二、为什么
从版本与许可走向部署选择
Edition、许可、版本和服务分支
| Edition | 合法用途与能力定位 |
|---|---|
| Enterprise Developer / Standard Developer | 免费用于开发和测试,分别对应 Enterprise / Standard 能力边界;不能承接生产工作负载。旧主版本中的 Developer 通常对应 Enterprise 开发能力 |
| Express | 免费轻量版,受数据库大小、计算和内存等产品限制,适合小型应用与学习 |
| Standard | 生产版,能力和资源上限低于 Enterprise,须按许可条款授权 |
| Enterprise | 完整生产能力与最高扩展边界,须按许可条款授权 |
| Evaluation | 有期限的评估用途,期限结束前必须迁移到合法授权版本 |
“免费安装成功”不是生产授权。生产环境必须保存 Edition、采购渠道、许可模型、核数或 Server/CAL 依据以及 Software Assurance 权利。最终解释以 Microsoft Product Terms 和对应版本的官方许可文本为准。
SQL Server 要同时记录产品主版本、完整 build、Edition、数据库兼容级别和补丁服务分支。下面选择 SQL Server 2025 版本 CU8 作为可复现实验基线,引擎 build 为 17.0.4075.5,Ubuntu 22.04 包版本为 17.0.4075.5-1;仍运行 SQL Server 2022 版本的兼容线固定为 CU26、build 16.0.4265.3。上线前必须重新核对官方 build 表,并只在同一 major 的批准 CU 分支内更新。CU(Cumulative Update,累积更新)包含累计修复;GDR(General Distribution Release,通用分发修复)主要承载安全与关键修复。选择 CU 或 GDR 后应沿批准的服务分支维护,不能把某个动态 CU 号永久固化为不再复核的值。SQL Server 2017 版本及之后采用现代服务模型,不再用 Service Pack 作为常规基线。
以下查询的前置条件是具有 VIEW SERVER STATE 权限。完整 build 必须与对应主版本的 官方 build 列表 比对,并检查 产品生命周期;不能只看 ProductVersion 的主版本。
SELECT
SERVERPROPERTY('ServerName') AS server_name,
SERVERPROPERTY('ProductVersion') AS product_version,
SERVERPROPERTY('ProductLevel') AS product_level,
SERVERPROPERTY('ProductUpdateLevel') AS update_level,
SERVERPROPERTY('Edition') AS edition,
SERVERPROPERTY('EngineEdition') AS engine_edition;
SELECT name, compatibility_level
FROM sys.databases
ORDER BY name;期望输出包含完整 build、Edition 和兼容级别。若 build 不在批准清单、生命周期已结束或服务分支漂移,应停止上线,先在预生产复现补丁路径。验证是归档查询结果、安装制品哈希、build 清单链接和许可依据;回滚是按已验证的补丁卸载能力或恢复节点镜像执行,不能假设所有 CU 都可无损卸载。
何时选择 SQL Server,何时选择其他关系数据库或托管形态
SQL Server 的优势不只是 T-SQL。它把成熟关系事务、Query Store、SQL Server Agent、备份恢复、Availability Group、Microsoft Entra/Active Directory 集成和商业支持组合成一个长期维护体系。组织已经围绕 .NET、Microsoft 身份、Power BI、SSIS/SSRS 或既有 SQL Server 运维建立能力时,继续选择 SQL Server 往往能减少身份、审计、工具与人才的割裂。核心数据依赖强约束、复杂事务、稳定存储过程和可预测 PITR 时,它也比把多个专用组件临时拼在一起更直接。
选择前要用业务负载证明需要关系能力。高频点查、短事务、外键、唯一约束、复杂联接与可审计恢复符合它的主场;纯追加的大规模扫描通常应把分析副本或列式平台纳入架构;低延迟内存状态不应让 SQL Server 代替缓存;大量图片和归档文件应进入对象存储,数据库保存元数据。把所有工作负载塞进一个实例会让日志、buffer pool、tempdb、备份窗口与许可成本同时成为瓶颈。
| 决策维度 | SQL Server 更有优势 | 需要比较其他方案 |
|---|---|---|
| 组织生态 | .NET、Microsoft 身份、Power BI、既有 DBA 与商业支持 | 团队已标准化 PostgreSQL/MySQL 与开源运维 |
| 数据约束 | 强事务、外键、复杂 T-SQL、Query Store 治理 | 文档聚合、键值状态或大规模列式扫描 |
| 高可用 | 团队能维护 AG、WSFC/Pacemaker、fencing 与备份 | 无法承担仲裁、证书、补丁和恢复演练 |
| 成本 | 能量化 Edition、核许可、支持与运维价值 | 许可随核心数扩张且能力未被使用 |
| 可迁移性 | 业务接受 T-SQL、Agent、SSIS 等平台能力绑定 | 必须保持跨数据库 SQL 与工具可替换性 |
SQL Server、PostgreSQL 与 MySQL 都能承担典型 OLTP,选择不能简化为“谁性能更快”。要用同一数据分布、并发、事务隔离、驱动池、索引和恢复目标做验证,再比较 p95、锁等待、日志增长、备份恢复时间、许可与三年运维成本。大量依赖 CLR、SQL Server Agent、Always Encrypted、特定 T-SQL 或 BI 集成时迁移成本很高;反过来,如果只是基础 CRUD,商业特性没有进入验收,较轻的关系数据库可能更经济。
部署形态同样是产品选择。Azure SQL Database 适合接受平台约束的单数据库或弹性池工作负载,平台承担大量实例运维,但 server 级能力、跨库行为和部分 Agent 模式需要重新设计;Azure SQL Managed Instance 提供更接近实例的兼容面,网络、维护窗口和成本仍需验证;Azure VM 上的 SQL Server 与自建主机保留最多控制权,也把操作系统、补丁、fencing、备份和容量责任交回团队。不能因为名称都含 SQL Server 就假定三者的功能、故障切换、备份导出与退出路径完全相同。
最终 ADR 要记录数据量与增长、峰值事务、恢复目标、Edition 能力、许可口径、身份与审计、HA 故障域、运维人员、托管责任边界和退出方法。若团队不能在隔离环境完成恢复、补丁与故障切换,优先购买能覆盖这些责任的托管或支持,而不是把单节点 Developer/Express 实验直接升级为生产。
可用性与数据分发边界
| 技术 | 保护对象 | 适用目标 | 不能替代 |
|---|---|---|---|
| Availability Group | 数据库日志流与多副本 | 高可用、只读副本、灾备 | login、Agent job、备份 |
| Log Shipping | 定时日志备份、复制和恢复 | 低复杂度灾备 | 自动切换 |
| Replication | 指定对象与数据变更 | 数据分发 | 完整实例高可用 |
| PITR | Full/Diff/Log/Tail-log 恢复链 | 误删与时间点恢复 | 在线故障转移 |
Linux AG 使用 CLUSTER_TYPE=EXTERNAL,Pacemaker 负责角色裁决,STONITH/fencing 负责在网络分区时隔离失联节点。VIP 只是连接入口,quorum 只证明集群具备裁决能力;二者都不能证明数据库已同步。生产拓扑至少准备三个投票成员或等价仲裁设计、独立故障域、同一 major/CU、可解析的节点 FQDN、TCP 5022、恢复验证过的备份,以及能够命中每个节点的真实 fence agent。
三、怎么做
先用容器和 sqlcmd 跑通第一张表
学习环境先选择 Developer Edition。它免费用于开发和测试,可以体验 Enterprise 对应能力,但不能承接生产负载。下面的容器只绑定本机端口,数据放入独立 volume;MSSQL_SA_PASSWORD 只用于临时学习环境,并通过环境变量传入,不能提交到仓库。
export MSSQL_SA_PASSWORD='replace-with-a-local-strong-password'
docker run -d --name sqlserver-learning \
--platform linux/amd64 \
-e ACCEPT_EULA=Y \
-e MSSQL_PID=Developer \
-e MSSQL_SA_PASSWORD \
-p 127.0.0.1:14333:1433 \
-v sqlserver-learning-data:/var/opt/mssql \
mcr.microsoft.com/mssql/server:2025-CU8-ubuntu-22.04SQL Server 初始化通常比端口监听更晚。先观察日志,再使用容器内的 sqlcmd 做真正的登录检查:
docker logs --tail 60 sqlserver-learning
docker exec -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" sqlserver-learning \
/opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -C -b \
-Q "SELECT @@VERSION AS version, SERVERPROPERTY('Edition') AS edition;"日志出现 ready for client connections,查询返回 SQL Server 2025 版本与 Developer Edition,才说明引擎能够认证并执行语句。-C 仅用于这个没有受信证书的本机实验,生产连接必须验证证书,不能照搬。
进入交互终端后创建一个小数据库:
docker exec -it -e SQLCMDPASSWORD="$MSSQL_SA_PASSWORD" sqlserver-learning \
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -C -d masterCREATE DATABASE shop;
GO
USE shop;
GO
CREATE SCHEMA sales;
GO
CREATE TABLE sales.app_order
(
order_id bigint IDENTITY(1,1) PRIMARY KEY,
order_no varchar(40) NOT NULL UNIQUE,
customer_id bigint NOT NULL,
status varchar(20) NOT NULL
CONSTRAINT CK_app_order_status
CHECK (status IN ('CREATED','PAID','CANCELLED')),
amount decimal(12,2) NOT NULL
CONSTRAINT CK_app_order_amount CHECK (amount >= 0),
created_at datetime2(3) NOT NULL DEFAULT sysutcdatetime()
);
GO
INSERT sales.app_order(order_no,customer_id,status,amount)
OUTPUT inserted.order_id,inserted.order_no,inserted.status,inserted.amount
VALUES ('ORD-1001',101,'CREATED',199.00),('ORD-1002',102,'PAID',88.50);
GO
SELECT order_id,order_no,customer_id,status,amount
FROM sales.app_order
WHERE status='PAID'
ORDER BY order_id;
GOOUTPUT inserted... 与 PostgreSQL 的 RETURNING 类似,会直接带回数据库生成的值。反向写入一个非法状态:
INSERT sales.app_order(order_no,customer_id,status,amount)
VALUES ('ORD-1003',103,'UNKNOWN',10.00);
GO预期收到 CHECK 约束冲突。再确认事务回滚:
BEGIN TRANSACTION;
UPDATE sales.app_order SET status='PAID' WHERE order_no='ORD-1001';
SELECT order_no,status FROM sales.app_order WHERE order_no='ORD-1001';
ROLLBACK TRANSACTION;
SELECT order_no,status FROM sales.app_order WHERE order_no='ORD-1001';
GO事务内第一次查询显示 PAID,回滚后仍应是 CREATED。这条最小链路把实例、database、schema、表、约束、写入、查询和事务放在同一个可复现实验里。输入 QUIT 离开后,可以删除临时环境:
docker rm -f sqlserver-learning
docker volume rm sqlserver-learning-data
unset MSSQL_SA_PASSWORD固定安装制品
可复现部署必须固定下载地址或仓库版本、文件哈希、配置文件和容器 digest。示例中的 <APPROVED_...> 都是变更单中审核后的真实值,不允许原样执行。
Linux 固定包版本安装
前置条件是官方支持矩阵中的 Linux 发行版与 x86-64 处理器、root 权限、批准的 Microsoft 仓库配置和包版本。仓库通道决定主版本,包版本决定 build,两者都要进入制品清单。
set -euo pipefail
approved_pkg='17.0.4075.5-1'
: "${MSSQL_SA_PASSWORD:?load bootstrap password from the secret runner}"
. /etc/os-release
[[ "$ID" == ubuntu && "$VERSION_ID" == 22.04 ]] || {
echo '这套安装基线只支持 Ubuntu 22.04 x86-64' >&2; exit 64;
}
[[ "$(uname -m)" == x86_64 ]] || { echo '只支持 x86-64' >&2; exit 65; }
curl -fsSLo /tmp/packages-microsoft-prod.deb \
https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb
echo '0d335c06ceb3227e330a36a7135997207843c726e42ec341e34a3825f151213b /tmp/packages-microsoft-prod.deb' | sha256sum -c -
sudo dpkg -i /tmp/packages-microsoft-prod.deb
curl -fsSLo /tmp/mssql-server-2025.list \
https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2025.list
echo 'eb0e32a8b6aa904e934cf2497cc94ffea3a6b0a7a1c939caf39385d016e76130 /tmp/mssql-server-2025.list' | sha256sum -c -
sudo install -m 0644 /tmp/mssql-server-2025.list \
/etc/apt/sources.list.d/mssql-server-2025.list
sudo apt-get update
apt-cache madison mssql-server
sudo ACCEPT_EULA=Y apt-get install -y "mssql-server=${approved_pkg}"
sudo MSSQL_PID='Developer' \
MSSQL_SA_PASSWORD="$MSSQL_SA_PASSWORD" \
/opt/mssql/bin/mssql-conf -n setup
sudo systemctl enable --now mssql-server
sudo systemctl --no-pager --full status mssql-server
export SQLCMDPASSWORD="$MSSQL_SA_PASSWORD"
/opt/mssql-tools18/bin/sqlcmd -S 'tcp:localhost,1433' -U sa \
-N -C -Q "SELECT @@VERSION;"期望仓库能看到批准版本、服务为 active (running),查询返回目标 build。-N 开启加密,实验中的 -C 只用于尚未配置受信证书的首次引导,不得成为生产模板。失败时检查 journalctl -u mssql-server、errorlog、内存与目录权限;包版本不存在时停止,不得自动换成更高版本。验证后归档 dpkg-query -W mssql-server 和 build。实验回滚先导出所需数据再卸载;/var/opt/mssql 不会因卸载自然等同安全清理,删除前必须核对路径和恢复需求。
固定 digest 的 Linux 容器
官方 SQL Server 容器的支持边界是 Linux 宿主上的 Intel/AMD x86-64。ARM 主机上的模拟执行不是受支持生产路径。标签用于发现候选版本,部署必须改用审核后的 digest;容器内仍要查询 build。
set -euo pipefail
lab_id="${LAB_ID:?set a unique LAB_ID}"
[[ "$lab_id" =~ ^[a-z0-9-]+$ ]] || { echo 'LAB_ID 只能含小写字母、数字和连字符' >&2; exit 64; }
: "${MSSQL_SA_PASSWORD:?load MSSQL_SA_PASSWORD from the approved secret runner}"
candidate='mcr.microsoft.com/mssql/server:2025-CU8-ubuntu-22.04'
docker pull "$candidate"
docker image inspect "$candidate" --format '{{json .RepoDigests}}'
image='mcr.microsoft.com/mssql/server@sha256:065cea643fdc49604793545de45f4e865bf4c5abd4b9e9d1711388b7e10481a8'
container="sqlserver-lab-${lab_id}"
volume="sqlserver-lab-data-${lab_id}"
created_volume=0
cleanup_failed_run() {
docker rm -f "$container" >/dev/null 2>&1 || true
if (( created_volume == 1 )); then docker volume rm "$volume" >/dev/null 2>&1 || true; fi
}
trap cleanup_failed_run ERR INT TERM
if docker container inspect "$container" >/dev/null 2>&1; then
echo "容器已存在: $container" >&2; exit 65
fi
if docker volume inspect "$volume" >/dev/null 2>&1; then
echo "卷已存在: $volume" >&2; exit 66
fi
docker pull "$image"
docker volume create "$volume"
created_volume=1
docker run -d --name "$container" \
--platform linux/amd64 \
-e ACCEPT_EULA=Y \
-e MSSQL_PID=Developer \
-e MSSQL_SA_PASSWORD \
-p 127.0.0.1:14333:1433 \
-v "$volume:/var/opt/mssql" \
"$image"
deadline=$((SECONDS + 180))
until docker exec -i "$container" sh -c \
'read -r SQLCMDPASSWORD; export SQLCMDPASSWORD; exec /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -C -b -Q "SELECT 1"' \
<<<"$MSSQL_SA_PASSWORD" >/dev/null 2>&1; do
if (( SECONDS >= deadline )); then
docker logs --tail 100 "$container" >&2
echo 'SQL Server 180 秒内未就绪' >&2
exit 70
fi
sleep 5
done
docker exec -i "$container" sh -c \
'read -r SQLCMDPASSWORD; export SQLCMDPASSWORD; exec /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -C -b -Q "SELECT @@VERSION,SERVERPROPERTY('"'"'Edition'"'"');"' \
<<<"$MSSQL_SA_PASSWORD"
trap - ERR INT TERM
printf 'READY container=%s volume=%s\n' "$container" "$volume"期望镜像检查返回明确 sha256,三分钟内输出 READY,查询返回 Developer Edition 和批准 build。名字冲突会在创建前失败;中断或超时只清理本次已经创建的容器和卷。若日志出现处理器架构、EULA、密码复杂度、权限或内存错误,应修复根因后使用新的 LAB_ID 重建,禁止用浮动标签掩盖失败。验证同时保存 digest 与数据库 build。此时 sa 仍只是引导身份;完成后必须按“应用驱动”一节创建 migration/runtime 身份并完成正反授权测试,另建受控管理员后轮换引导密码并执行 ALTER LOGIN sa DISABLE,应用连接串不得使用 sa。成功后的显式清理先核对上方输出的精确名称,再执行 docker rm -f "sqlserver-lab-${LAB_ID}";只有确认无需恢复才执行 docker volume rm "sqlserver-lab-data-${LAB_ID}"。
用 sqlcmd 完成身份、对象、事务、计划与数据交换
sqlcmd 是安装后最小而完整的管理入口。连接配置必须明确 DNS、端口、database、登录身份和证书校验。生产使用受信 CA 与 -Nm 或经验证的 strict 模式,不使用 -C 跳过证书验证;密码由 secret runner 注入 SQLCMDPASSWORD,不会出现在命令参数、shell 历史或进程列表。
先证明连接落在正确身份和正确数据库
: "${SQLCMDPASSWORD:?load SQLCMDPASSWORD from the secret runner}"
SQLCMD=/opt/mssql-tools18/bin/sqlcmd
SERVER='tcp:sql-primary.example.internal,1433'
"$SQLCMD" -S "$SERVER" -U lab_runtime -d sqlserver_lab -Nm -b -V 16 -r1 \
-Q "
SET NOCOUNT ON;
SELECT @@SERVERNAME AS server_name,
SERVERPROPERTY('ProductVersion') AS product_version,
SERVERPROPERTY('Edition') AS edition,
DB_NAME() AS database_name,
ORIGINAL_LOGIN() AS original_login,
SUSER_SNAME() AS login_name,
USER_NAME() AS database_user;
SELECT encrypt_option,auth_scheme,net_transport,client_net_address
FROM sys.dm_exec_connections WHERE session_id=@@SPID;"-b 让 SQL 错误产生非零退出码,-V 16 把严重级别 16 及以上视为失败,-r1 把错误写到 stderr,适合自动化区分结果与诊断。身份查询必须返回批准 build、sqlserver_lab、lab_runtime login/user 且 encrypt_option=TRUE;任何一项不符都停止后续操作。Windows 身份或 Microsoft Entra 身份要改用对应认证参数,不能同时保留 SQL 密码作为隐藏回退。
从订单对象追到事务与执行计划
对象浏览从 schema 和权限开始,再看表、列、约束与索引。INFORMATION_SCHEMA 适合可移植的基础列信息,SQL Server 特有状态使用 sys catalog;不要通过 SELECT * FROM sys.objects 把无关元数据倾倒到终端。
SELECT s.name AS schema_name, t.name AS table_name,
SUM(p.rows) AS rows
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id=t.schema_id
JOIN sys.partitions p ON p.object_id=t.object_id AND p.index_id IN (0,1)
GROUP BY s.name,t.name
ORDER BY s.name,t.name;
SELECT c.column_id,c.name,TYPE_NAME(c.user_type_id) AS data_type,
c.max_length,c.precision,c.scale,c.is_nullable
FROM sys.columns c
WHERE c.object_id=OBJECT_ID(N'sales.app_order')
ORDER BY c.column_id;
SELECT i.index_id,i.name,i.type_desc,i.is_unique,i.is_primary_key,
i.is_disabled
FROM sys.indexes i
WHERE i.object_id=OBJECT_ID(N'sales.app_order')
ORDER BY i.index_id;把 SQL 保存为 UTF-8 文件后使用 -i 执行,输出用 -o 进入受控证据目录。脚本开头设置 :On Error exit 和 SET XACT_ABORT ON;前者控制 sqlcmd 批处理,后者让多数运行时错误自动回滚当前事务。仍要在 CATCH 中读取 XACT_STATE(),因为并非所有错误和事务状态相同。
:On Error exit
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRY
DECLARE @before bigint=(SELECT COUNT_BIG(*) FROM sales.app_order);
BEGIN TRANSACTION;
EXEC sales.create_order @order_no='ORD-SMOKE-1001',@customer_id=1,@amount=199.0000;
IF (SELECT COUNT_BIG(*) FROM sales.app_order)<>@before+1
THROW 51001,'create_order did not create exactly one row',1;
SELECT TOP (1) order_id,order_no,customer_id,status,amount,created_at
FROM sales.app_order
ORDER BY order_id DESC;
ROLLBACK TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE()<>0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
GO
BEGIN TRY
INSERT sales.app_order(order_no,customer_id,status,amount)
VALUES('ORD-DENIED-1001',1,'CREATED',1.0000);
THROW 51002,'direct INSERT unexpectedly succeeded',1;
END TRY
BEGIN CATCH
IF ERROR_NUMBER()=51002 THROW;
SELECT ERROR_NUMBER() AS expected_permission_error;
END CATCH;
GOset -euo pipefail
install -d -m 0700 /srv/sqlserver-evidence
output_part=/srv/sqlserver-evidence/order-smoke.out.part
output_final=/srv/sqlserver-evidence/order-smoke.out
rm -f "$output_part"
"$SQLCMD" -S "$SERVER" -U lab_runtime -d sqlserver_lab -Nm -b -V 16 -r1 \
-i /srv/release/order-smoke.sql -o "$output_part"
grep -F 'expected_permission_error' "$output_part" >/dev/null
test -s "$output_part"
mv -f "$output_part" "$output_final"正向调用在事务中产生一行后回滚,脚本结束时总行数不变;反向直接 INSERT 应返回权限错误并被 CATCH 记录。若直接 INSERT 成功,脚本主动抛出 51002 并以非零退出,说明 lab_runtime 获得了超出存储过程边界的 DML 权限。动态值由存储过程参数、驱动参数或受控 sqlcmd 变量进入;变量替换是文本替换,不应用于拼接不受信标识符和 SQL 片段。
查询性能先用实际执行计划、逻辑读和耗时建立证据。SET STATISTICS XML ON 返回计划 XML,SET STATISTICS IO/TIME ON 的诊断在 stderr;生产长查询应先在只读副本或预生产复现,并给语句明确筛选范围。
set -euo pipefail
plan_part=/srv/sqlserver-evidence/orders-plan.xml.part
plan_final=/srv/sqlserver-evidence/orders-plan.xml
diagnostic=/srv/sqlserver-evidence/orders-io-time.log
rm -f "$plan_part"
"$SQLCMD" -S "$SERVER" -U lab_runtime -d sqlserver_lab -Nm -b -V 16 -r1 \
-v CustomerId='1' \
-Q "
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SET STATISTICS XML ON;
SELECT TOP (100) order_id,customer_id,amount,created_at
FROM sales.app_order
WHERE customer_id=\$(CustomerId)
ORDER BY created_at DESC,order_id DESC;
SET STATISTICS XML OFF;
SET STATISTICS TIME OFF;
SET STATISTICS IO OFF;" \
> "$plan_part" 2> "$diagnostic"
grep -F '<ShowPlanXML' "$plan_part" >/dev/null
mv -f "$plan_part" "$plan_final"这里的 CustomerId 只允许由发布系统按整数规则校验,普通应用仍使用驱动参数。检查计划中的实际与估算行数、Index Seek/Scan、Key Lookup、Sort、spill 和隐式转换,再结合 Query Store 判断是否回归。不能看到 missing index 建议就直接创建索引;先核对现有索引前缀、写放大和真实查询集合。
让批量交换仍能证明订单没有变形
大量数据交换使用 bcp 或 Bulk Insert,而不是循环执行 INSERT。原生格式适合同版本 SQL Server 间高吞吐传输,字符格式更便于跨系统检查;两者都要显式列顺序、编码、分隔符、批次和错误文件。先由迁移身份创建 staging 表和专用 bulk user,验证行数、类型、重复键与业务约束,再用受控事务合并到目标表。实验中的 lab_bulk login 密码和 lab_runtime 一样由临时密钥存储注入,不能把占位符原样执行。
USE master;
CREATE LOGIN lab_bulk WITH PASSWORD='<TEMP_STRONG_SECRET>',
CHECK_POLICY=ON,CHECK_EXPIRATION=ON;
GO
USE sqlserver_lab;
CREATE SCHEMA staging AUTHORIZATION dbo;
CREATE TABLE staging.order_import
(
order_id bigint NOT NULL,
customer_id bigint NOT NULL,
customer_name nvarchar(100) NOT NULL,
amount decimal(19,4) NOT NULL,
created_at datetime2(3) NOT NULL
);
CREATE USER lab_bulk FOR LOGIN lab_bulk;
CREATE ROLE app_bulk;
ALTER ROLE app_bulk ADD MEMBER lab_bulk;
GRANT SELECT ON OBJECT::sales.app_order TO app_bulk;
GRANT SELECT ON OBJECT::sales.customer TO app_bulk;
GRANT SELECT,INSERT,DELETE ON OBJECT::staging.order_import TO app_bulk;
DENY ALTER ON SCHEMA::sales TO app_bulk;
GOset -euo pipefail
BCP=/opt/mssql-tools18/bin/bcp
export_part=/srv/export/orders-archive.tsv.part
export_final=/srv/export/orders-archive.tsv
reject_part=/srv/import/orders.rejects.part
rm -f "$export_part" "$reject_part"
source_signature="$("$SQLCMD" -S "$SERVER" -U lab_runtime \
-d sqlserver_lab -Nm -b -V 16 -h -1 -W \
-Q "SET NOCOUNT ON; SELECT CONCAT(COUNT_BIG(*),'|',CONVERT(varchar(50),COALESCE(SUM(o.amount),0)),'|',SUM(CASE WHEN c.customer_name=N'实验客户' THEN 1 ELSE 0 END)) FROM sales.app_order o JOIN sales.customer c ON c.customer_id=o.customer_id WHERE c.customer_name=N'实验客户';")"
echo "$source_signature" | grep -Eq '^[0-9]+\|[0-9.]+\|[0-9]+$'
source_count="${source_signature%%|*}"
test "$source_count" -gt 0
"$BCP" "SELECT o.order_id,o.customer_id,c.customer_name,o.amount,o.created_at FROM sales.app_order o JOIN sales.customer c ON c.customer_id=o.customer_id WHERE c.customer_name=N'实验客户'" \
queryout "$export_part" \
-S sql-primary.example.internal,1433 -U lab_bulk -d sqlserver_lab \
-Ys -J /etc/company-ca/sqlserver.pem \
-c -t $'\t' -r $'\n' -q
test -s "$export_part"
mv -f "$export_part" "$export_final"
export_count="$(wc -l < "$export_final")"
test "$export_count" -eq "$source_count"
: "${BULK_SQLCMDPASSWORD:?load lab_bulk password from the secret runner}"
SQLCMDPASSWORD="$BULK_SQLCMDPASSWORD" "$SQLCMD" \
-S "$SERVER" -U lab_bulk -d sqlserver_lab -Nm -b -V 16 \
-Q "SET NOCOUNT ON; DELETE FROM staging.order_import;"
"$BCP" sqlserver_lab.staging.order_import in "$export_final" \
-S sql-primary.example.internal,1433 -U lab_bulk -d sqlserver_lab \
-Ys -J /etc/company-ca/sqlserver.pem \
-c -t $'\t' -r $'\n' -b 10000 \
-e "$reject_part"
test ! -s "$reject_part"
staging_signature="$(SQLCMDPASSWORD="$BULK_SQLCMDPASSWORD" "$SQLCMD" \
-S "$SERVER" -U lab_bulk -d sqlserver_lab -Nm -b -V 16 -h -1 -W \
-Q "SET NOCOUNT ON; SELECT CONCAT(COUNT_BIG(*),'|',CONVERT(varchar(50),COALESCE(SUM(amount),0)),'|',SUM(CASE WHEN customer_name=N'实验客户' THEN 1 ELSE 0 END)) FROM staging.order_import;")"
unset BULK_SQLCMDPASSWORD
test "$staging_signature" = "$source_signature"
sha256sum "$export_final"bcp -U 未提供 -P 时交互询问密码,秘密不进入命令行。自动任务应改用配置完成的 Kerberos/Entra 集成身份或受控 secret runner,不能把 -P 拼进进程参数。这里固定 bcp 18 的 -Ys strict TLS,并用 -J 指定受信 PEM;旧版工具不识别这些参数时必须升级,不能删除验证参数继续。Linux/macOS 的 bcp 不支持 -C 65001,示例使用该平台受支持的字符模式 -c,并通过导出后再导入 staging 的往返、字段类型、行数和业务聚合确认 UTF-8 内容没有失真。set -euo pipefail 保证 bcp 失败立即停止,导出只有成功且非空后才从 .part 原子改名;test ! -s 只证明没有拒绝文件,还要在 staging 内核对 count、checksum 或关键聚合。完成条件是导入行数与来源一致、坏行明确处置、目标唯一约束和外键通过、运行身份不能 TRUNCATE 正式表,导出文件按保留策略加密与销毁。实验完成后先清空 staging,再删除 lab_bulk user、role 与 login。
TLS、TDE 与密钥恢复
受信 TLS 的正反测试与轮换
前置条件是证书私钥仅服务账号可读,EKU 包含 Server Authentication,SAN 含客户端实际连接的 DNS 名,证书链受客户端信任。Linux 示例把证书和私钥放到受控路径。
sudo install -o mssql -g mssql -m 600 server.key /var/opt/mssql/secrets/server.key
sudo install -o mssql -g mssql -m 644 server.crt /var/opt/mssql/secrets/server.crt
sudo /opt/mssql/bin/mssql-conf set network.tlscert /var/opt/mssql/secrets/server.crt
sudo /opt/mssql/bin/mssql-conf set network.tlskey /var/opt/mssql/secrets/server.key
sudo /opt/mssql/bin/mssql-conf set network.forceencryption 1
sudo systemctl restart mssql-server
sudo journalctl -u mssql-server --since '-5 minutes' --no-pager# 固定客户端版本并归档;-Nm 是 mandatory,-Ns 才是 strict
: "${SQLCMDPASSWORD:?load runtime password from the secret runner}"
/opt/mssql-tools18/bin/sqlcmd -? | head -n 3
# 正向一:CA 受信且 server_name 与 SAN 一致;预期 TDS 7.x + TLS
/opt/mssql-tools18/bin/sqlcmd -S 'tcp:sql-lab.example.internal,1433' \
-U lab_runtime -Nm -b \
-Q "SELECT encrypt_option,auth_scheme,protocol_version,client_net_address FROM sys.dm_exec_connections WHERE session_id=@@SPID;"
# 正向二:相同受信名称;预期 TDS 8 strict
/opt/mssql-tools18/bin/sqlcmd -S 'tcp:sql-lab.example.internal,1433' \
-U lab_runtime -Ns -b \
-Q "SELECT SERVERPROPERTY('ProductVersion') AS build,encrypt_option,auth_scheme,protocol_version FROM sys.dm_exec_connections WHERE session_id=@@SPID;"
# 反向:使用不在 SAN 中的名称,必须失败,不能添加 -C
if /opt/mssql-tools18/bin/sqlcmd -S 'tcp:wrong-name.example.internal,1433' \
-U lab_runtime -Ns -b -Q 'SELECT 1;'; then
echo '错误 SAN 反向测试意外成功' >&2; exit 71
fi错误 SAN 的名称必须在一次性客户端上解析到同一 SQL Server,且归档的失败原因必须是证书名称不匹配;DNS 不可达不能冒充证书反证。未知 CA 则换到另一台未安装实验 CA 的一次性客户端,先确认 DNS/TCP 可达,再执行:
set -euo pipefail
: "${SQLCMDPASSWORD:?load runtime password from the secret runner}"
getent hosts sql-lab.example.internal
timeout 5 bash -c '</dev/tcp/sql-lab.example.internal/1433'
if /opt/mssql-tools18/bin/sqlcmd -S 'tcp:sql-lab.example.internal,1433' \
-U lab_runtime -Ns -b -Q 'SELECT 1;'; then
echo '未知 CA 反向测试意外成功' >&2; exit 72
fi正向期望两种模式都显示 encrypt_option=TRUE,strict 还必须同时归档 -Ns、客户端版本、服务端 build 和该连接的 protocol_version。错误 SAN 与未知 CA 均应返回非零;未知 CA 用独立、可销毁且未安装实验 CA 的客户端执行,不能污染生产信任库。若反向测试成功,立即停止验收,检查是否残留 -C、信任绕过或连错实例。正向失败则按客户端 strict 能力、SQL Server build、证书链、SAN、有效期、私钥权限和服务端绑定定位,禁止先关闭强制加密。
轮换前先把新 CA 链分发到客户端并用独立探针验证链,再把新证书/私钥安装到新路径;记录旧的 network.tlscert、network.tlskey 和证书指纹。切换路径并重启后,关闭旧探针连接,从 sqlcmd、JDBC 和 .NET 分别新建 strict 连接,重复正确 SAN、错误 SAN、未知 CA 三组测试并归档新指纹。失败时立即恢复旧路径、重启并重跑同一组正反测试;新证书稳定且旧连接排空后才撤销旧证书及旧 CA。旧连接存活或 encrypt_option=TRUE 都不能替代新握手证据。
TDE 密钥隔离恢复
前置条件是实验库、隔离恢复实例和受 ACL 保护的备份目录。TDE 的恢复依赖 master 中的数据库主密钥、证书和私钥;只复制 .bak 不够。
sudo install -d -o mssql -g mssql -m 0750 \
/var/opt/mssql/backup/keys /var/opt/mssql/restore/keysUSE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD='<OFFLINE_ESCROW_SECRET>';
CREATE CERTIFICATE sqlserver_lab_tde_cert
WITH SUBJECT='sqlserver_lab TDE recovery certificate';
BACKUP CERTIFICATE sqlserver_lab_tde_cert
TO FILE='/var/opt/mssql/backup/keys/sqlserver_lab_tde_cert.cer'
WITH PRIVATE KEY
(
FILE='/var/opt/mssql/backup/keys/sqlserver_lab_tde_cert.pvk',
ENCRYPTION BY PASSWORD='<PRIVATE_KEY_EXPORT_SECRET>'
);
GO
USE sqlserver_lab;
CREATE DATABASE ENCRYPTION KEY
WITH ALGORITHM=AES_256
ENCRYPTION BY SERVER CERTIFICATE sqlserver_lab_tde_cert;
ALTER DATABASE sqlserver_lab SET ENCRYPTION ON;
DECLARE @deadline datetime2(0)=DATEADD(minute,10,SYSUTCDATETIME()),
@state int,@percent real;
WHILE 1=1
BEGIN
SELECT @state=encryption_state,@percent=percent_complete
FROM sys.dm_database_encryption_keys
WHERE database_id=DB_ID(N'sqlserver_lab');
SELECT SYSUTCDATETIME() AS observed_at,@state AS encryption_state,
@percent AS percent_complete;
IF @state=3 BREAK;
IF @state IS NULL OR @state IN (0,1,5)
THROW 51010,N'TDE 未进入加密方向或正在解密,停止备份',1;
IF SYSUTCDATETIME()>=@deadline
THROW 51011,N'TDE 十分钟内未达到 encryption_state=3,停止备份',1;
WAITFOR DELAY '00:00:05';
END;
SELECT DB_NAME(database_id) AS database_name,encryption_state,
percent_complete,key_algorithm,key_length
FROM sys.dm_database_encryption_keys
WHERE database_id=DB_ID(N'sqlserver_lab');
BACKUP DATABASE sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_tde_full.bak'
WITH INIT, CHECKSUM, COMPRESSION;期望只查询到 sqlserver_lab 且十分钟内进入 encryption_state=3;超时、缺行、仍未加密或转为解密都会在 BACKUP 前抛错。此时保存 DMV、errorlog、磁盘吞吐和等待证据,排查密钥、扫描进度与 I/O 后从轮询重新开始,不能在状态未知时继续备份。把合格备份复制到隔离实例后,先做没有证书的反向恢复,期望返回缺少服务器证书及 thumbprint;随后导入密钥并恢复到新库。
USE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD='<ISOLATED_INSTANCE_DMK_SECRET>';
CREATE CERTIFICATE sqlserver_lab_tde_cert
FROM FILE='/var/opt/mssql/restore/keys/sqlserver_lab_tde_cert.cer'
WITH PRIVATE KEY
(
FILE='/var/opt/mssql/restore/keys/sqlserver_lab_tde_cert.pvk',
DECRYPTION BY PASSWORD='<PRIVATE_KEY_EXPORT_SECRET>'
);
RESTORE FILELISTONLY FROM DISK='/var/opt/mssql/restore/sqlserver_lab_tde_full.bak';
RESTORE DATABASE sqlserver_lab_tde_restore
FROM DISK='/var/opt/mssql/restore/sqlserver_lab_tde_full.bak'
WITH MOVE 'sqlserver_lab_data' TO '/var/opt/mssql/data/sqlserver_lab_tde_restore.mdf',
MOVE 'sqlserver_lab_log' TO '/var/opt/mssql/data/sqlserver_lab_tde_restore.ldf',
RECOVERY, CHECKSUM, STATS=5;
DBCC CHECKDB('sqlserver_lab_tde_restore') WITH NO_INFOMSGS, ALL_ERRORMSGS;验证还需执行业务行数、金额汇总和关键主外键抽样。证书和私钥应分别离线托管、双人恢复演练;轮换 TDE 保护器后,旧备份仍可能依赖旧证书,不能提前销毁。实验清理先删除恢复库,再按保留策略销毁临时副本;生产密钥材料不得留在普通备份目录。
备份加密可在 BACKUP ... WITH ENCRYPTION (ALGORITHM=AES_256, SERVER CERTIFICATE=<cert>) 指定,它不自动给活动数据文件加密。Always Encrypted 必须通过支持的驱动、列主密钥和列加密密钥配置,并验证服务端账号无法解密敏感值;密钥不应与数据库同域托管。
Linux AG、Pacemaker 与 fencing
建立 Linux AG 与端点
每个实例先创建独立数据库镜像证书并只导出公钥。私钥只能由 mssql 读取,证书文件通过受控通道互换;下例在每个节点分别执行,文件名必须含节点名。
sudo /opt/mssql/bin/mssql-conf set hadr.hadrenabled 1
sudo systemctl restart mssql-server
sudo systemctl --no-pager --full status mssql-server
sudo journalctl -u mssql-server --since '-5 minutes' --no-pagerUSE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD='<MASTER_KEY_SECRET>';
CREATE CERTIFICATE dbm_certificate
WITH SUBJECT='SQL Server Linux AG endpoint';
BACKUP CERTIFICATE dbm_certificate
TO FILE='/var/opt/mssql/data/<NODE>-dbm.cer';
CREATE ENDPOINT Hadr_endpoint
STATE=STARTED AS TCP (LISTENER_PORT=5022)
FOR DATABASE_MIRRORING
(ROLE=ALL,AUTHENTICATION=CERTIFICATE dbm_certificate,
ENCRYPTION=REQUIRED ALGORITHM AES);把每个对端公钥放到本节点受控路径后,为每个对端创建 login、user 和证书并授予端点连接权;<PEER> 逐一替换,密码来自密码库。
USE master;
CREATE LOGIN [<PEER>_dbm_login] WITH PASSWORD='<PEER_LOGIN_SECRET>';
CREATE USER [<PEER>_dbm_user] FOR LOGIN [<PEER>_dbm_login];
CREATE CERTIFICATE [<PEER>_dbm_cert]
AUTHORIZATION [<PEER>_dbm_user]
FROM FILE='/var/opt/mssql/data/<PEER>-dbm.cer';
GRANT CONNECT ON ENDPOINT::Hadr_endpoint TO [<PEER>_dbm_login];主节点把目标库切到 FULL、完成 full/log 备份后创建 EXTERNAL AG;自动种子只在专用复制网络和容量门禁通过后启用。
CREATE AVAILABILITY GROUP app_ag
WITH (CLUSTER_TYPE=EXTERNAL)
FOR DATABASE sqlserver_lab
REPLICA ON
N'sql1' WITH (ENDPOINT_URL='TCP://sql1.example.internal:5022',
AVAILABILITY_MODE=SYNCHRONOUS_COMMIT,FAILOVER_MODE=EXTERNAL,SEEDING_MODE=AUTOMATIC),
N'sql2' WITH (ENDPOINT_URL='TCP://sql2.example.internal:5022',
AVAILABILITY_MODE=SYNCHRONOUS_COMMIT,FAILOVER_MODE=EXTERNAL,SEEDING_MODE=AUTOMATIC),
N'sql3' WITH (ENDPOINT_URL='TCP://sql3.example.internal:5022',
AVAILABILITY_MODE=ASYNCHRONOUS_COMMIT,FAILOVER_MODE=EXTERNAL,SEEDING_MODE=AUTOMATIC);每个 secondary 执行加入和授权自动建库,然后从主节点确认三副本 connected、目标同步副本为 SYNCHRONIZED、无 suspended,send/redo queue 回到批准阈值。
ALTER AVAILABILITY GROUP app_ag JOIN WITH (CLUSTER_TYPE=EXTERNAL);
ALTER AVAILABILITY GROUP app_ag GRANT CREATE ANY DATABASE;
SELECT ar.replica_server_name,ars.role_desc,ars.connected_state_desc,
drs.synchronization_state_desc,drs.is_suspended,
drs.log_send_queue_size,drs.redo_queue_size
FROM sys.availability_replicas ar
JOIN sys.dm_hadr_availability_replica_states ars ON ars.replica_id=ar.replica_id
LEFT JOIN sys.dm_hadr_database_replica_states drs ON drs.replica_id=ar.replica_id;Pacemaker、VIP 与 fencing
先按真实平台配置并实测 STONITH;没有成功围栏证据就不得创建生产写入口。SQL Server HA 资源代理固定为与引擎相同的 17.0.4075.5-1。以下三台 Ubuntu 22.04 必须是专用新节点;发现任何既有 cluster 配置就停止,不得执行 destroy 覆盖现场。
set -euo pipefail
ha_pkg='17.0.4075.5-1'
sudo apt-get install -y "mssql-server-ha=${ha_pkg}" pacemaker pcs \
fence-agents resource-agents-base resource-agents-common resource-agents-extra
sudo systemctl restart mssql-server
sudo systemctl --no-pager --full status mssql-server
sudo systemctl enable --now pcsd
: "${HACLUSTER_PASSWORD:?load hacluster password from the secret runner}"
printf 'hacluster:%s\n' "$HACLUSTER_PASSWORD" | sudo chpasswd上面一段在三台节点分别执行。资产与平台编排必须提供从未加入其他 cluster 的全新节点;不得在安装运行手册中删除既有 Pacemaker/Corosync 配置。若 pcs status、资产记录或 /etc/corosync/corosync.conf 表明节点已被使用,立即停止并更换节点。确认三台均为空白后,只在 sql1 执行建群命令,认证密码由 pcs 交互读取。
Ubuntu 22.04 使用 UFW 时,在三节点分别只对批准的集群网段开放 Pacemaker、Corosync、TDS 和 AG endpoint;其他防火墙必须实现等价规则。TCP 2224、3121、21064、1433、5022 与 UDP 5405 缺一不可。
set -euo pipefail
cluster_cidr='<APPROVED_CLUSTER_CIDR>'
sudo ufw status | grep -q '^Status: active$' || {
echo 'UFW 未启用,停止并由平台实现等价防火墙规则' >&2; exit 76;
}
for port in 2224 3121 21064 1433 5022; do
sudo ufw allow from "$cluster_cidr" to any port "$port" proto tcp
done
sudo ufw allow from "$cluster_cidr" to any port 5405 proto udp
sudo ufw status verbose
sudo ss -lntup建群后从每个节点到另外两节点分别执行 nc -zvw3 <PEER> 2224、nc -zvw3 <PEER> 3121、nc -zvw3 <PEER> 21064、nc -zvw3 <PEER> 1433 与 nc -zvw3 <PEER> 5022,并结合 ss -lunp、pcs status --full 验证 UDP 5405 的 Corosync 链路。任何方向失败都不得继续创建资源。
set -euo pipefail
sudo pcs host auth sql1 sql2 sql3 -u hacluster
sudo pcs cluster setup app-sql sql1 sql2 sql3
sudo pcs cluster start --all
sudo pcs cluster enable --all
sudo pcs status --fullpcs host auth 在终端读取三节点一致的 hacluster 密码,不把密码放入参数或历史。AG 创建后,在每个 SQL Server 实例创建相同的 Pacemaker 最小登录,并把凭据写入 root-only 文件。
USE master;
CREATE LOGIN PMLogin WITH PASSWORD='<PMLOGIN_SECRET>',CHECK_POLICY=ON;
GRANT VIEW SERVER STATE TO PMLogin;
GRANT ALTER,CONTROL,VIEW DEFINITION
ON AVAILABILITY GROUP::app_ag TO PMLogin;set -euo pipefail
: "${PMLOGIN_PASSWORD:?load PMLogin password from the secret runner}"
sudo test -d /var/opt/mssql/secrets
sudo -u mssql test -x /var/opt/mssql/secrets
printf 'PMLogin\n%s\n' "$PMLOGIN_PASSWORD" |
sudo tee /var/opt/mssql/secrets/passwd >/dev/null
sudo chown root:root /var/opt/mssql/secrets/passwd
sudo chmod 0400 /var/opt/mssql/secrets/passwd
sudo -u mssql test -x /var/opt/mssql/secrets下面以带外 BMC/IPMI 作为明确的 fencing 平台。每个 Pacemaker 节点都必须部署 sql1、sql2、sql3 三套 root-only BMC secret 与同名脚本,使任一 survivor 都能隔离任一目标。示例只展示 sql1 的一套文件;在三台 Pacemaker 节点上重复执行,并为 sql2/sql3 使用各自密码和文件名。密码脚本只输出密钥,不写日志。
set -euo pipefail
: "${BMC_SQL1_PASSWORD:?load sql1 BMC password from the secret runner}"
sudo install -d -o root -g root -m 0700 /etc/pacemaker
printf '%s' "$BMC_SQL1_PASSWORD" | sudo tee /etc/pacemaker/bmc-sql1.secret >/dev/null
sudo chmod 0400 /etc/pacemaker/bmc-sql1.secret
printf '#!/bin/sh\nexec cat /etc/pacemaker/bmc-sql1.secret\n' |
sudo tee /usr/local/sbin/fence-secret-sql1 >/dev/null
sudo chmod 0700 /usr/local/sbin/fence-secret-sql1三台节点的九套本地文件都就绪后,只在 sql1 管理节点把三个 fence device 各创建一次;CIB 是全集群共享配置,不得在其他节点重复创建同名资源。
set -euo pipefail
sudo pcs stonith create fence-sql1 fence_ipmilan \
pcmk_host_list=sql1 ipaddr='<SQL1_BMC_IP>' login='<BMC_USER>' \
passwd_script=/usr/local/sbin/fence-secret-sql1 lanplus=1
sudo pcs stonith create fence-sql2 fence_ipmilan \
pcmk_host_list=sql2 ipaddr='<SQL2_BMC_IP>' login='<BMC_USER>' \
passwd_script=/usr/local/sbin/fence-secret-sql2 lanplus=1
sudo pcs stonith create fence-sql3 fence_ipmilan \
pcmk_host_list=sql3 ipaddr='<SQL3_BMC_IP>' login='<BMC_USER>' \
passwd_script=/usr/local/sbin/fence-secret-sql3 lanplus=1
sudo pcs property set stonith-enabled=true
sudo pcs stonith status
sudo pcs stonith config先从 sql2 和 sql3 分别直接调用 fence_ipmilan -a <SQL1_BMC_IP> -l <BMC_USER> --password-script=/usr/local/sbin/fence-secret-sql1 -P -o status,证明两个 survivor 都能访问 sql1 的 BMC;sql2/sql3 目标按同一矩阵验证。随后在隔离演练窗口把一个 survivor 置于 standby,从另一个 survivor 提交 sudo pcs stonith fence sql1,必须看到 BMC 实际断电且 pcs status 将 sql1 标为离线;恢复并交换 survivor 后重复。三个目标均完成交叉实际围栏并归档证据后,才允许创建 AG 与 VIP 资源。若任何 fence 失败,保持业务入口关闭并修复 BMC 网络、映射或权限。
sudo pcs resource create app_ag ocf:mssql:ag ag_name=app_ag
sudo pcs resource promotable app_ag meta notify=true failure-timeout=60s
sudo pcs resource create app_vip ocf:heartbeat:IPaddr2 \
ip='<APPROVED_VIP>' cidr_netmask='<PREFIX_LENGTH>'
sudo pcs constraint colocation add app_vip with promoted app_ag-clone INFINITY
sudo pcs constraint order promote app_ag-clone then start app_vip
sudo pcs status --full
sudo pcs quorum status
sudo crm_verify -L -V切换前必须同时满足唯一 primary、目标同步副本 SYNCHRONIZED、队列低于阈值、备份可恢复、应用已停止新写。用 pcs resource move app_ag-clone <TARGET_NODE> --promoted --lifetime=PT5M 发起有限迁移,随后从 VIP 新建连接,执行事务 marker 并在旧主验证写入失败。完成后 pcs resource clear app_ag-clone,确认临时约束消失。出现双主、fencing、丢 quorum 或队列超限时立即保持入口栅栏,先隔离旧主再恢复服务;禁止用强制数据丢失故障转移掩盖根因。
Full、Differential、Log、Tail-log 与 STOPAT
Full 备份建立可恢复基线;Differential 只含相对 differential base 的变化;Log 备份维持日志链并支持时间点恢复;Tail-log 在故障后捕获尚未备份的日志。Full 恢复模型下,首次 Full 后必须持续做日志备份。恢复序列使用 NORECOVERY 保持 restoring,最后一次用 RECOVERY 上线。
用业务标记建立可解释的日志链
前置条件是实验库、独立备份盘、足够恢复空间、数据库为 Full 模型。先记录业务标记时间,不用墙钟猜测。
sudo install -d -o mssql -g mssql -m 0750 \
/var/opt/mssql/backup/sqlserver_lab /var/opt/mssql/restoreUSE master;
ALTER DATABASE sqlserver_lab SET RECOVERY FULL;
BACKUP DATABASE sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_full.bak'
WITH INIT, CHECKSUM, COMPRESSION, STATS=5;
GO
USE sqlserver_lab;
CREATE TABLE dbo.recovery_probe
(
probe_id int IDENTITY PRIMARY KEY,
marker nvarchar(50) NOT NULL,
created_at datetime2(3) NOT NULL DEFAULT sysutcdatetime()
);
INSERT dbo.recovery_probe(marker) VALUES(N'full 后');
BACKUP LOG sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_log_01.trn'
WITH INIT, CHECKSUM, COMPRESSION, STATS=5;
INSERT dbo.recovery_probe(marker) VALUES(N'差异前');
BACKUP DATABASE sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_diff.bak'
WITH DIFFERENTIAL, INIT, CHECKSUM, COMPRESSION, STATS=5;
INSERT dbo.recovery_probe(marker) VALUES(N'log 02 内');
BACKUP LOG sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_log_02.trn'
WITH INIT, CHECKSUM, COMPRESSION, STATS=5;
INSERT dbo.recovery_probe(marker) VALUES(N'目标保留');
SELECT sysutcdatetime() AS stop_after_this_time;
WAITFOR DELAY '00:00:05';
INSERT dbo.recovery_probe(marker) VALUES(N'目标排除');在隔离库证明 STOPAT,而不是只检查备份文件
恢复验证与生产切流必须分开。日常演练直接复制既有备份到隔离实例,不改变源库;只有批准的切换窗口才允许 tail-log 使用 WITH NORECOVERY,因为它会把源库留在 RESTORING。执行前先在应用入口停止新写、排空事务并记录唯一 writer,任何门禁失败都停止。
USE master;
ALTER DATABASE sqlserver_lab SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
BACKUP LOG sqlserver_lab
TO DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_tail.trn'
WITH NORECOVERY,INIT,CHECKSUM,COMPRESSION;
RESTORE VERIFYONLY
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_full.bak'
WITH CHECKSUM;
RESTORE HEADERONLY
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_tail.trn';
RESTORE DATABASE sqlserver_lab_pitr
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_full.bak'
WITH MOVE 'sqlserver_lab_data' TO '/var/opt/mssql/data/sqlserver_lab_pitr.mdf',
MOVE 'sqlserver_lab_log' TO '/var/opt/mssql/data/sqlserver_lab_pitr.ldf',
NORECOVERY,CHECKSUM;
RESTORE DATABASE sqlserver_lab_pitr
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_diff.bak'
WITH NORECOVERY,CHECKSUM;
RESTORE LOG sqlserver_lab_pitr
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_log_02.trn'
WITH NORECOVERY,CHECKSUM;
RESTORE LOG sqlserver_lab_pitr
FROM DISK='/var/opt/mssql/backup/sqlserver_lab/sqlserver_lab_tail.trn'
WITH STOPAT='<APPROVED_UTC_TIMESTAMP>',RECOVERY,CHECKSUM;
DBCC CHECKDB(N'sqlserver_lab_pitr') WITH NO_INFOMSGS;
USE sqlserver_lab_pitr;
SELECT marker,COUNT_BIG(*) AS copies
FROM dbo.recovery_probe GROUP BY marker ORDER BY marker;恢复结果必须含“目标保留”且不含“目标排除”,CHECKDB 无错误,业务行数、金额和 schema hash 与基准一致,真实应用身份只读冒烟成功。VERIFYONLY 只证明介质结构可读,不能替代实际 RESTORE。LSN gap、证书缺失、路径冲突或强校验失败时保持入口关闭;若目标尚未接收写入,可在审批后对源库执行 RESTORE DATABASE sqlserver_lab WITH RECOVERY 并重开入口。目标一旦接收写入,回退前必须先解决增量数据归并,禁止同时开放两个 writer。
SQL Server Agent 作业
Linux 上的 SQL Server Agent 随引擎配置启用,由同一 mssql-server systemd 服务承载。先保存原配置,再启用并重启服务;作业 owner 使用不可登录或受控低权主体,不使用 sa。
sudo /opt/mssql/bin/mssql-conf get sqlagent.enabled
sudo /opt/mssql/bin/mssql-conf set sqlagent.enabled true
sudo systemctl restart mssql-server
sudo systemctl --no-pager --full status mssql-serverUSE msdb;
EXEC dbo.sp_add_job @job_name=N'lab_backup_verify',@enabled=1;
EXEC dbo.sp_add_jobstep @job_name=N'lab_backup_verify',
@step_name=N'check recent backup',@subsystem=N'TSQL',
@database_name=N'msdb',
@command=N'IF NOT EXISTS (SELECT 1 FROM backupset WHERE database_name=N''sqlserver_lab'' AND backup_finish_date>DATEADD(hour,-25,SYSUTCDATETIME())) THROW 51030,N''backup stale'',1;';
EXEC dbo.sp_add_schedule @schedule_name=N'lab_daily_utc',
@freq_type=4,@freq_interval=1,@active_start_time=10000;
EXEC dbo.sp_attach_schedule @job_name=N'lab_backup_verify',@schedule_name=N'lab_daily_utc';
EXEC dbo.sp_add_jobserver @job_name=N'lab_backup_verify';
EXEC dbo.sp_start_job @job_name=N'lab_backup_verify';通过 msdb.dbo.sysjobactivity 等待真实 stop_execution_date,再用 sp_help_jobhistory 核对最终 run_status=1。超时或失败时保留历史,按 Agent 服务、owner、数据库权限、步骤错误和 msdb 容量定位;不要用固定 sleep 或手工补跑宣称成功。实验结束后删除作业与计划,并恢复原 sqlagent.enabled。
把订单链接入应用
前面的 sales.app_order 已经经历约束、事务、权限和 sqlcmd 反例。应用接入继续使用订单号和状态,而不是另起一套博客表;这样 Java 测试的提交、回滚、最小权限与后续备份恢复观察的是同一份业务状态。
固定依赖、连接和身份
下面是一套可独立编译的 Java 17 最小项目。Maven 固定 Microsoft JDBC Driver 13.4.0.jre11、HikariCP 6.3.3、Flyway 13.4.0 和 JUnit;migration 与 runtime 使用不同身份,密码只从进程环境注入。
sqlserver-app/
├─ pom.xml
└─ src/
├─ main/java/example/Database.java
├─ main/java/example/OrderService.java
├─ main/resources/db/migration/V1__create_app_order.sql
└─ test/java/example/OrderServiceTest.java<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId><artifactId>sqlserver-app</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release></properties>
<dependencies>
<dependency><groupId>com.microsoft.sqlserver</groupId><artifactId>mssql-jdbc</artifactId><version>13.4.0.jre11</version></dependency>
<dependency><groupId>com.zaxxer</groupId><artifactId>HikariCP</artifactId><version>6.3.3</version></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId><version>13.4.0</version></dependency>
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-database-sqlserver</artifactId><version>13.4.0</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>5.13.4</version><scope>test</scope></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version>
<configuration><release>17</release></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
</plugins></build>
</project>管理员只负责创建身份与最小权限。Flyway 可在 sales schema 建表,runtime 只能 CRUD,且不能 DDL。
USE master;
CREATE LOGIN order_migrator WITH PASSWORD='<MIGRATION_SECRET>',CHECK_POLICY=ON;
CREATE LOGIN order_app WITH PASSWORD='<APP_SECRET>',CHECK_POLICY=ON;
GO
USE sqlserver_lab;
IF SCHEMA_ID(N'sales') IS NULL EXEC(N'CREATE SCHEMA sales AUTHORIZATION dbo');
CREATE USER order_migrator FOR LOGIN order_migrator;
CREATE USER order_app FOR LOGIN order_app;
CREATE ROLE order_migration; CREATE ROLE order_runtime;
ALTER ROLE order_migration ADD MEMBER order_migrator;
ALTER ROLE order_runtime ADD MEMBER order_app;
GRANT CREATE TABLE TO order_migration;
GRANT ALTER ON SCHEMA::sales TO order_migration;
GRANT SELECT,INSERT,UPDATE,DELETE ON SCHEMA::sales TO order_runtime;
DENY ALTER,CONTROL ON SCHEMA::sales TO order_runtime;用迁移创建同一张订单表
V1__create_app_order.sql 复用前面的订单状态约束,并把稳定业务键放在数据库唯一约束中。已有环境已经手工创建该表时,应先建立受控 baseline 或在隔离库重建,不能让 Flyway 与人工 DDL 同时拥有对象。
CREATE TABLE sales.app_order(
order_id bigint IDENTITY PRIMARY KEY,
order_no varchar(40) NOT NULL CONSTRAINT UQ_app_order_no UNIQUE,
customer_id bigint NOT NULL,
status varchar(20) NOT NULL CONSTRAINT CK_app_order_status
CHECK (status IN ('CREATED','PAID','CANCELLED')),
amount decimal(12,2) NOT NULL CONSTRAINT CK_app_order_amount CHECK (amount>=0),
version rowversion NOT NULL,
created_at datetime2(3) NOT NULL DEFAULT SYSUTCDATETIME()
);Database.java 由单实例部署任务先执行 migration;应用进程只调用 pool()。SQL_URL 使用 jdbc:sqlserver://sql.example.internal:1433;databaseName=sqlserver_lab;encrypt=strict;trustServerCertificate=false;hostNameInCertificate=sql.example.internal;multiSubnetFailover=true;applicationName=order-api。
package example;
import com.zaxxer.hikari.*;
import org.flywaydb.core.Flyway;
public final class Database {
private static String env(String name) {
var value=System.getenv(name);
if(value==null||value.isBlank()) throw new IllegalStateException("missing "+name);
return value;
}
public static void migrate() {
Flyway.configure().dataSource(env("SQL_URL"),env("SQL_MIGRATION_USER"),
env("SQL_MIGRATION_PASSWORD")).defaultSchema("sales").schemas("sales")
.locations("classpath:db/migration").validateMigrationNaming(true)
.load().migrate();
}
public static HikariDataSource pool() {
var c=new HikariConfig();
c.setJdbcUrl(env("SQL_URL")); c.setUsername(env("SQL_APP_USER"));
c.setPassword(env("SQL_APP_PASSWORD")); c.setMaximumPoolSize(12);
c.setMinimumIdle(2); c.setConnectionTimeout(3000);
c.setValidationTimeout(1000); c.setLeakDetectionThreshold(15000);
c.setAutoCommit(false); return new HikariDataSource(c);
}
private Database() {}
}在一个事务里创建并支付订单
OrderService.java 使用参数绑定、显式事务、查询超时和 try-with-resources。只有 1205 deadlock 会触发最多三次整事务重试;权限、约束、超时和未知提交结果不能一概重试。order_no 是幂等收敛键,重试前后都查询它,而不是重新生成业务身份。
package example;
import javax.sql.DataSource;
import java.sql.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.locks.LockSupport;
public final class OrderService {
private final DataSource ds;
public OrderService(DataSource ds){this.ds=ds;}
public long createAndPay(String orderNo,long customerId,String amount) throws SQLException {
for(int attempt=1;attempt<=3;attempt++){
try{return createOnce(orderNo,customerId,amount);}
catch(SQLException e){
if(e.getErrorCode()!=1205||attempt==3) throw e;
long ms=50L*attempt+ThreadLocalRandom.current().nextLong(40);
LockSupport.parkNanos(ms*1_000_000L);
}
}
throw new SQLException("retry exhausted");
}
private long createOnce(String orderNo,long customerId,String amount) throws SQLException {
try(var c=ds.getConnection()){
try(var q=c.prepareStatement(
"INSERT sales.app_order(order_no,customer_id,status,amount) "
+ "OUTPUT inserted.order_id VALUES (?,?,'CREATED',?)")){
q.setQueryTimeout(3);q.setString(1,orderNo);q.setLong(2,customerId);
q.setBigDecimal(3,new java.math.BigDecimal(amount));
try(var r=q.executeQuery()){
if(!r.next())throw new SQLException("missing id");
long id=r.getLong(1);
try(var pay=c.prepareStatement(
"UPDATE sales.app_order SET status='PAID' WHERE order_no=? AND status='CREATED'")){
pay.setQueryTimeout(3);pay.setString(1,orderNo);
if(pay.executeUpdate()!=1)throw new SQLException("order state did not advance");
}
c.commit();return id;
}
}catch(SQLException e){c.rollback();throw e;}
}
}
public long count() throws SQLException {
try(var c=ds.getConnection();var q=c.prepareStatement("SELECT COUNT_BIG(*) FROM sales.app_order")){
q.setQueryTimeout(3);try(var r=q.executeQuery()){r.next();return r.getLong(1);}
}
}
public String status(String orderNo) throws SQLException {
try(var c=ds.getConnection();var q=c.prepareStatement("SELECT status FROM sales.app_order WHERE order_no=?")){
q.setQueryTimeout(3);q.setString(1,orderNo);
try(var r=q.executeQuery()){if(!r.next())throw new SQLException("not found");return r.getString(1);}
}
}
public boolean exists(String orderNo) throws SQLException {
try(var c=ds.getConnection();var q=c.prepareStatement(
"SELECT CASE WHEN EXISTS(SELECT 1 FROM sales.app_order WHERE order_no=?) THEN 1 ELSE 0 END")){
q.setQueryTimeout(3);q.setString(1,orderNo);
try(var r=q.executeQuery()){r.next();return r.getInt(1)==1;}
}
}
public void deleteTestOrders() throws SQLException {
try(var c=ds.getConnection();var q=c.prepareStatement(
"DELETE FROM sales.app_order WHERE order_no LIKE 'ORD-JAVA-%'")){
q.setQueryTimeout(3);q.executeUpdate();c.commit();
}
}
public void attemptDdl() throws SQLException {
try(var c=ds.getConnection();var q=c.prepareStatement("DROP TABLE sales.app_order")){
q.setQueryTimeout(3);q.execute();c.commit();
}
}
}用正反测试证明提交、回滚和最小权限
OrderServiceTest.java 在真实 SQL Server 上验证订单状态提交、金额约束回滚、最小权限和连接归还。测试数据使用固定前缀,重复执行前先在隔离库清理该前缀;共享环境不能运行破坏性测试。
package example;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.*;
import java.sql.SQLException;
import static org.junit.jupiter.api.Assertions.*;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
final class OrderServiceTest {
private HikariDataSource ds;
private OrderService service;
@BeforeAll void start(){Database.migrate();ds=Database.pool();service=new OrderService(ds);}
@BeforeEach void cleanBefore() throws Exception {service.deleteTestOrders();}
@AfterEach void cleanAfter() throws Exception {service.deleteTestOrders();}
@AfterAll void stop(){if(ds!=null)ds.close();}
@Test void commitRollbackLeastPrivilegeAndPoolReturn() throws Exception {
long before=service.count();
service.createAndPay("ORD-JAVA-1001",101,"199.00");
assertEquals("PAID",service.status("ORD-JAVA-1001"));
assertThrows(SQLException.class,
()->service.createAndPay("ORD-JAVA-BAD",101,"-1.00"));
assertFalse(service.exists("ORD-JAVA-BAD"));
assertEquals(before+1,service.count());
assertThrows(SQLException.class,service::attemptDdl);
assertEquals(0,ds.getHikariPoolMXBean().getActiveConnections());
}
}以系统 Maven 执行,不依赖正文未提供的 Wrapper。迁移任务完成后立即销毁 migration secret 注入环境;应用测试只保留 runtime 凭据。
export SQL_MIGRATION_USER=order_migrator SQL_APP_USER=order_app
: "${SQL_MIGRATION_PASSWORD:?}" "${SQL_APP_PASSWORD:?}" "${SQL_URL:?}"
mvn -B -DskipTests=false verifyAG 演练在事务之间迁移 VIP:旧连接允许自然失败,新连接必须在池超时内落到唯一 primary;未确认提交的请求只按业务幂等键重放。故障定位顺序是 DNS/VIP、TCP、证书链与 SAN、TDS strict、认证和目标库、连接池、查询超时。禁止通过 trustServerCertificate=true、明文连接、扩大池或改用 sa 掩盖根因。
补丁、兼容级别与可逆升级迁移
补丁改变 build,兼容级别控制部分数据库查询处理和语义,两者是不同旋钮。升级引擎后数据库通常保留原兼容级别,先完成正确性与性能观察,再单独提高兼容级别并利用 Query Store 比较。备份通常只能向相同或更高版本恢复,不能把新版本数据库备份还原回旧引擎;这是回退设计的核心约束。
Side-by-side 迁移
跨主版本优先新建并行 Linux 实例。变更前保存源端完整 build、Edition、兼容级别、实例级 login SID、Agent job、链接服务器、TDE 证书和数据库 owner;对每个源库执行 CHECKDB,并把 full/log 备份在隔离实例实际恢复。目标端先导入证书和最小权限身份,再恢复到新路径,保持入口关闭。
BACKUP DATABASE [<SOURCE_DB>]
TO DISK='/var/opt/mssql/backup/migration/source_full.bak'
WITH COPY_ONLY,COMPRESSION,CHECKSUM,INIT;
BACKUP LOG [<SOURCE_DB>]
TO DISK='/var/opt/mssql/backup/migration/source_log.trn'
WITH COMPRESSION,CHECKSUM,INIT;
RESTORE VERIFYONLY
FROM DISK='/var/opt/mssql/backup/migration/source_full.bak'
WITH CHECKSUM;切换窗口先停止新写并排空事务,再做 tail-log;把备份通过带 SHA-256 校验的受控通道复制到目标故障域,依次恢复 full、log、tail-log,最后 WITH RECOVERY。在目标执行 CHECKDB、schema hash、业务行数与金额强校验、最小权限正反测试和应用冒烟;只有全部通过才原子切换 DNS/VIP。源端保持只读或网络栅栏直到回退窗结束,不能同时保留两个 writer。若切换后强校验失败,立即关闭新入口并按已记录的反向 DNS/VIP 操作回源;在新端已接收写入后,必须先决定数据合并策略,不能无条件回切。
Linux 补丁与版本升级
Linux 节点升级必须先固定仓库通道和完整包版本,确认备份已在隔离实例恢复、TDE 证书与私钥可用、应用驱动支持目标版本,并保存当前 build、Edition、兼容级别、Query Store 与实例级对象。升级过程中一次只改变一个节点;AG 场景先升级 secondary,待其恢复为 SYNCHRONIZED、无 suspended 且 send/redo queue 回到阈值后,才能进入计划切换。
set -euo pipefail
target_pkg='17.0.4075.5-1'
: "${SQLCMDPASSWORD:?load validation password from the secret runner}"
sudo apt-get update
apt-cache madison mssql-server
sudo ACCEPT_EULA=Y apt-get install -y "mssql-server=${target_pkg}"
sudo systemctl restart mssql-server
sudo systemctl --no-pager --full status mssql-server
sudo journalctl -u mssql-server --since '-10 minutes' --no-pager
/opt/mssql-tools18/bin/sqlcmd -S 'tcp:localhost,1433' -U '<VALIDATION_LOGIN>' \
-N -b \
-Q "SELECT SERVERPROPERTY('ProductVersion'),SERVERPROPERTY('Edition');"期望包管理器只安装批准版本,服务恢复为 active,errorlog 完成数据库 recovery,查询返回目标完整 build。任一步失败就保持业务入口栅栏,保留日志并按已演练的节点镜像或包卸载能力回退;不要假设跨主版本数据库可以直接降级。引擎稳定后先保持原兼容级别,完成事务、恢复、性能和故障转移回归,再单独审批提高兼容级别。
实验收尾与恢复标准
实验结束前先保存需要的死锁图、Query Store 对照、备份链、CHECKDB、AG 同步与正反权限证据。确认应用已离开实验资源后,删除 Agent 作业、实验 listener/AG、辅助副本和恢复库,再删除 sqlserver_lab。证书私钥、备份和卷按各自保留策略处理,不能因为“只是实验”散落到仓库或共享盘。
系统可以恢复服务不等于业务恢复。统一恢复标准是:目标身份从真实客户端经受信 TLS 建连;授权操作成功且越权失败;关键事务、约束与金额/行数强校验正确;CHECKDB 无错误;备份链在隔离位置实际恢复;等待、CPU、内存、I/O、tempdb、日志和容量回到基线;AG/FCI 集群只有一个合法主角色,listener、quorum 和 fencing 符合设计;Agent、告警和下一次备份均已成功。
官方核对入口
| 主题 | Microsoft 官方资料 |
|---|---|
| Edition 与能力 | SQL Server editions and supported features |
| 当前 build | SQL Server 2025 版本 build、SQL Server 2022 版本 build |
| Linux 安装、升级与容器 | Install SQL Server on Linux、Deploy SQL Server Linux containers、mssql-docker |
| 页、extent 与日志 | Pages and extents architecture、Transaction log architecture |
| ADR 与行版本 | Accelerated database recovery、Manage ADR and PVS filegroup、PVS DMV、tempdb version store DMV |
| 身份与 contained user | CREATE USER、Contained database users |
| Query Store | Monitor performance by using Query Store |
| TLS 与 TDS 8 | TDS 8.0、Encrypt connections on Linux |
| TDE 与 Always Encrypted | Transparent Data Encryption、Always Encrypted |
| 备份恢复与 CHECKDB | Backup and restore、DBCC CHECKDB |
| Linux AG 与 Pacemaker | Create Linux availability group、Deploy Pacemaker cluster、Linux availability group failover |
| JDBC | Microsoft JDBC Driver releases |
四、问题处理
SQL Server 故障先分清三层:客户端有没有到达实例,实例有没有接受身份,语句进入引擎后在等待什么。把所有超时都归为“数据库慢”,会让网络、TLS、登录、锁、内存授予和存储问题互相掩盖。
服务无法启动
应用同时出现连接拒绝,systemctl 又显示 failed 时,先在数据库主机保留本次启动日志:
sudo systemctl --no-pager --full status mssql-server
sudo journalctl -u mssql-server -n 200 --no-pager
sudo tail -n 200 /var/opt/mssql/log/errorlog
df -hT /var/opt/mssql /var/opt/mssql/data
df -i /var/opt/mssql /var/opt/mssql/data
sudo -u mssql test -r /var/opt/mssql/mssql.conf
sudo -u mssql test -w /var/opt/mssql/data日志停在 recovery 并持续推进,和一启动就因为目录权限、磁盘已满、证书私钥不可读或无效参数退出,是不同问题。先修日志明确指出的目录、容量或配置;补丁后首次启动还要给系统数据库升级留下时间,不能用连续重启把 recovery 反复打断。
服务恢复后,从应用所在网段使用真实驱动与受信 TLS 执行 SELECT @@SERVERNAME, DB_NAME(),再检查 SQL Server Agent 和下一次备份。只有本机 SELECT 1 成功,不能证明远端解析、证书、目标数据库和作业链已经恢复。
地址可达,但 TLS 或登录失败
Connection refused 说明还没进入 SQL Server;TLS 握手失败说明网络已通但身份链未建立;18456 则说明请求已经到达引擎。按这个顺序定位:
getent ahosts sql-primary.example.internal
nc -vz sql-primary.example.internal 1433
openssl s_client \
-connect sql-primary.example.internal:1433 \
-servername sql-primary.example.internal \
-showcerts </dev/null随后在服务端读取 18456 对应时间窗的 errorlog。state 会帮助区分账号不存在、密码错误、login 被禁用、默认数据库不可用等原因。能登录实例却访问不了业务库时,检查 login 与 database user 的 SID:
SELECT name, is_disabled, default_database_name
FROM sys.server_principals
WHERE name = N'app_runtime';
USE app;
SELECT dp.name, dp.authentication_type_desc, dp.sid,
sp.name AS mapped_login
FROM sys.database_principals AS dp
LEFT JOIN sys.server_principals AS sp ON sp.sid = dp.sid
WHERE dp.name = N'app_runtime';普通 orphan user 使用 ALTER USER app_runtime WITH LOGIN = app_runtime 重新映射;contained 或 external user 不能套用这条修复。证书问题要修 CA、SAN、有效期、私钥权限或服务绑定,不能把 TrustServerCertificate=true 变成永久配置。恢复后要同时证明正确主机名成功、错误主机名失败,并确认会话的 encrypt_option=TRUE。
阻塞和死锁不是同一种故障
请求排队但 CPU 不高时,先找 head blocker;收到 1205 时,读取死锁图。阻塞是一条等待链,死锁是等待环,处理方式不同。
SELECT r.session_id,
r.status,
r.wait_type,
r.wait_time,
r.blocking_session_id,
DB_NAME(r.database_id) AS database_name,
t.text
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.session_id <> @@SPID
ORDER BY r.wait_time DESC;
DBCC OPENTRAN;head blocker 如果正在完成正常事务,贸然 KILL 会把等待换成长时间回滚。先联系事务拥有者提交或回滚;确实失控时,再评估回滚量并精确终止 session。死锁则从 system_health 的 xml_deadlock_report 看 victim、资源和访问顺序,统一对象访问顺序、缩短事务或补索引。应用只对完整且幂等的事务有限重试 1205,不能只重放最后一条语句。
确认恢复时,要看到等待链清空、开放事务回到基线、业务结果正确,并在相同并发下不再形成等待环。长期预防依赖事务时长、阻塞时长与 deadlock graph 的持续采集,而不是定时清理连接。
tempdb 已满或出现持续 PAGELATCH 等待
tempdb 同时承载临时表、排序、哈希、游标和行版本。先区分“空间被谁使用”和“分配页发生争用”:
USE tempdb;
SELECT name,
size * 8.0 / 1024 AS size_mb,
FILEPROPERTY(name, 'SpaceUsed') * 8.0 / 1024 AS used_mb,
growth,
is_percent_growth
FROM sys.database_files;
SELECT SUM(user_object_reserved_page_count) * 8.0 / 1024 AS user_objects_mb,
SUM(internal_object_reserved_page_count) * 8.0 / 1024 AS internal_objects_mb,
SUM(version_store_reserved_page_count) * 8.0 / 1024 AS version_store_mb
FROM sys.dm_db_file_space_usage;内部对象高通常指向大排序、hash spill 或索引维护;version store 高要查长快照事务和 RCSI/SNAPSHOT 使用;PAGELATCH 集中在分配页时再评估等大小数据文件。先暂停失控查询或批处理并扩展已经验证的 tempdb 卷,根治查询、内存授予、事务时长与固定增长配置。重启只会暂时重建 tempdb,不能代替找出增长源。
9002 或事务日志磁盘即将耗尽
先读取恢复模型、日志占用和复用等待:
SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases
WHERE name = N'app';
USE app;
SELECT total_log_size_in_bytes / 1048576.0 AS total_log_mb,
used_log_space_in_bytes / 1048576.0 AS used_log_mb,
used_log_space_in_percent
FROM sys.dm_db_log_space_usage;
DBCC OPENTRAN;LOG_BACKUP 表示 Full 或 Bulk-logged 恢复模型缺少日志备份;ACTIVE_TRANSACTION 指向未结束事务;AVAILABILITY_REPLICA 则要看 AG 的发送和重做队列。恢复对应链路并为日志卷增加安全余量,不能删 .ldf、随意切 Simple 或反复 shrink。日志能够复用、下一份日志备份进入连续链、磁盘回到容量线并完成新写入,才算恢复。
慢 SQL、统计陈旧与计划回归
先使用 Query Store 找到相同 query 的历史计划和运行时变化,再用实际参数检查当前计划。不要从全库建索引或清计划缓存开始:
SELECT TOP (20)
qt.query_sql_text,
p.plan_id,
rs.avg_duration,
rs.avg_cpu_time,
rs.avg_logical_io_reads,
rsi.start_time,
rsi.end_time
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_plan AS p ON p.plan_id = rs.plan_id
JOIN sys.query_store_query AS q ON q.query_id = p.query_id
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_runtime_stats_interval AS rsi
ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
ORDER BY rsi.end_time DESC, rs.avg_duration DESC;估算行数与实际行数偏离时检查统计新鲜度、数据倾斜、参数敏感计划和隐式转换;大量逻辑读检查访问路径;RESOURCE_SEMAPHORE 检查内存授予;tempdb spill 则继续查排序、hash 和并发。已经验证过的旧计划可以临时强制止血,但修好统计、查询或索引并通过真实参数族压测后应解除强制,避免永久冻结在过时计划。
Availability Group 不同步或失去 quorum
先确认集群仍有唯一合法 primary,再看每个数据库的同步状态与队列:
SELECT ar.replica_server_name,
ars.role_desc,
ars.connected_state_desc,
drs.database_state_desc,
drs.synchronization_state_desc,
drs.synchronization_health_desc,
drs.log_send_queue_size,
drs.redo_queue_size,
drs.last_hardened_lsn,
drs.last_redone_time
FROM sys.availability_replicas AS ar
JOIN sys.dm_hadr_availability_replica_states AS ars
ON ars.replica_id = ar.replica_id
JOIN sys.dm_hadr_database_replica_states AS drs
ON drs.replica_id = ar.replica_id;发送队列增长多与端点网络、辅助副本磁盘或 suspended data movement 有关;redo 队列增长说明日志已经到达但应用不过来。quorum 丢失时先隔离另一分区并恢复仲裁或网络,不能在两边分别强制上线。只有旧主已被 fencing、目标副本满足数据丢失边界时,才按演练过的模式切换。
恢复后要确认只有一个 primary、quorum 稳定、队列持续回落、listener 指向正确节点,并从真实应用连接完成写读。节点显示 ONLINE 但仍存在第二条写路径,不能关闭事故。
备份文件存在,但恢复链不完整
先用 RESTORE HEADERONLY 和 RESTORE FILELISTONLY 读取介质,不直接覆盖源数据库:
RESTORE VERIFYONLY
FROM DISK = N'/srv/backup/app-full.bak'
WITH CHECKSUM;
RESTORE HEADERONLY
FROM DISK = N'/srv/backup/app-full.bak';
RESTORE FILELISTONLY
FROM DISK = N'/srv/backup/app-full.bak';Full、Differential 和 Log 必须沿 DatabaseBackupLSN、DifferentialBaseLSN、FirstLSN 与 LastLSN 组成连续链。TDE 数据库还需要对应服务器证书和私钥;缺失密钥时不要尝试移除加密或重建证书冒充原密钥。VERIFYONLY 只能发现部分介质问题,真正验收仍要在隔离实例按 NORECOVERY 顺序恢复到新库,最后 RECOVERY。
恢复完成后运行 DBCC CHECKDB、关键行数与金额聚合,再让只读应用执行真实查询。RPO 由最后可恢复 LSN 与事故时刻决定,RTO 由从开始恢复到业务验证完成的实际用时决定,不由备份任务的绿色状态决定。
823、824 和 825 要按数据损坏风险处理
823 是操作系统 I/O 错误,824 是页级逻辑一致性错误,825 表示读取重试后成功。825 不是“已经自愈”,它常常是存储故障的早期信号。
SELECT database_id, file_id, page_id, event_type,
error_count, last_update_date
FROM msdb.dbo.suspect_pages
ORDER BY last_update_date DESC;
DBCC CHECKDB(N'app') WITH NO_INFOMSGS, ALL_ERRORMSGS;先保护现有备份、errorlog 和存储日志,隔离故障存储,再判断使用页恢复、数据库恢复或健康副本。REPAIR_ALLOW_DATA_LOSS 只在没有可用恢复点且业务明确接受丢数据时,才在副本上评估;它不是默认修复命令。新存储上的实际恢复、CHECKDB、业务一致性和持续 I/O 观测都通过后,才能恢复流量。
补丁、驱动或兼容级别变化后错误率上升
先确认改变的是引擎 build、数据库兼容级别、驱动版本还是连接策略。side-by-side 升级尚未切流时,停止新节点并保留旧入口最简单;已经切流并产生新写入后,回退必须解决增量数据,不能把旧库直接重新开放为主库。
驱动升级后,用相同身份分别比较 sqlcmd 和应用连接,检查 TLS strict、DNS、池上限、取消与超时、数据类型和 listener 切换。引擎升级后再检查 CHECKDB、Query Store、Agent、备份和 AG。恢复的判断不是“错误率下降”,而是目标 build 与驱动制品明确、事务结果正确、连接池能够回收、关键计划与延迟回到预算,并且新的备份可以在隔离实例恢复。
