在设备数据采集系统中,多张参数表的数据量以每月数百万行的速度增长。随着时间推移,单表查询性能逐渐下降,历史数据维护成本不断攀升。本文介绍一种完全自动化的按月分区方案,通过动态识别业务数据起始时间,自动生成分区边界,实现零配置部署。

核心技术点:
┌─────────────────────────────────────┐│ 多张设备参数表 ││ ├── 表1: xxx参数数据 ││ ├── 表2: xxx参数数据 ││ ├── ... ││ └── 表N: xxx参数数据 ││ 共同特征:均有 create_time 时间戳 │└─────────────────────────────────────┘
| 候选方案 | 优点 | 缺点 | 是否采用 |
|---|---|---|---|
| 按年分区 | 管理简单 | 分区过大,查询收益低 | ❌ |
| 按月分区 | 粒度适中,消除效果好 | 需定期维护 | ✅ |
| 按周分区 | 精度高 | 分区过多,管理复杂 | ❌ |
RANGE RIGHT 的核心语义:边界值属于右侧分区。
分区函数定义:CREATE PARTITION FUNCTION PF_Monthly(datetime2)AS RANGE RIGHT FOR VALUES('2023-06-01','2023-07-01','2023-08-01')实际分区映射:┌──────────┬─────────────────┬─────────────────┬──────────────────┐│ 分区 1 │ 分区 2 │ 分区 3 │ 分区 4 ││ (-∞, │ [2023-06-01, │ [2023-07-01, │ [2023-08-01, ││ 2023-06) │ 2023-07-01) │ 2023-08-01) │ +∞) │└──────────┴─────────────────┴─────────────────┴──────────────────┘为什么选择 RANGE RIGHT?
-- 查询6月数据时,WHERE条件自然对应当月:SELECT * FROM table WHERE create_time >= '2023-06-01' AND create_time < '2023-07-01'-- RANGE RIGHT 下,'2023-06-01' 归入分区2(6月)-- 分区消除精准命中,不会跨区
原始数据最早时间: 2023-06-15 08:30:00 ↓ 对齐到月初分区起始边界: 2023-06-01好处:✓ 每个分区完整对应一个自然月✓ 查询逻辑直观,不需要记住偏移量✓ 运维时按自然月扩展/合并,不易出错
-- ============================================================-- 脚本功能:动态创建按月分区函数-- 适用场景:多张业务表需要统一按月分区-- 特性:-- 1. 自动识别数据起始时间-- 2. 自动预留未来12个月分区-- 3. 支持重复执行(幂等)-- 4. 空表保护-- ============================================================DECLARE @MinDate DATE, -- 最早数据日期 @LoopDate DATE, -- 循环游标 @FutureEnd DATE, -- 分区终点 @ValStr NVARCHAR(MAX) = N'',-- 边界值拼接 @SqlFunc NVARCHAR(MAX); -- 动态SQL-- ─────────────────────────────────────────-- 步骤1:联合查询所有目标表的最小时间-- ─────────────────────────────────────────SELECT @MinDate = MIN(t.MinDT)FROM ( SELECT CAST(MIN(create_time) AS DATE) FROM biz_param_data_table1 UNION ALL SELECT CAST(MIN(create_time) AS DATE) FROM biz_param_data_table2 UNION ALL SELECT CAST(MIN(create_time) AS DATE) FROM biz_param_data_table3 -- ... 追加更多表) t;-- ─────────────────────────────────────────-- 步骤2:空数据处理 + 月初对齐-- ─────────────────────────────────────────IF @MinDate IS NULL SET @MinDate = DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);SET @MinDate = DATEFROMPARTS(YEAR(@MinDate), MONTH(@MinDate), 1);SET @FutureEnd = DATEADD(MONTH, 12, GETDATE());SET @LoopDate = @MinDate;-- ─────────────────────────────────────────-- 步骤3:生成边界值列表-- ─────────────────────────────────────────WHILE @LoopDate <= @FutureEndBEGIN SET @ValStr += N'''' + CONVERT(VARCHAR, @LoopDate, 120) + N''' ,'; SET @LoopDate = DATEADD(MONTH, 1, @LoopDate);ENDSET @ValStr = LEFT(@ValStr, LEN(@ValStr) - 1);-- ─────────────────────────────────────────-- 步骤4:幂等创建分区函数-- ─────────────────────────────────────────IF NOT EXISTS ( SELECT 1 FROM sys.partition_functions WHERE name = 'PF_Month_Device_Data')BEGIN SET @SqlFunc = N' CREATE PARTITION FUNCTION PF_Month_Device_Data(datetime2) AS RANGE RIGHT FOR VALUES(' + @ValStr + N'); '; EXEC sp_executesql @SqlFunc; PRINT '分区函数创建成功。边界数量: ' + CAST(LEN(@ValStr)-LEN(REPLACE(@ValStr,',',''))+1 AS VARCHAR);ENDELSE PRINT '分区函数已存在,跳过创建。';-- ============================================================-- 创建分区方案,指定文件组映射-- ============================================================IF NOT EXISTS ( SELECT 1 FROM sys.partition_schemes WHERE name = 'PS_Month_Device_Data')BEGIN CREATE PARTITION SCHEME PS_Month_Device_Data AS PARTITION PF_Month_Device_Data ALL TO ([PRIMARY]); PRINT '分区方案创建成功。';END
-- ============================================================-- 为业务表创建分区聚集索引-- ⚠️ 执行前请确认处于业务低峰期-- ============================================================CREATE CLUSTERED INDEX IX_TableName_create_time ON biz_param_data_table1(create_time) ON PS_Month_Device_Data(create_time);
┌────────────────────────────────────────────────────────────┐│ 脚本执行流程 │└────────────────────────────────────────────────────────────┘ 开始 │ ▼┌─────────────────┐ 空 ┌──────────────────┐│ 查询所有表最早 │─────────→│ 使用当前月1号 ││ create_time │ 数据 │ 作为起始边界 │└────────┬────────┘ └────────┬─────────┘ │ 有数据 │ ▼ ▼┌─────────────────────────────────────────┐│ 将最早时间对齐到当月1号 ││ DATEFROMPARTS(YEAR, MONTH, 1) │└────────────────────┬────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ 计算终止边界 = GETDATE() + 12个月 │└────────────────────┬────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ WHILE 循环生成边界字符串 ││ '2023-06-01','2023-07-01',... │└────────────────────┬────────────────────┘ │ ▼┌─────────────────────────────────────────┐│ 检查分区函数是否存在 ││ 不存在 → 动态执行 CREATE PARTITION ││ 已存在 → 跳过 │└─────────────────────────────────────────┘ │ ▼ 结束
-- ❌ 错误写法:直接拼日期,易出现语言/格式问题SET @ValStr += @LoopDate + ',';-- ✅ 正确写法:CONVERT 指定 style 120 (yyyy-mm-dd)SET @ValStr += N'''' + CONVERT(VARCHAR, @LoopDate, 120) + N''' ,';
style 120 对照表:
| Style | 格式 | 示例 |
|---|---|---|
| 120 | ODBC 规范 | yyyy-mm-dd hh:mi:ss |
| 23 | ISO 日期 | yyyy-mm-dd |
| 112 | 紧凑格式 | yyyymmdd |
-- 循环拼接后的字符串:-- '2023-06-01' ,'2023-07-01' ,'2023-08-01' ,-- ↑ 多余逗号-- 去除尾逗号,保留有效边界:SET @ValStr = LEFT(@ValStr, LEN(@ValStr) - 1);-- 结果:'2023-06-01' ,'2023-07-01' ,'2023-08-01'
CREATE PARTITION FUNCTION PF_Month_Device_Data(datetime2) -- ← 这里
为什么用 datetime2 而非 datetime?
| 特性 | datetime | datetime2 |
|---|---|---|
| 精度 | 3.33ms | 100ns |
| 日期范围 | 1753-9999 | 0001-9999 |
| 存储空间 | 8字节 | 6-8字节 |
| ANSI兼容 | ❌ | ✅ |
datetime2 精度更高、范围更广,且能兼容 date、datetime 的隐式转换。
-- 通过系统视图检查对象是否存在IF NOT EXISTS ( SELECT 1 FROM sys.partition_functions WHERE name = 'PF_Month_Device_Data')
| 系统视图 | 用途 |
|---|---|
sys.partition_functions | 查询分区函数 |
sys.partition_schemes | 查询分区方案 |
sys.partition_range_values | 查询分区边界值 |
-- 查看全部分区边界及对应的分区号SELECT p.boundary_id AS 边界序号, p.value AS 边界值, p.boundary_id + 1 AS 对应分区号FROM sys.partition_functions pfJOIN sys.partition_range_values p ON p.function_id = pf.function_idWHERE pf.name = 'PF_Month_Device_Data'ORDER BY p.boundary_id;
输出示例:
| 边界序号 | 边界值 | 对应分区号 |
|---|---|---|
| 1 | 2023-06-01 | 2 |
| 2 | 2023-07-01 | 3 |
| 3 | 2023-08-01 | 4 |
分区1 无边界值,存储所有小于
2023-06-01的数据
-- 查看每个分区的数据量及时间范围SELECT $PARTITION.PF_Month_Device_Data(create_time) AS 分区号, COUNT(*) AS 记录数, MIN(create_time) AS 最早记录, MAX(create_time) AS 最晚记录FROM biz_param_data_table1GROUP BY $PARTITION.PF_Month_Device_Data(create_time)ORDER BY 分区号;
-- 开启统计信息,验证是否仅扫描目标分区SET STATISTICS IO ON;SELECT COUNT(*) FROM biz_param_data_table1WHERE create_time >= '2024-03-01' AND create_time < '2024-04-01';SET STATISTICS IO OFF;-- 查看消息窗口的 "逻辑读取" 次数-- 正确分区消除时,读取页数应远小于全表
-- 建议每月1号定时执行DECLARE @NewMonth DATE = DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);SET @NewMonth = DATEADD(MONTH, 1, @NewMonth);ALTER PARTITION SCHEME PS_Month_Device_Data NEXT USED [PRIMARY];ALTER PARTITION FUNCTION PF_Month_Device_Data() SPLIT RANGE (@NewMonth);PRINT '已新增分区边界: ' + CAST(@NewMonth AS VARCHAR);
-- 将指定月份的数据快速迁出-- 第1步:创建结构相同的归档表SELECT TOP 0 * INTO biz_param_data_table1_archive_202306FROM biz_param_data_table1;-- 第2步:切换分区(秒级完成,仅修改元数据)ALTER TABLE biz_param_data_table1 SWITCH PARTITION 2 TO biz_param_data_table1_archive_202306;-- 第3步:合并空分区ALTER PARTITION FUNCTION PF_Month_Device_Data() MERGE RANGE ('2023-07-01');-- ============================================================-- 月度分区维护作业-- 执行频率:每月1号 02:00-- ============================================================BEGIN TRY BEGIN TRANSACTION; -- 1. 扩展新月份 DECLARE @NewBoundary DATE; SET @NewBoundary = DATEADD(MONTH, 1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1)); ALTER PARTITION SCHEME PS_Month_Device_Data NEXT USED [PRIMARY]; ALTER PARTITION FUNCTION PF_Month_Device_Data() SPLIT RANGE (@NewBoundary); -- 2. 日志记录 INSERT INTO maintenance_log (operation, detail, exec_time) VALUES ('PARTITION_SPLIT', '边界值:' + CAST(@NewBoundary AS VARCHAR), GETDATE()); COMMIT TRANSACTION; PRINT '分区维护成功完成';END TRYBEGIN CATCH ROLLBACK TRANSACTION; PRINT '分区维护失败: ' + ERROR_MESSAGE();END CATCH不会。 脚本内置空数据保护:
IF @MinDate IS NULL SET @MinDate = DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);
检查两点:
SELECT t.name AS 表名, i.name AS 索引名, ps.name AS 分区方案FROM sys.tables tJOIN sys.indexes i ON t.object_id = i.object_idJOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_idWHERE i.type = 1; -- 1 = CLUSTERED
WHERE create_time = '2024-03-15'WHERE create_time >= '2024-03-01' AND create_time < '2024-04-01'WHERE YEAR(create_time) = 2024 AND MONTH(create_time) = 3WHERE CONVERT(VARCHAR, create_time, 23) >= '2024-03-01'已做幂等保护: 脚本检查 sys.partition_functions 系统视图,对象存在则跳过创建。
UNION:会排序去重,7张表的结果需要排序比对UNION ALL:直接拼接,性能更高这里我们只需要取全局最小值,去重无意义,
UNION ALL是最优选择。
| 函数 | 功能 | 示例 | 返回值 |
|---|---|---|---|
CAST(x AS type) | 类型转换 | CAST('2024-01-01' AS DATE) | 2024-01-01 |
MIN() | 取最小值 | MIN(create_time) | 最早时间 |
YEAR() | 提取年份 | YEAR('2024-06-15') | 2024 |
MONTH() | 提取月份 | MONTH('2024-06-15') | 6 |
DATEFROMPARTS() | 拼装日期 | DATEFROMPARTS(2024,6,1) | 2024-06-01 |
GETDATE() | 当前时间 | GETDATE() | 2026-07-20 14:30:00 |
DATEADD() | 日期运算 | DATEADD(MONTH,1,'2024-06-01') | 2024-07-01 |
CONVERT(type,x,style) | 格式化转换 | CONVERT(VARCHAR,GETDATE(),120) | 2026-07-20 14:30:00 |
LEN() | 字符串长度 | LEN('abc') | 3 |
LEFT() | 左截取 | LEFT('hello',3) | hel |
$PARTITION.func(val) | 返回分区号 | $PARTITION.pf(create_time) | 3 |
| 特性 | 实现方式 | 收益 |
|---|---|---|
| 自动化 | 动态识别最小时间,自动生成边界 | 零手动配置 |
| 健壮性 | 空数据保护 + 幂等设计 | 可重复执行 |
| 可维护 | 统一分区函数,统一边界规则 | 运维标准化 |
| 扩展性 | 预留12个月 + SPLIT扩展 | 长期免维护 |
| 操作 | 分区前 | 分区后 | 提升幅度 |
|---|---|---|---|
| 单月范围查询 | 全表扫描 | 分区扫描 | ~90% |
| 历史数据归档 | DELETE 大事务 | SWITCH 秒级 | ~99% |
| 索引重建 | 整表锁 | 分区级锁 | ~70% |
参考资料
- SQL Server 分区表官方文档
- CREATE PARTITION FUNCTION (Transact-SQL)
- $PARTITION (Transact-SQL)