Buzeli
buzeliSoluções Digitais
成本

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: 51481

15个基准测试查询的总时间(添加索引前):约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. 在验证缩容后才购买预留实例:只有在正确规格通过确认后才进行预留。在错误规格上购买预留实例是会被废弃的锁定资金。