dbaai 发表于 2026-9-17 07:50:09

数据库容量规划实战:增长预测与扩容决策

做 DBA 这些年,最怕的不是数据库挂了,而是某天早上收到一条磁盘告警:/data 使用率 91%。挂了有应急预案,慢慢涨到 90% 才是真的折磨人——你不知道它是明天满,还是三个月后满,也不知道该清数据、加硬盘,还是该上分区。这篇就把容量规划这件事拆成能落地的动作:量什么、怎么预测、什么时候该做哪种扩容。

一、具体的问题

多数团队的容量管理停留在"看剩余百分比"这个层面,于是反复踩下面这几个坑:


[*]只看空闲率,不看增速。 剩 30% 看着很安全,但如果上个月涨了 10%,三个月后就得半夜扩容。反过来剩 15% 也不一定危险——如果那是一张半年没动过的历史表。
[*]只看数据文件,不看日志和临时空间。 我见过最典型的一次:数据目录 60G 稳如老狗,结果一个没加 WHERE 的大批量 UPDATE 把 binlog 撑到 240G,先挂的是 binlog 所在盘。undo 表空间、tempdb、归档目录、临时表空间同理,它们才是突然爆仓的那一个。
[*]没有历史数据,全凭感觉。 出事的时候才去翻监控,发现只保留 15 天,压根算不出月增速。容量这件事,没有历史采样就没有预测。
[*]清理等于删数据,不考虑归档和压缩。 一刀切删掉历史数据,业务下周要查去年的对账单,又得从备份里捞,捞回来还得找地方放——比不清理还麻烦。
[*]扩容方案只有"加盘"一种。 加盘最省事,也最贵、最不可持续。真正的决策顺序应该是:清理 → 归档 → 压缩 → 分区 → 加盘 → 分库分表,越往后成本越高。


二、核心原理

1. 容量要量三个口径,不是一个


[*]已用容量(used):当前真实占用,最容易拿,也最没信息量。
[*]增速(growth):单位时间的增量,决定你还有多少缓冲期。这是核心指标。
[*]水位与阈值(threshold):不是拍脑袋定 80%,而是按"从告警到扩容完成需要多久"反推。假设扩容流程要走 3 天审批 + 1 天执行,那告警线就应设在"剩余可撑 7 天"的位置,而不是固定百分比。


2. 三种可用的预测算法


方法算法适用场景缺点
简单线性外推月增量 = (本次 - 上次) / 间隔月数增长平稳的业务表遇到促销、双十一会严重低估
近 N 期移动平均取最近 3 期月增量的平均值有波动,想平滑掉异常月对趋势转折反应慢
复合增长率 CAGR(末值/初值)^(1/月数) - 1长期指数型增长短期样本少时不稳定


实际工作里我用得最多的是移动平均 + 最坏值双轨:平时看移动平均定采购计划,风险评估看最近 3 期的最大值。两者差得远,说明增长不稳定,预警线要再往上抬。

3. 剩余天数怎么算才靠谱


剩余天数 = (可用容量 - 安全预留) / 日均增量


关键是安全预留这一项不能省。我一般按总容量的 10% 或"最大单表的一次性增长量"取大者。很多数据库在磁盘 100% 时会直接崩且难以原地恢复,留出不至于踩到 100% 的缓冲,比预测精度更重要。

4. 扩容决策树

按单位成本从低到高排,永远先试便宜的:


[*]清理:真的没用的数据(临时表、过期会话、孤儿大对象)——零成本。
[*]归档:冷数据挪到历史库/对象存储,主库只留近 N 个月——低成本,需要改造查询入口。
[*]压缩:InnoDB 表压缩、Oracle 表压缩、PG 的 TOAST——几乎零改造,代价是少量 CPU。
[*]分区:按时间分区,冷分区直接 detach 或换到廉价存储——需要改表结构,收益是清理变成 O(1) 的 DDL。
[*]加盘:最快见效,成本线性上升,治标。
[*]分库分表:成本最高,只有在单实例确实撑不住写入时才动。


三、实例参考(动手步骤)

下面这套在 MySQL 8.0 上跑通,Oracle 和 PG 的等价语句我一并给出。

步骤 1:先建采样表,从今天开始攒数据

这一步最容易被跳过,但它是后面所有预测的基础。没有历史,什么都算不出来。


CREATE TABLE IF NOT EXISTS ops_cap_snapshot (
id         BIGINT AUTO_INCREMENT PRIMARY KEY,
snap_date    DATE NOT NULL,
db_name      VARCHAR(64) NOT NULL,
data_mb      DECIMAL(18,2) NOT NULL,
idx_mb       DECIMAL(18,2) NOT NULL,
total_mb   DECIMAL(18,2) NOT NULL,
rows_cnt   BIGINT DEFAULT 0,
UNIQUE KEY uk_day_db (snap_date, db_name)
);


步骤 2:每天采一次全库占用


INSERT INTO ops_cap_snapshot (snap_date, db_name, data_mb, idx_mb, total_mb, rows_cnt)
SELECT CURDATE(),
       table_schema,
       ROUND(SUM(data_length)/1024/1024, 2),
       ROUND(SUM(index_length)/1024/1024, 2),
       ROUND(SUM(data_length + index_length)/1024/1024, 2),
       SUM(table_rows)
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql','sys','performance_schema','information_schema')
GROUP BY table_schema
ON DUPLICATE KEY UPDATE
data_mb = VALUES(data_mb), idx_mb = VALUES(idx_mb),
total_mb = VALUES(total_mb), rows_cnt = VALUES(rows_cnt);


配个 cron 每天凌晨跑一次即可:


0 3 * * * mysql -udbamon -p'xxx' ops -e "source /opt/dba/cap_snapshot.sql" >> /var/log/dba/cap.log 2>&1


Oracle 等价:


SELECT owner, ROUND(SUM(bytes)/1024/1024, 2) AS total_mb
FROM dba_segments
WHERE owner NOT IN ('SYS','SYSTEM')
GROUP BY owner ORDER BY total_mb DESC;

-- 有 AWR 许可的,直接拿历史快照,连采样都省了
SELECT s.snap_id, TO_CHAR(s.begin_interval_time,'YYYY-MM-DD') snap_day,
       o.owner, ROUND(SUM(t.space_used_total)/1024/1024, 2) mb
FROM dba_hist_seg_stat t
JOIN dba_hist_snapshot s ON s.snap_id = t.snap_id
JOIN dba_objects o ON o.object_id = t.obj#
GROUP BY s.snap_id, s.begin_interval_time, o.owner
ORDER BY s.snap_id;


PostgreSQL 等价:


SELECT schemaname,
       ROUND(SUM(pg_total_relation_size(schemaname||'.'||tablename))/1024/1024, 2) AS total_mb
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
GROUP BY schemaname ORDER BY total_mb DESC;


步骤 3:算增速并外推剩余天数


WITH base AS (
SELECT db_name, snap_date, total_mb,
         LAG(total_mb) OVER (PARTITION BY db_name ORDER BY snap_date) AS prev_mb,
         LAG(snap_date) OVER (PARTITION BY db_name ORDER BY snap_date) AS prev_date
FROM ops_cap_snapshot
),
delta AS (
SELECT db_name, snap_date,
         total_mb - prev_mb AS add_mb,
         DATEDIFF(snap_date, prev_date) AS days
FROM base WHERE prev_mb IS NOT NULL AND DATEDIFF(snap_date, prev_date) > 0
),
rate AS (
SELECT db_name,
         AVG(add_mb/days) AS avg_mb_per_day,
         MAX(add_mb/days) AS worst_mb_per_day,
         COUNT(*) AS samples
FROM delta WHERE snap_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY db_name
),
cur AS (
SELECT db_name, total_mb FROM ops_cap_snapshot
WHERE snap_date = (SELECT MAX(snap_date) FROM ops_cap_snapshot)
)
SELECT c.db_name,
       ROUND(c.total_mb/1024, 2) AS cur_gb,
       ROUND(r.avg_mb_per_day, 2) AS avg_mb_day,
       ROUND(r.worst_mb_per_day, 2) AS worst_mb_day,
       r.samples,
       ROUND((500*1024 - c.total_mb) / NULLIF(r.avg_mb_per_day, 0)) AS days_left_avg,
       ROUND((500*1024 - c.total_mb) / NULLIF(r.worst_mb_per_day, 0)) AS days_left_worst
FROM cur c JOIN rate r USING (db_name)
ORDER BY days_left_worst;


上面的 500*1024 换成你的实际可用容量(MB)。重点看 days_left_worst 这一列——它才是你可能被叫醒的时间点。

步骤 4:定位到底是谁在涨


SELECT table_schema, table_name,
       ROUND((data_length+index_length)/1024/1024, 2) AS mb,
       table_rows,
       ROUND(index_length/1024/1024, 2) AS idx_mb,
       ROUND(index_length/GREATEST(data_length,1), 2) AS idx_ratio
FROM information_schema.tables
WHERE table_schema = 'orderdb'
ORDER BY mb DESC LIMIT 20;


看两个信号:idx_ratio 大于 1.5 说明索引比数据还大,多半有重复索引或冗余前缀索引;表里 table_rows 不大但 mb 很大,多半是碎片,或者有大字段被删过没回收。

步骤 5:按顺序做治理,并做前后对比

真实案例,一个订单库 orderdb,可用容量 500G,治理前水位 421G:


-- 1) 清理:干掉重复索引(先确认再用)
SELECT * FROM sys.schema_redundant_indexes WHERE table_schema='orderdb';

-- 2) 归档:把两年前数据挪走,分批删避免长事务
INSERT INTO archive_db.t_order_hist SELECT * FROM orderdb.t_order WHERE create_time < '2024-01-01';
DELETE FROM orderdb.t_order WHERE create_time < '2024-01-01' LIMIT 5000;-- 循环执行直到影响 0 行

-- 3) 压缩:对归档库大表开压缩
ALTER TABLE archive_db.t_order_hist ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8;

-- 4) 碎片回收(会锁表,放维护窗口)
ALTER TABLE orderdb.t_order ENGINE=InnoDB;


治理前后对比:


项目治理前清理重复索引后归档两年前数据后归档+压缩后
orderdb 占用421 GB392 GB214 GB158 GB
水位84%78%43%32%
日均增量1.9 GB/天1.9 GB/天0.85 GB/天0.62 GB/天
按最坏值剩余天数38 天52 天121 天176 天
是否加盘必须必须不必不必


一次归档+压缩,把 38 天的紧张局面拉到 176 天,省掉了这次扩容采购。这就是"先做便宜的"的价值。

步骤 6:别漏了非数据文件的空间


# binlog / 归档 / 临时目录,这些才是突然爆仓的元凶
du -sh /var/lib/mysql/binlog /var/lib/mysql/undotbs /var/lib/mysql/tmp 2>/dev/null

# MySQL 自动清理 binlog,别设太长
SET GLOBAL binlog_expire_logs_seconds = 604800;   -- 保留 7 天

# Oracle 归档目录满了会直接 hang 住整个库
RMAN> DELETE NOPROMPT ARCHIVELOG ALL COMPLETED BEFORE 'SYSDATE-7';


四、实操检查清单


[*][ ] 今天就建采样表,哪怕只有一行。容量预测的前提是历史数据,晚一天开始就少一天样本。
[*][ ] 采样脚本放进 cron,并加失败告警(脚本静默失败是最常见的断供原因)。
[*][ ] 监控指标里同时有 used、日均增量、剩余天数,不能只有百分比。
[*][ ] 告警阈值按"扩容流程耗时 + 缓冲"反推,不要固定写 80%。
[*][ ] 同时监控 binlog / undo / temp / 归档目录,这几处爆仓的概率高于数据文件。
[*][ ] 预测同时给出平均增速和最坏增速两条线,采购看平均、告警看最坏。
[*][ ] 每月跑一次"谁在涨"的 TOP 20 排查,关注 index/data 比值大于 1.5 的表。
[*][ ] 大表删除一律分批(每次 5000 行 + sleep),避免长事务和主从延迟。
[*][ ] 时间字段的大表优先改分区,冷分区直接归档,把 DELETE 变成 DDL。
[*][ ] 归档前先跟业务确认保留期,并用 INSERT ... SELECT 落历史库后再删,别直接 DELETE。
[*][ ] ALTER TABLE ... ENGINE=InnoDB 会锁表,必须放维护窗口,先在从库验证耗时。
[*][ ] 每次治理后回填对比表,让"省下多少"可见——这是下一次申请资源时的最好证据。
[*][ ] 每次扩容后重新校准预测模型,业务增长曲线变了,旧结论就作废。


容量规划这件事,技术含量不高,但它是少数几个"做了就能明确省钱、不做就一定会在半夜出事"的工作。核心就一句:把百分比换成天数。你说"还剩 30%",没人有感觉;你说"按最坏增速 38 天后满",所有人都会动起来。
页: [1]
查看完整版本: 数据库容量规划实战:增长预测与扩容决策