慢SQL治理与性能调优:DBA如何用Shell/Python提升运维效率?

慢SQL治理与性能调优:DBA如何用Shell/Python提升运维效率?

前言

做DBA这些年,最怕的不是凌晨被叫起来处理主从延迟,也不是磁盘告警,而是业务研发在群里甩一句"接口又慢了,是不是数据库的问题"。你登上跳板机,连上实例,show processlist 刷了三遍,一切看起来都挺正常。CPU不高,连接数也正常,可业务那边确实在转圈。最后翻了半小时慢日志,发现是一条藏在几十万条记录里的查询,在某个特定城市参数下走了全表扫描,平时20ms,数据量涨过阈值后直接跳到1.8秒。

这种场景我经历过不止一次。慢SQL治理最磨人的地方不在于"改一条SQL",而在于从海量日志里把真正致命的那几条捞出来,并且持续盯住它们不再复发 。靠人肉每天cat slow.log,不现实,也不可持续。这篇文章要聊的就是我在实际项目里落地的一套组合拳:用Shell做日志的生命周期管理和粗筛,用Python做聚合、执行计划采集和趋势对比,把DBA从重复劳动里拽出来。

读完你能拿到什么:一套可直接复用的慢日志切割脚本、一个Python聚合分析器(含EXPLAIN批量采集逻辑)、以及我们在真实生产环境里踩过的坑和对应的规避手段。适合正在被慢SQL困扰的DBA、运维开发,以及需要自己动手做数据库可观测性的后端工程师。

一、总览:脚本化治理到底解决什么问题

慢SQL治理的完整链路其实不复杂:开启日志 → 采集聚合 → 筛选高价值目标 → 执行计划分析 → 优化落地 → 防复发 。每一步单独看都有现成工具,但拼在一起就出问题。mysqldumpslow 能汇总,但它输出的抽象化SQL丢掉了参数上下文,拿到手你也不知道这条查询是哪个业务场景发出来的。pt-query-digest 很强,但部署和配置成本对中小团队不算低。

我们的思路是:Shell管生命周期,Python管分析逻辑 。Shell负责每天零点切割日志、压缩归档、清理过期文件,保证磁盘不被撑爆,这件事 cron 就能干,简单可靠。Python负责解析切割后的日志文件,按SQL指纹聚合,计算总耗时和扫描行数的加权评分,再把Top N的SQL拿到线上执行EXPLAIN,输出结构化的巡检报告。

这里有一个容易被忽略的前置条件:慢日志必须能关联到来源 。如果日志里只有一句SQL文本,没有应用名、没有接口名、没有用户标识,你拿到Top 10也找不到人认领。我们会在连接初始化时通过SET SESSION打上业务标签,虽然MySQL原生慢日志不直接记录这个,但可以通过performance_schema关联线程信息来补足,后文会展开。

二、分模块拆解:Shell侧的日志流水线

2.1 慢日志阈值不是拍脑袋定的

在写任何脚本之前,先确认long_query_time的档位。很多团队线上设的是默认10秒,这等于没开。10秒的查询在业务侧已经是故障级别了,你的监控应该先告警,而不是等日志来告诉你。

我们的三档策略供参考:核心链路(派单、支付、抢单)设50ms,因为司机端对延迟的感知阈值极低;普通查询设200ms;另外打开log_queries_not_using_indexes作为兜底,但只开短期窗口,查完就关。这个参数长期开着,日志膨胀速度会让你怀疑人生。

还有一个坑:SET GLOBAL long_query_time 只对新建连接 生效。如果你的应用用的是连接池,几十个连接早就建好了,改完GLOBAL变量等半天日志还是空的。要么重启应用让连接重建,要么在业务侧的连接初始化逻辑里补一句SET SESSION long_query_time。我们吃过这个亏,调完参数盯着空日志纳闷了半小时。

2.2 切割脚本:别让慢日志吃掉磁盘

MySQL不会自动轮转慢日志。slow.log 一天涨几个G的场景我见过不止一次,尤其是log_queries_not_using_indexes开着的时候。Shell脚本做这件事最合适,简单、可移植、cron友好。

bash 复制代码
#!/bin/bash
# slow_log_rotate.sh
# 每天凌晨执行,切割并归档慢日志

LOG_DIR="/data/mysql/log"
SLOW_LOG="${LOG_DIR}/slow.log"
ARCHIVE_DIR="${LOG_DIR}/archive"
DATE=$(date +%Y%m%d)
KEEP_DAYS=7

# 确保归档目录存在
mkdir -p "${ARCHIVE_DIR}"

# 切割:mv后touch新文件,保持inode不变,MySQL无需重启
mv "${SLOW_LOG}" "${ARCHIVE_DIR}/slow-${DATE}.log"
touch "${SLOW_LOG}"
chown mysql:mysql "${SLOW_LOG}"

# 压缩前一天及更早的日志,节省空间
find "${ARCHIVE_DIR}" -name "slow-*.log" -mtime +0 -exec gzip {} \;

# 清理超过保留期的归档
find "${ARCHIVE_DIR}" -name "slow-*.log.gz" -mtime +${KEEP_DAYS} -delete

echo "[$(date)] slow log rotated: slow-${DATE}.log" >> "${LOG_DIR}/rotate.log"

这段脚本的关键在mv + touch 而不是 cp + truncate。MySQL持有的是文件句柄,直接truncate可能导致写入错位。mv 之后MySQL会继续往旧的inode写?不会,因为touch创建了新文件,但MySQL的连接还在旧的fd上。严格来说,更稳妥的做法是切割后执行FLUSH SLOW LOGS,但如果你没有SUPER权限或者不想在脚本里嵌mysql客户端调用,mv + touch 在多数场景下可用,代价是切割瞬间的少量日志可能写进旧文件。我们接受这个损耗,因为切割在凌晨低峰执行。

2.3 用Shell做第一道粗筛

切割完日志,别急着上Python。先用Shell做一次轻量过滤,把明显不关心的库和用户排除掉。比如我们线上有监控系统自己的探活查询,每秒几十条,耗时也在阈值边缘,如果不筛掉,聚合结果里它们会霸占前排。

bash 复制代码
#!/bin/bash
# prefilter_slow_log.sh
# 粗筛:排除监控用户和特定库的干扰记录

INPUT_LOG="$1"
OUTPUT_LOG="$2"

grep -v "user: monitor" "${INPUT_LOG}" \
  | grep -v "db: information_schema" \
  | grep -v "db: performance_schema" \
  > "${OUTPUT_LOG}"

echo "filtered: $(wc -l < "${INPUT_LOG}") -> $(wc -l < "${OUTPUT_LOG}") lines"

这一步的意义在于降低后续Python解析的噪音 。慢日志的格式是块状的,# User@Host: ... 开头,后面跟# Query_time和SQL文本。Shell的正则做块级过滤不够灵活,但行级的排除足够好用。筛完之后再交给Python。

三、Python分析器:从日志文本到可行动的清单

3.1 解析慢日志的块结构

MySQL慢日志的格式长这样:

复制代码
# Time: 2026-10-20T02:15:33.123456+08:00
# User@Host: app_user[app_user] @ 10.0.1.22 []  Id: 884231
# Query_time: 1.842301  Lock_time: 0.000112 Rows_sent: 20  Rows_examined: 184230
SET timestamp=1729361733;
SELECT id, order_no, amount FROM t_order
WHERE city_id = 130100 AND status = 'WAITING'
ORDER BY created_at DESC LIMIT 20;

关键字段是Query_time、Rows_examined、Rows_sent,以及User@Host里的连接信息。Rows_examined和Rows_sent的比值是我们最看重的指标之一:扫描18万行只返回20行,索引的选择性一定出了问题。反过来,扫描1万行返回1万行,那可能是业务确实需要这么多数据,优化的方向是分页或异步导出,而不是加索引。

3.2 按SQL指纹聚合

同一个查询模板,参数不同,在日志里会出现几百次。直接逐条分析没有意义,必须按指纹聚合。我们的指纹规则很简单:去掉数字、字符串常量、IN列表内容,保留SQL骨架 。Python的re模块足够用,不需要上复杂的解析器。

python 复制代码
import re
from collections import defaultdict

def normalize_sql(sql: str) -> str:
    """将SQL中的字面量替换为占位符,生成指纹"""
    # 去掉多余空白和换行
    s = re.sub(r'\s+', ' ', sql.strip())
    # 数字替换(包括小数和负数)
    s = re.sub(r'-?\d+\.?\d*', '?', s)
    # 单引号字符串替换
    s = re.sub(r"'[^']*'", '?', s)
    # IN列表内的多个占位符合并,避免 "IN (?,?,?)" 和 "IN (?)" 被当成不同指纹
    s = re.sub(r'IN\s*\([^)]+\)', 'IN (?)', s, flags=re.IGNORECASE)
    return s.lower()

def parse_slow_log(filepath: str):
    """解析慢日志文件,返回聚合结果"""
    entries = []
    current = {}
    sql_buffer = []

    with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
        for line in f:
            line = line.rstrip('\n')
            if line.startswith('# Time:'):
                if current.get('sql'):
                    entries.append(current)
                current = {'time': line.split(': ', 1)[1]}
                sql_buffer = []
            elif line.startswith('# User@Host:'):
                current['user_host'] = line.split(': ', 1)[1] if ': ' in line else line
            elif line.startswith('# Query_time:'):
                # 格式: # Query_time: 1.842  Lock_time: 0.000 Rows_sent: 20  Rows_examined: 184230
                m = re.search(r'Query_time:\s+([\d.]+).*Rows_sent:\s+(\d+).*Rows_examined:\s+(\d+)', line)
                if m:
                    current['query_time'] = float(m.group(1))
                    current['rows_sent'] = int(m.group(2))
                    current['rows_examined'] = int(m.group(3))
            elif line.startswith('SET timestamp=') or line.startswith('#') or not line.strip():
                continue
            else:
                sql_buffer.append(line)
                current['sql'] = ' '.join(sql_buffer)

    if current.get('sql'):
        entries.append(current)

    # 按指纹聚合
    groups = defaultdict(lambda: {
        'count': 0, 'total_time': 0.0, 'max_time': 0.0,
        'max_examined': 0, 'sql_sample': '', 'user_hosts': set()
    })
    for e in entries:
        if 'query_time' not in e or 'sql' not in e:
            continue
        fp = normalize_sql(e['sql'])
        g = groups[fp]
        g['count'] += 1
        g['total_time'] += e['query_time']
        g['max_time'] = max(g['max_time'], e['query_time'])
        g['max_examined'] = max(g['max_examined'], e.get('rows_examined', 0))
        if not g['sql_sample']:
            g['sql_sample'] = e['sql']
        g['user_hosts'].add(e.get('user_host', 'unknown')[:60])

    return groups

这个解析器故意保持简单。没有用sqlparse之类的库,因为慢日志里的SQL偶尔会有截断或格式异常,重量级解析器反而容易抛异常。正则加字符串处理虽然糙,但线上跑了一年多没出过解析崩溃。

3.3 评分排序:总耗时 vs 扫描行数

聚合完之后,按什么排序决定了你先看哪条SQL。只按总耗时排,那些执行频率高但单次不慢的查询会漏掉;只按单次最大耗时排,偶发的慢查询可能被误判为重点。

我们用的评分公式是加权组合:

python 复制代码
def score_group(g):
    """
    综合评分:总耗时权重 0.6,扫描行数峰值权重 0.4
    总耗时反映业务影响面,扫描行数反映潜在风险
    """
    time_score = g['total_time']
    scan_score = g['max_examined'] / 1000.0  # 归一化到千行量级
    return time_score * 0.6 + scan_score * 0.4

实际使用时,我们会在报告里同时展示三个维度的Top 5:总耗时Top (谁在拖垮整体)、单次最大扫描行数Top (谁是定时炸弹)、执行频率Top(谁在无意义地消耗资源)。这三个视角对应不同的优化策略。总耗时高的加索引或改SQL,扫描行数大的查执行计划,频率高的看能不能加缓存或合并查询。

四、疑难问题分析:EXPLAIN采集的坑比想象的多

4.1 为什么不能直接在慢日志上跑EXPLAIN

你从日志里拿到的SQL文本,很多时候不能直接拿去EXPLAIN。原因有三:第一,日志里的SQL可能被截断,尤其是long_query_time较小的时候,MySQL会记录完整SQL,但某些中间件或代理可能会截断;第二,SQL里的参数是字面量,比如city_id = 130100,这个参数在你测试的环境里可能走索引,线上真实数据分布下却不走;第三,LIMIT偏移量大的分页查询,在测试环境数据量小的时候执行计划完全正常。

我们有过一次教训:测试环境把一条LIMIT 100000, 20的查询跑到5ms,因为测试表只有几万行。上线后数据量两百万,同样的SQL直接飙到3秒。执行计划看起来没问题,type=range,但rows估算和实际扫描差了三个数量级。不要相信测试环境的执行时间,这是用血换来的规矩。

4.2 批量采集EXPLAIN的安全姿势

我们的做法是:Python脚本从聚合结果里取Top N的SQL样本,连到从库 上执行EXPLAIN,而不是主库。从库的延迟通常在秒级,不影响业务写入,而且执行计划在多数情况下与主库一致(优化器版本相同、统计信息同步的前提下)。

但有个细节要注意:从库的EXPLAIN 如果涉及临时表或复杂JOIN,仍然可能消耗可观的CPU。我们的策略是加超时控制 ,通过pymysql的read_timeout参数限制单条EXPLAIN的等待时间,超时就跳过,不阻塞整个分析流程。

python 复制代码
import pymysql

def explain_sql(conn_params, sql, timeout=5):
    """在从库上执行EXPLAIN,超时返回None"""
    try:
        conn = pymysql.connect(
            **conn_params,
            read_timeout=timeout,
            write_timeout=timeout
        )
        with conn.cursor(pymysql.cursors.DictCursor) as cur:
            cur.execute(f"EXPLAIN {sql}")
            return cur.fetchone()
    except Exception as e:
        return {'error': str(e)}
    finally:
        try:
            conn.close()
        except:
            pass

返回的EXPLAIN字典里,我们只看四个字段:type、key、rows、Extra。type出现ALL或index就标红;key为NULL直接告警;rows和日志里的Rows_sent比值超过100就列为高优先级;Extra里出现Using filesort或Using temporary的记一笔。

4.3 一个反直觉的坑:索引走了,但走错了

不是所有"走了索引"的查询都是健康的。我们遇到过一条查询,EXPLAIN显示key用上了联合索引的前缀列,type=ref,看起来没问题。但rows估算值是80万,实际扫描行数也对得上。原因在于优化器选了一个选择性极差的索引前缀,另一个选择性更好的索引因为统计信息过期没被选中。

这种情况EXPLAIN本身不会报错,你需要对比possible_keys和key 。如果possible_keys里有多个索引,而key选了一个rows明显偏大的,那就是统计信息或者索引设计的问题。我们的处理方式是:对rows超过表总行数10%的查询,强制走FORCE INDEX做一次对照EXPLAIN,看是否更优,然后决定是ANALYZE TABLE刷新统计信息还是调整索引。

五、优化方案推导:从诊断到落地的决策链

拿到一份带评分的慢SQL清单和EXPLAIN结果后,下一步是决定"改什么、怎么改、谁来改"。这一步最怕的是分析报告躺在邮箱里没人认领。

5.1 按病灶分类,而不是按SQL分类

我们的报告模板把问题分成四类,每类对应不同的处理路径:

第一类:索引缺失或失效 。type=ALL或key=NULL,Rows_examined远大于Rows_sent。处理方式最直接:加索引或改SQL写法让索引生效。但加索引之前必须确认两件事:该表的写入频率是否高到索引维护成本不可忽视;现有索引是否已经存在冗余,能不能通过调整联合索引顺序来覆盖而不用新增。

第二类:回表过多 。type=ref但rows很大,Extra里没有Using index。说明查询走了二级索引但需要大量回表取数据。处理方式是把SELECT *改成只取必要字段,并把过滤条件涉及的列加入联合索引形成覆盖索引。

第三类:排序和临时表 。Extra里出现Using filesort或Using temporary。这类问题加索引不一定能解决,因为排序字段和过滤字段的组合可能无法同时被一个索引覆盖。我们的经验是:如果ORDER BY的字段基数高(比如created_at),且过滤条件选择性好,延迟关联往往比硬加索引更有效。

第四类:深分页 。LIMIT偏移量大,Rows_examined接近偏移量加返回行数。延迟关联是标准解法:先用覆盖索引查出主键,再用主键关联取完整行。

5.2 改写示例:延迟关联的实际写法

sql 复制代码
-- 原始:扫描10万行,丢弃99980行
SELECT * FROM t_order 
WHERE city_id = 130100 AND status = 'WAITING'
ORDER BY created_at DESC LIMIT 100000, 20;

-- 延迟关联:内层只查主键,走覆盖索引
SELECT o.* FROM t_order o
INNER JOIN (
    SELECT id FROM t_order
    WHERE city_id = 130100 AND status = 'WAITING'
    ORDER BY created_at DESC LIMIT 100000, 20
) t ON o.id = t.id;

内层查询如果有一个(city_id, status, created_at)的联合索引,ORDER BY和过滤都在索引里完成,不需要回表。外层只拿20个主键去取完整行,回表次数从10万降到20。代价是SQL变长了,可读性下降,所以我们在代码里加了注释说明原因,防止后来的人"优化"回去。

5.3 谁来改:把责任落到人

技术方案再漂亮,没人执行等于零。我们的做法是在Python报告里附带最近一次执行该SQL的应用标识 。通过performance_schema.events_statements_history关联线程ID和PROCESSLIST的INFO字段,或者更简单的方式:让应用在连接初始化时执行SET SESSION 打标签,然后在慢日志的# User@Host行里就能看到标签信息。

这一步的落地成本不高,但效果显著。报告发出去的时候@对应的人,而不是群发。DBA负责提供分析结论和建议,研发负责确认业务逻辑并提交变更。谁的孩子谁抱走。

六、项目落地实操:从脚本到日常流程

6.1 定时任务编排

我们把整个链路拆成三个cron任务,错开执行时间:

bash 复制代码
# 每天 00:05 切割慢日志
5 0 * * * /opt/scripts/slow_log_rotate.sh >> /var/log/slow_rotate.log 2>&1

# 每天 00:10 粗筛并生成当日待分析文件
10 0 * * * /opt/scripts/prefilter_slow_log.sh \
  /data/mysql/log/archive/slow-$(date +\%Y\%m\%d).log \
  /tmp/slow_filtered_$(date +\%Y\%m\%d).log

# 每天 01:00 执行Python分析并发送报告
0 1 * * * /usr/bin/python3 /opt/scripts/slow_analyzer.py \
  --input /tmp/slow_filtered_$(date +\%Y\%m\%d).log \
  --output /opt/reports/slow_report_$(date +\%Y\%m\%d).html \
  --top 20

选择凌晨1点跑分析,是因为EXPLAIN虽然轻量,但批量执行时对从库仍有负担,低峰期最安全。

6.2 报告长什么样

报告不是越长越好。我们的HTML报告只有三屏:第一屏是摘要 ,显示当天慢日志总条数、Top 3指纹及其评分、相比昨日的增量;第二屏是明细表 ,每个指纹一行,列包括SQL样本(截断到200字符)、执行次数、总耗时、最大扫描行数、EXPLAIN的type和key、建议动作;第三屏是趋势图,最近7天慢查询总量和Top 1评分的变化曲线。

趋势比单日绝对值更重要。某天慢查询数量翻倍,可能是发版引入的新SQL;Top 1评分的持续上升,说明那条查询的数据量在增长,索引快撑不住了。我们在趋势图上标了发版时间点,一眼就能看出相关性。

6.3 防复发:把EXPLAIN卡在发布前

慢SQL治理最有效的一招,其实不在生产环境,而在代码合并之前 。我们在CI流程里加了一个轻量检查:对本次变更涉及的SQL文件(MyBatis的Mapper XML或JPA的@Query注解),提取SQL并做一次EXPLAIN,如果type低于range且涉及核心表,直接标记为需要DBA审核才能合并。

这个卡点的实现成本比想象中低。不需要完整的SQL解析器,用正则从Mapper XML里抠出<select>标签内容,交给Python的explain_sql函数跑一遍就行。误报率有一些(比如动态SQL拼接不完整的情况),但拦住的问题比放过去的多得多。

七、踩坑经验总结

坑一:long_query_time 的GLOBAL修改对连接池无效 。这个前面提过,但值得再强调。我们第一次调阈值时,改完等了二十分钟日志没动静,以为参数没生效,反复检查SHOW VARIABLES都是对的。最后发现应用连接池里躺着50个老连接,它们的long_query_time还是10秒。解决办法要么重启应用,要么在连接池的initSQL或connectionInitSql配置里补SET SESSION。

坑二:log_queries_not_using_indexes 是双刃剑 。开着它确实能抓到"跑得快但没走索引"的隐患,但小表全扫、配置表查询这些也会疯狂刷日志。我们只在每周的专项巡检窗口开两小时,平时关闭。如果你决定长期开,务必配合min_examined_row_limit,比如设成100,低于100行的扫描不记录。

坑三:慢日志里的SQL不一定能直接EXPLAIN 。尤其是用了PREPARE语句或某些ORM生成的SQL,日志里可能出现?占位符而不是实际参数。我们的解析器遇到含?的SQL会跳过EXPLAIN步骤,标记为"需人工确认参数"。不要硬跑,否则EXPLAIN的结果可能完全误导你。

坑四:从库的统计信息可能比主库旧 。如果主库刚做过大批量数据变更,从库的information_schema统计还没同步,在从库上跑的EXPLAIN可能选错索引。我们的做法是:EXPLAIN结果只作为参考,最终确认优化方案时,在主库低峰期用EXPLAIN FORMAT=JSON再看一次,或者直接观察优化上线后的实际执行时间。

坑五:不要一次性改太多。有一周我们一口气加了五个索引,结果写入性能下降,主从延迟从200ms跳到2秒。原因是索引维护的IO开销在批量写入时集中爆发。教训是:每次只改一个索引或一条SQL,观察至少24小时再动下一个。慢SQL治理是马拉松,不是冲刺。

这套脚本体系在我们环境里跑了快一年,慢日志日均条数从高峰期的三四百降到二十以内,派单接口的P99回到了200ms以下。但实话实说,脚本解决的是"发现和跟踪"的效率问题,最终的SQL改写和索引设计仍然需要人对业务的理解。工具把DBA从翻日志里解放出来,让精力花在真正需要判断力的地方,这才是效率提升的本质。

后续我会继续写这个系列里关于索引冗余检测脚本 和performance_schema 细粒度监控的落地细节,感兴趣的话可以关注主页。如果你也在做类似的治理,欢迎对照上面的代码跑一遍,遇到问题大概率是慢日志格式或连接池配置的差异,把日志样本发出来,我能帮你定位。

相关推荐
用户0332126663671 小时前
使用 Python 快速在 PowerPoint 中生成表格【教程】
python
夜晚回家1 小时前
Python 身份运算符详解 | is 与 == 到底差在哪
python
fengye2071611 小时前
cpython框架结构
python
用户91294046848421 小时前
大模型返回 JSON 后,先过这道 Python 校验
python
Dmz丶2 小时前
前端转 AI 100 天 Day 32:LLM 客户端工程化——重试、超时、降级、限流
python
曲径海盗2 小时前
【Python 学习第 39 天】for 循环
python·学习笔记·编程入门
万联WANFLOW2 小时前
Kimi K3.1 Pro 接口接入指南:模型选型、费用计算与零数据保留
java·spring boot·python·安全·spring·大模型
问心无愧05133 小时前
[护网杯 2018]easy_tornado
python·tornado
全栈弄潮儿3 小时前
Python实战第13期:模块与包
后端·python·编程语言