1. 商户日终余额查询的业务背景
在支付结算系统中,商户日终余额查询是最基础也最频繁的操作之一。这个看似简单的功能背后,实际上承载着复杂的业务逻辑:
- 需要聚合当日所有交易流水(包括成功、失败、退款等不同状态)
- 要考虑不同渠道的结算周期差异(如T+0、T+1等)
- 需处理各种特殊场景(如部分结算、手续费抵扣等)
我经历过一个真实案例:某电商平台在促销期间,日终余额查询的响应时间从平时的200ms飙升到8秒,直接导致财务部门无法按时完成对账。这就是典型的查询性能问题引发的业务阻塞。
2. 原始SQL的问题诊断
假设我们有一个基础的余额查询SQL如下:
SELECT m.merchant_id, m.merchant_name, SUM(CASE WHEN t.trans_type = 'PAYMENT' THEN t.amount ELSE 0 END) - SUM(CASE WHEN t.trans_type = 'REFUND' THEN t.amount ELSE 0 END) AS daily_balance FROM merchants m JOIN transactions t ON m.merchant_id = t.merchant_id WHERE t.trans_date = CURRENT_DATE GROUP BY m.merchant_id, m.merchant_name;这个SQL存在几个典型性能瓶颈:
- 全表扫描风险:没有有效利用trans_date字段的索引
- 过度计算:对每笔交易都进行CASE WHEN判断
- 冗余字段:GROUP BY中包含不需要的merchant_name
- 连接方式低效:使用默认的JOIN而非更高效的连接方式
3. 优化方案设计与实施
3.1 索引策略优化
首先为trans_date和merchant_id创建复合索引:
CREATE INDEX idx_trans_date_merchant ON transactions(trans_date, merchant_id);这个索引设计遵循了最左前缀原则,可以同时优化WHERE条件和JOIN操作。
3.2 SQL重写
优化后的SQL:
SELECT m.merchant_id, m.merchant_name, (COALESCE(p.payment_sum, 0) - COALESCE(r.refund_sum, 0)) AS daily_balance FROM merchants m LEFT JOIN ( SELECT merchant_id, SUM(amount) AS payment_sum FROM transactions WHERE trans_date = CURRENT_DATE AND trans_type = 'PAYMENT' GROUP BY merchant_id ) p ON m.merchant_id = p.merchant_id LEFT JOIN ( SELECT merchant_id, SUM(amount) AS refund_sum FROM transactions WHERE trans_date = CURRENT_DATE AND trans_type = 'REFUND' GROUP BY merchant_id ) r ON m.merchant_id = r.merchant_id;这个版本有三大改进:
- 将单次扫描拆分为两次针对性扫描
- 提前过滤交易类型,减少计算量
- 使用LEFT JOIN确保没有交易的商户也能显示
3.3 执行计划验证
使用EXPLAIN分析优化前后的执行计划:
优化前:
| id | select_type | table | type | rows | Extra | |----|-------------|-------|--------|---------|-------------| | 1 | SIMPLE | t | ALL | 1000000 | Using where | | 1 | SIMPLE | m | eq_ref | 1 | NULL |优化后:
| id | select_type | table | type | rows | Extra | |----|-------------|------------|-------|------|------------------------------| | 1 | PRIMARY | m | ALL | 5000 | NULL | | 1 | PRIMARY | <derived2> | ref | 100 | NULL | | 1 | PRIMARY | <derived3> | ref | 100 | NULL | | 2 | DERIVED | t | range | 5000 | Using where; Using temporary | | 3 | DERIVED | t | range | 5000 | Using where; Using temporary |4. 进阶优化技巧
4.1 物化视图策略
对于高频查询但数据变化不频繁的场景,可以使用物化视图:
CREATE MATERIALIZED VIEW merchant_daily_balance AS SELECT ... [同优化后SQL] ... REFRESH COMPLETE ON DEMAND;4.2 分区表设计
如果交易数据量极大(如超过1亿条),建议按日期范围分区:
CREATE TABLE transactions ( ... ) PARTITION BY RANGE (trans_date) ( PARTITION p202301 VALUES LESS THAN ('2023-02-01'), PARTITION p202302 VALUES LESS THAN ('2023-03-01'), ... );4.3 应用层缓存
对于实时性要求不高的场景,可以在应用层实现二级缓存:
@Cacheable(value = "dailyBalance", key = "#merchantId+'-'+#date") public BigDecimal getDailyBalance(String merchantId, LocalDate date) { // 查询数据库 }5. 性能对比与监控
优化前后的关键指标对比:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 执行时间(ms) | 8200 | 120 | 68倍 |
| 扫描行数 | 1.2M | 12K | 100倍 |
| CPU消耗 | 85% | 3% | 28倍 |
建立慢查询监控机制:
-- MySQL慢查询日志配置 SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';6. 避坑指南
在实际优化过程中,我遇到过几个典型问题:
过度索引陷阱:曾为每个查询字段单独创建索引,反而导致写入性能下降30%。后来改用复合索引解决了问题。
隐式类型转换:有一次trans_date字段的比较因为类型不匹配导致索引失效:
WHERE trans_date = '2023-01-01' -- 字符串 vs DATE类型统计信息过时:遇到过优化后性能反而下降的情况,原因是表的统计信息没有及时更新。解决方法是:
ANALYZE TABLE transactions;连接顺序问题:当多表关联时,MySQL优化器有时会选择次优的连接顺序。可以通过STRAIGHT_JOIN提示强制连接顺序:
SELECT ... FROM table1 STRAIGHT_JOIN table2 ...