RDS CPU高达94%:错误表上缺少索引每月多花$566——以及为什么缩容前必须先测量
发布于 2026年5月1日
起点:8个vCPU下CPU高达94%
一家金融科技客户的RDS运行在db.r6g.2xlarge上——8个vCPU,64 GB内存,按需付费$747/月。干预前一周,CPU在周四峰值达到94.1%。其他工作日:27%、43%,频繁超过20%。
内存有57.4 GB空闲(共64 GB)——90%闲置。连接数:平均1.6到2.9,正常情况下最大15。数据库没有连接或内存压力,只有CPU压力。
关系型数据库中内存闲置、连接数低但CPU高,通常指向一个原因:查询在做本应由索引完成的工作。数据库正在扫描整张表来查找本可通过索引在微秒内找到的行。
审计:查询最频繁的表没有任何索引
开发团队在代码库中留下的索引脚本涵盖了13张表——会话、分析、cookie、用户、方案、加盟、佣金、废弃购物车、发票。其中没有'transactions'表。
'transactions'表是整个系统中查询最频繁的表。每个重要操作都要经过它:
加盟商报告:每页数十次查询,过滤条件为加盟商 + 交易状态
跟踪定时任务(每15分钟执行一次EventBridge):在pad + 跟踪最后更新时间上进行全表扫描
导出:没有索引的ORDER BY last_event_date DESC = 在48k+行上进行文件排序
结账时拒绝重复:每笔新交易都按买家 + 状态进行查找
审计时表中有52,678行,每次没有索引的查询都扫描约48k条活跃记录。跟踪定时任务每15分钟触发一次,排队约1,480个任务——每个任务都在没有索引的'transactions'表上执行一次选择查询。
添加前的基准测试:证明工作价值的数字
在创建任何索引之前,使用EXPLAIN和实时计时执行了关键查询。所有对'transactions'表的查询在EXPLAIN中都返回'type=ALL, key=NULL'——全表扫描确认:
-- 跟踪定时任务:34.48毫秒,扫描48,858行
EXPLAIN SELECT * FROM orders
WHERE tracking_code IS NOT NULL
AND pad = 1
AND (tracking_last_description IS NULL
OR tracking_last_description NOT IN (
'最终状态1', '最终状态2'
))
AND (tracking_last_update IS NULL
OR tracking_last_update
BETWEEN (NOW() - INTERVAL 1 MONTH) AND (NOW() - INTERVAL 30 MINUTE));
-- type: ALL | key: NULL | rows: 48858 | Extra: Using where
-- 导出:31.17毫秒 + 文件排序
EXPLAIN SELECT * FROM orders ORDER BY last_event_date DESC LIMIT 100;
-- type: ALL | key: NULL | rows: 48858 | Extra: Using filesort
-- 用户认证:20.76毫秒
EXPLAIN SELECT * FROM user WHERE login = 'email@example.com';
-- type: ALL | key: NULL | rows: 5148115个基准测试查询的总时间(添加索引前):约624毫秒。添加索引后,目标是同一组查询低于200毫秒。
索引:设计选择
'transactions'表新增了7个索引。这些选择基于代码中的实际查询,而不是'值多样性高的列'这一标准:
-- 关键:报告查询(频繁过滤条件)
CREATE INDEX idx_tx_reseller_status
ON orders (reseller, order_status) ALGORITHM=INPLACE LOCK=NONE;
CREATE INDEX idx_tx_vendor_status
ON orders (vendor, order_status) ALGORITHM=INPLACE LOCK=NONE;
-- 关键:每15分钟一次的跟踪定时任务
CREATE INDEX idx_tx_tracking
ON orders (pad, tracking_last_update) ALGORITHM=INPLACE LOCK=NONE;
-- 高:结账时拒绝重复
CREATE INDEX idx_tx_buyer_status
ON orders (buyer, order_status) ALGORITHM=INPLACE LOCK=NONE;
-- 高:带ORDER BY的导出
CREATE INDEX idx_tx_last_event_date
ON orders (last_event_date) ALGORITHM=INPLACE LOCK=NONE;
-- 高:按时间范围查询
CREATE INDEX idx_tx_created_at
ON orders (created_at) ALGORITHM=INPLACE LOCK=NONE;
-- 高:单独的状态过滤
CREATE INDEX idx_tx_status
ON orders (order_status) ALGORITHM=INPLACE LOCK=NONE;在MariaDB 10.11+的活跃生产表上,'ALGORITHM=INPLACE LOCK=NONE'是必须的。没有它,索引创建会获取元数据锁,在整个操作期间阻塞对表的读写。使用INPLACE,表保持可用。
为什么用复合索引而不是简单索引? 查询'reseller IS NOT NULL AND order_status IN (4, 17)'从复合索引(reseller, order_status)中获得的好处远比两个独立的简单索引多。优化器使用复合索引直接过滤——有两个简单索引时,它选择一个,另一个作为过滤器,降低了效果。
执行:23个索引,3.91秒
23个索引通过直接在容器中执行的PHP脚本在生产环境中创建。总时间:3.91秒。零错误。零停机。
创建后立即执行的添加后基准测试:
| 查询 | 添加前 | 添加后 | 提升倍数 |
|----------------------------------|-----------------|-------------------------------------------|---------|
| transactions ORDER BY last_event_date | ALL 31.17ms | index/idx_tx_last_event_date 0.71ms | 43.9x |
| user login | ALL 20.76ms | ref/idx_usuario_login 0.75ms | 27.7x |
| transactions created_at 30天 | ALL 30.91ms | range/idx_tx_created_at 1.82ms | 17x |
| analytics created_at 7天 | ALL 120.25ms | range/idx_analytics_created_at 11.38ms | 10.6x |
| session created_at 30天 | ALL 88.83ms | range/idx_sessao_created_at 9.06ms | 9.8x |
| transactions order_status | ALL 32.23ms | range/idx_tx_status 5.49ms | 5.9x |
| cookies created_at 30天 | ALL 130.33ms | range/idx_cookies_created_at 31.34ms | 4.2x |总体减少:15个基准测试查询的总时间减少了75%。13个进行全表扫描的查询中有12个消除了ALL。
CloudWatch的影响:CPU峰值94.1% → 26%
添加索引前一周(3月3-7日,db.r6g.2xlarge):
| 日期 | 星期 | 平均值 | 最高峰值 |
|---------|------|--------|---------|
| 3月3日 | 周一 | 2.52% | 10.6% |
| 3月4日 | 周二 | 2.57% | 43.6% |
| 3月5日 | 周三 | 2.50% | 27.3% |
| 3月6日 | 周四 | 2.54% | 94.1% |
| 3月7日 | 周五 | 1.74% | 19.0% |
| 平均 | | 2.37% | 38.9% |添加索引后一周(3月10-14日,同一个db.r6g.2xlarge实例):
| 日期 | 星期 | 平均值 | 最高峰值 |
|---------|------|--------|---------|
| 3月10日 | 周一 | 2.30% | 14.0% |
| 3月11日 | 周二 | 2.94% | 26.0% |
| 3月12日 | 周三 | 2.42% | 11.7% |
| 3月13日 | 周四 | 2.17% | 8.3% |
| 3月14日 | 周五 | 1.51% | 8.1% |
| 平均 | | 2.27% | 13.6% |绝对峰值:从94.1%降至26.0%。峰值平均值:从38.9%降至13.6%——降低65%。第二周唯一超过20%的峰值(周二26%)是由运维部署事件引起的,而非数据库负载。
正确的顺序:先添加索引,后缩容
经过完整工作周的索引验证后,才可以用真实数据进行缩容。逻辑很简单:如果在添加索引前就缩容,你是在为异常负载调整实例大小——存在本不应该有的全表扫描。
在缩容前购买预留实例是最昂贵的错误: 你会将1年或3年锁定在一个添加索引后必然需要降低的规格上。预先付费购买db.r6g.2xlarge的预留实例,然后因为要迁移到xlarge而废弃,代表着损失的资金。
正确的顺序:
1. 添加索引:消除全表扫描,测量CPU的实际影响
2. 等待完整工作周:在真实生产负载下收集CPU指标
3. 缩容:按添加索引后的负载来调整,而非添加索引前的负载
4. 再等待一个完整工作周:验证较小的实例能够承受负载
5. 预留实例:只有在正确规格通过真实数据确认后才购买
缩容:db.r6g.2xlarge → xlarge
有了3月10-14日确认最高峰值26%(且该峰值是运维事件而非真实负载)的数据,db.r6g.xlarge(4个vCPU)的预测很直接:vCPU减半,百分比翻倍。
缩容通过'--no-apply-immediately'安排(在下一个维护窗口,周六01:00 BRT执行)。RDS在维护窗口期间不可用约10-15分钟。
使用db.r6g.xlarge的第一个完整工作周(3月17-21日):
| 日期 | 星期 | 平均值 | 最高峰值 |
|---------|------|--------|---------|
| 3月17日 | 周一 | 5.00% | 31.6% |
| 3月18日 | 周二 | 4.52% | 39.4% |
| 3月19日 | 周三 | 4.75% | 40.9% |
| 3月20日 | 周四 | 4.18% | 20.1% |
| 3月21日 | 周五 | 2.76% | 14.4% |
| 平均 | | 4.24% | 29.3% |周三最高峰值40.9%——这个峰值发生在当地时间下午3点的一分钟内(社交媒体流量突发)。该小时的平均值为8.2%。内存:32 GB中有30 GB空闲——舒适。缩容已验证。
进一步缩减到db.r6g.large(2个vCPU)被放弃:预测最高峰值约82%,对于不可预见的峰值没有安全余量。每月额外节省$213不值得承担这个风险。
预留实例:每月额外节省$193
经过完整一周的验证确认规格正确后,购买了预留实例:db.r6g.xlarge,1年,无预付款,Single-AZ,MariaDB,us-east-1。
| 预留实例选项 | 预付款 | 每小时 | 每月等效 | 相比OD节省 |
|-----------------|---------|---------|----------|----------------|
| 按需付费 | $0 | $0.519 | $374/月 | — |
| 1年无预付款 | $0 | $0.248 | $181/月 | $193/月 (52%) |
| 1年部分预付款 | $1,035 | $0.118 | $172/月 | $202/月 (54%) |
| 1年全额预付款 | $2,028 | $0 | $169/月 | $205/月 (55%) |选择:1年无预付款($0预付款,$0.248/小时)。原因:无预付款和全额预付款之间的差异是每月$12——不值得锁定$2,028的资金。3年预留实例相比1年无预付款每月多节省$65,但需要36个月的确定性。
最终结果:$566/月,$6,792/年
完整的数据库优化周期(不含EC2预留实例,那是另一篇文章):
| 步骤 | 每月节省 | 累计月费 |
|----------------------------------|---------|----------|
| 基准:db.r6g.2xlarge 按需付费 | — | $747/月 |
| 添加索引 + 缩容后(xlarge) | $373/月 | $374/月 |
| 1年无预付款预留实例后 | $193/月 | $181/月 |
| 总节省 | $566/月 | $6,792/年|'transactions'表没有任何索引。这是这个案例中最重要的细节。这不是高级数据库调优的问题——而是最重要的表上缺少基础工作。RDS每月支付$747,去做本应由索引免费完成的工作。
如何复制这个优化周期
将'RDS CPU高'转化为'经过优化且成本正确的数据库'的五个步骤:
1. 审计实际查询:从代码中收集10-20个最频繁的查询(而非估算),使用EXPLAIN执行,在最高量的表上识别全表扫描。
2. 使用ALGORITHM=INPLACE LOCK=NONE创建索引:在活跃的生产表上,始终如此。否则,索引创建可能会锁表数分钟。
3. 添加前后的基准测试:记录每个关键查询的访问类型(ALL vs ref vs range)、扫描行数和执行时间。没有基准测试,你不知道真实影响。
4. 在工作周后再缩容:永远不要基于添加索引前的CPU指标来调整大小。等待优化后的真实数据。
5. 在验证缩容后才购买预留实例:只有在正确规格通过确认后才进行预留。在错误规格上购买预留实例是会被废弃的锁定资金。
