资讯动态

从慢SQL到高并发:DBShadow.net性能调优实战复盘

发布时间:2026/9/17 3:00:08 来源:尧图企业网站定制
DBShadow.net这个项目我在里面折腾了大半年一半时间写业务另一半时间全在跟性能瓶颈死磕。今天想把它踩过的坑、试过的方案、最后真正有效果的东西完整复盘一遍。如果你是做服务端开发或者数据平台维护的这篇应该能帮你少走几条弯路。先说结论性能优化这件事最怕的不是问题复杂而是你连瓶颈在哪都没搞清楚就开始改代码。我一开始也是看着延迟飙升就慌上来调了一堆参数结果越调越懵。后来老老实实沿着请求链路一层一层量体温才把DBShadow.net从“一压就挂”救成“高峰期也能从容扛住单机两千多QPS”。1. 先把DBShadow.net和前因后果讲清楚1.1 这项目是干什么的DBShadow.net是一个数据影子查询平台说白了就是把业务库的线上数据通过Binlog解析同步到独立的只读查询集群对外统一提供查询API。它解决的核心痛点是业务库不能直接跑复杂查询否则会拖垮在线交易但运营、数据、售后又需要一个能查订单、查日志、查任务明细的入口。项目的核心模块大概可以分成四块数据同步模块解析线上MySQL的Binlog把数据变更实时搬到只读查询库查询服务提供REST API核心接口是列表查询、分页、聚合统计管理后台创建影子实例、查看同步延迟、配置缓存策略运维支撑Prometheus监控、Grafana看板、告警规则。技术栈也比较常规后端Go 1.21数据库MySQL 8.0缓存Redis 6前面挂Nginx服务器是几台4C8G的云主机。刚上线的时候流量不大一天几百万次查询单机扛得住也就没人太在意性能问题。1.2 性能问题是怎么爆出来的问题爆发是在一次业务量翻倍之后。那天下午三点多监控先报警P99开始抖动紧接着5xx错误率往上冲最后数据库连接数直接顶到max_connections的90%。当时的现象非常典型核心查询接口P99从平时50ms左右飙到1200ms以上同步任务延迟从秒级涨到十几分钟查询结果经常和业务库对不上运营反馈后台看板打不开图表加载转圈数据库服务器负载升高慢查询数量肉眼可见地暴涨。说实话这种告警齐发的情况很容易让人慌乱。但我给自己立了一个规矩先不动任何东西把现象和链路数据收集齐了再说。后面的所有优化都是建立在这套“先测量、再定位、最后动手”的方法之上。2. 第一轮排查把瓶颈按在数据链路上2.1 从接口耗时分布找方向性能排查最忌讳“盲人摸象”。我的第一步不是看代码而是看Nginx访问日志里的request_time和upstream_response_time。这两个字段能告诉我耗时到底是发生在客户端到Nginx网络层还是Nginx到后端服务这一层。拉到半天日志统计下来结论很清晰98%的请求耗时都落在后端服务上客户端到网关这一段网络非常健康。于是方向锁定了后端服务慢而且大概率慢在数据库访问上。接着看服务内部的自定义埋点。我们在请求处理的关键路径上打了耗时日志比如参数解析、缓存读取、SQL查询、响应编码。结果SQL查询这一段平均占整个请求耗时的85%以上。到这一步数据库变成了第一嫌疑人。2.2 慢日志和索引设计问题MySQL慢日志是排查慢查询最重要的工具。开慢日志的命令很简单SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;long_query_time设置成1秒也就是说超过1秒的SQL都会记录到日志里。配合pt-query-digest可以快速找出Top N最耗时的SQLpt-query-digest /var/log/mysql/slow.log --since 2024-07-01 00:00:00 | head -n 60分析结果让我挺意外的最耗时的SQL居然不是那种复杂报表查询反而是一个非常普通的列表分页查询SELECT id, task_name, total_rows, cost_ms, status, created_at FROM shadow_task_log WHERE biz_id 10086 ORDER BY created_at DESC, id DESC LIMIT 100000, 200;EXPLAIN一看typeALL全表扫描预估扫描行数一百多万行。这表当时已经有两千多万行每次翻到后面几页就要丢掉前面十万行数据不慢才怪。问题根源有两个条件字段上缺少联合索引biz_id和created_at没法组合定位分页方式用了经典的LIMIT offset, size深分页页码越深扫描行数越多。2.3 连接数被打满的真相慢查询变多之后数据库连接被长时间占用新请求拿不到连接开始排队堆积。但还有一个隐藏推手我们的Go服务连接池没有设置MaxOpenConns上限。Go的database/sql如果我记得没错默认是不会限制最大打开连接数的。也就是说流量一旦脉冲式上涨服务会不断向数据库申请新连接把数据库连接数直接打到上限最终触发max_connections告警。这一步给我们的教训特别深连接池不设上限等于把自己置于“无限风险”之下。后面我会详细讲调参细节。3. 数据库层的几项关键优化3.1 联合索引与游标分页第一个动作是补索引。这种按biz_id筛选、按created_at和id倒序排序的场景最合适的就是联合索引ALTER TABLE shadow_task_log ADD INDEX idx_biz_created_id (biz_id, created_at, id);索引顺序很关键我把选择等值条件的biz_id放最前面然后放排序字段created_at最后带上id做辅助排序。这样MySQL既可以快速定位到某个业务的记录又可以直接按索引顺序读取避免filesort。索引建好之后原来1秒多的SQL直接降到几十毫秒。但还有一个隐患没有消除深分页。LIMIT 100000, 20即使有索引MySQL也要先跳过十万条记录这个过程依然是耗时的。于是我把分页改成了游标分页。前端不再传page和pageSize而是把上一页最后一条记录的created_at和id作为游标传回来SELECT id, task_name, total_rows, cost_ms, status, created_at FROM shadow_task_log WHERE biz_id 10086 AND (created_at 2024-07-01 10:30:00 OR (created_at 2024-07-01 10:30:00 AND id 345678)) ORDER BY created_at DESC, id DESC LIMIT 50;这种改写方式下索引可以直接定位到游标位置只看50行数据不管翻到第几页耗时都是稳定的。对DBShadow.net这种列表查询非常多的场景收益极其明显。3.2 count大表改成计数表和HLL除了列表分页另一个拖垮数据库的写法是COUNT(*)。运营看板需要显示“今日总调用次数”“失败次数”“平均耗时”一开始的实现简单粗暴SELECT COUNT(*) FROM shadow_task_log WHERE biz_id ? AND created_at ?;表一大了COUNT(*)即使走索引也要扫大量行实时统计一次要几百毫秒甚至几秒。如果看板页面同时发五个这样的统计请求数据库直接受不了。我的处理方法是分级对实时性要求不高的看板数据改成每5分钟跑一次定时任务把结果写入独立的聚合表对实时性要求较高的“今日实时失败数”用Redis做计数器每次查询写入时INCRBY一个业务一个key按天分片。如果只是需要“大概量级”而不是精确值还可以用Redis的HyperLogLog做去重统计误差在0.81%以内内存占用极小。性能优化的一个核心原则是能提前算好的就不要在请求路径上现算。3.3 慢查询巡检机制优化完已知慢SQL后我开始担心“还有多少隐藏的慢查询没被发现”。于是把慢查询处理做成了常态化机制MySQL开启long_query_time1日志保留7天每天凌晨跑一次pt-query-digest把Top SQL汇总后推送到告警群新增接口上线前必须贴出EXPLAIN执行计划和预估扫描行数建立索引变更评审避免重复建索引或建了不用。我一直觉得性能优化不是一次性工程。你这次把已知瓶颈解决了下次业务查法一变可能又冒出新的慢SQL。没有巡检机制就只能在线上事故发生时被动救火。4. 缓存与网关层的“防空防崩”改造4.1 查询缓存策略怎么定数据库优化做完之后接口延迟已经好了很多但离“高峰稳定”还有距离。DBShadow.net的查询有个特点同一个业务方短时间内反复查同一批数据的情况特别多比如运营盯着一个任务列表反复刷新。这种场景非常适合加缓存。缓存粒度我按“请求参数确定性”来设计biz_id 游标 limit拼接成一个key。这样不同业务方、不同查询条件的请求不会互相污染。TTL的选择也纠结过。太短了缓存命中率低太长了可能查到旧数据。最后我选了30秒为基准再加0到5秒的随机抖动。为什么加随机抖动如果没有抖动大量key会在同一秒集体过期瞬间全量穿透到数据库造成缓存雪崩。随机抖动可以让过期时间的分布更均匀。对时效性要求极高的接口则不缓存写结果只有读接口做短TTL。DBShadow的同步延迟控制在秒级30秒的缓存窗口业务方完全可以接受。4.2 穿透、击穿、雪崩的应对缓存改造过程中我发现实际业务场景里单单“加缓存”还会引出三座大山穿透、击穿、雪崩。先说出问题最多的穿透。一次排查发现某个接口在凌晨突然被打出一波“P95升高”查下来是有请求在反复查询一些不存在的日志ID。缓存永远存不了这些“空结果”于是所有请求全部穿透到数据库。应对办法是空值缓存如果数据库查不到把空结果也缓存30秒。另外针对biz_id集合有限的场景我在内存里加了一个布隆过滤器请求来了先判断这个biz_id是否真实存在过滤掉明显无效的查询。击穿问题出现在某个热点key过期的瞬间比如某个大客户的统计入口key正好到期瞬间几十个请求同时回源数据库。这里我用了Go的golang.org/x/sync/singleflight把短时间内的相同查询合并成一次数据库访问其它请求直接复用一个结果func (s *Service) QueryLogs(ctx context.Context, bizID int64, cursor string, limit int) (*PageResult, error) { key : cacheKey(bizID, cursor, limit) if v, err : s.cache.Get(ctx, key); err nil { if data, _ : unmarshalPageResult(v); data ! nil { return data, nil } } result, err, _ : s.single.Do(key, func() (interface{}, error) { rows, err : s.dao.QueryLogs(ctx, bizID, cursor, limit) if err ! nil { return nil, err } raw, _ : marshalPageResult(rows) _ s.cache.Set(ctx, key, raw, 30*time.Secondtime.Duration(rand.Intn(5))*time.Second) return rows, nil }) if err ! nil { return nil, err } return result.(*PageResult), nil }这段代码看起来简单但细节都在注释之外singleflight.Do的key必须和缓存的key完全一致否则合并不了另外如果查询失败千万不要缓存错误结果否则会把“不可用状态”也放给用户。4.3 Go连接池参数和Nginx配置连接池参数问题前面提到过。我修复的方式很直接db, err : sql.Open(mysql, dsn) if err ! nil { log.Fatal(err) } db.SetMaxOpenConns(50) db.SetMaxIdleConns(20) db.SetConnMaxLifetime(30 * time.Minute)三个参数各有用意。SetMaxOpenConns(50)限制数据库最大连接数防止服务把数据库打挂。SetMaxIdleConns(20)在低峰期保留一定空闲连接避免每次请求都重新建连。SetConnMaxLifetime(30 * time.Minute)特别容易被人忽略如果服务端的wait_timeout或者网络设备回收了连接而客户端还拿旧连接去做查询就会出现报错invalid connection。设置Lifetime就是主动定期换一批连接把这种异常提前规避掉。Nginx这一层也有一些“低垂的果实”。首先是让Nginx和上游服务之间保持长连接worker_processes auto; events { worker_connections 4096; use epoll; } http { upstream dbshadow_backend { server 127.0.0.1:8080 max_fails3 fail_timeout5s; keepalive 256; } server { listen 8443 ssl http2; gzip on; gzip_types application/json text/plain; location /api/ { proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_pass http://dbshadow_backend; proxy_connect_timeout 1s; proxy_read_timeout 5s; } } }关键点就在这一行proxy_set_header Connection 。如果没有这一行Nginx默认会用Connection: close跟上游通信每次请求都要重新走TCP握手高并发下握手开销非常可观。加上之后连接复用率大幅提升网关CPU占用都下去了。5. 同步链路、内核参数、GC与日志这些容易漏的角落5.1 同步任务为什么越积越多DBShadow.net的另一个核心痛点是Binlog同步延迟。线上库写入高峰时同步任务从秒级延迟一路涨到十几分钟查询到的基本都是旧数据。排查后发现几个问题叠加在一起同步任务单次批量太大一次性写入1万行一个大事务在目标库占用大量行锁其它连接都在等锁并行度太低早期是单消费者线程处理Binlog事件没有做重试和积压监控一旦失败后面所有事件全部排队。应对方案非常对症下药单事务写入改成500行一个事务锁持有时间短吞吐反而更高并行消费者按表的哈希分片处理保证同一行的Binlog事件顺序同时让不同表的数据可以由不同协程并发写入给同步组件加延迟指标Prometheus采集超过30秒就告警。事务大小这事很多人都觉得“越大越快”其实在写入冲突明显时小批量反而更高效。因为锁冲突少了系统整体吞吐上去了而不是一个事务闷头跑。5.2 Linux内核参数几个关键项翻到系统层时我顺手检查了云主机默认的内核参数发现有几处确实该调。比如在高并发短连接场景下本地端口范围不够用TIME_WAIT积压多都会让服务出现“端口耗尽、请求卡住”的假象。我梳理了一份适合这类查询服务的sysctl配置fs.file-max 2000000 net.core.somaxconn 2048 net.ipv4.tcp_max_syn_backlog 8192 net.ipv4.ip_local_port_range 1024 65535 net.ipv4.tcp_tw_reuse 1 net.ipv4.tcp_fin_timeout 15这里面的参数我只解释一个重点net.ipv4.tcp_tw_reuse只对客户端出站连接生效别指望它解决服务端TIME_WAIT堆积问题。如果服务端TIME_WAIT很多第一步要排查是不是自己主动断开连接太多比如HTTP keepalive没开、数据库连接频繁重建等而不是急着调内核。net.core.somaxconn影响的是Nginx listen队列长度如果请求瞬间超过队列长度内核会直接丢连接表现为连接超时。配合Nginx的worker_connections一起调整才有效。5.3 Go服务GC和内存分配优化高峰时期Go服务的堆内存涨到4GBGC频繁接口出现周期性抖动。单看go trace就能看到GC暂停占了不小的比例。我做了两件事。第一件是把部署时的GOGC从默认100调到了200让GC触发阈值更高减少GC频率实测对这类“内存占用相对稳定”的服务有效果。第二件是优化热点路径上的对象分配。比如解析日志条目时原来每次json.Unmarshal都会产生一批临时对象反复分配、反复GC。后来改用sync.Pool复用对象var entryPool sync.Pool{ New: func() interface{} { return ShadowLogEntry{} }, } func parseEntry(body []byte) (*ShadowLogEntry, error) { e : entryPool.Get().(*ShadowLogEntry) if err : json.Unmarshal(body, e); err ! nil { entryPool.Put(e) return nil, err } return e, nil } func releaseEntry(e *ShadowLogEntry) { *e ShadowLogEntry{} entryPool.Put(e) }注意用sync.Pool复用的对象放回池前必须清零否则下一个使用者会读到之前遗留的脏数据。我就在这里栽过一次排查了整整半天。5.4 日志从阻塞到异步还有一个很容易漏的地方是日志。高峰期iostat显示磁盘util经常冲到80%以上Go服务里大量请求线程被阻塞在日志写盘上。原本用的是同步写日志看似无害但请求一多日志写盘直接把CPU和IO都拖垮。解决办法是把日志改成异步写zap这类日志库都可以配置批量异步输出。另外日志级别也做了调整线上不再输出DEBUG级别信息减少无效IO。优化后磁盘util稳在30%以下接口P99又降了一截而且“周期性抖动”明显消失。6. 压测数据与监控体系落地6.1 压测怎么压才靠谱优化完成不代表结束要验证效果还必须压测。但我见过太多人压测“压了个寂寞”原因是用一个固定参数反复请求同一个URL结果全部命中缓存看起来性能爆炸真实场景一上线又原形毕露。我的做法是准备一个接近真实的请求参数集几十个biz_id混合使用部分请求命中缓存部分走DB分页游标覆盖前几页和深分页再混入一定比例的过滤查询。压测工具用了hey和wrk命令大概长这样hey -z 60s -c 200 -m GET http://127.0.0.1:8080/api/v1/logs?biz_id10086page160秒、200并发足够暴露大多数问题。如果条件允许最好在独立测试环境压测不要在线上直接压否则容易误伤真实用户。6.2 优化前后实测数据对比压测结果我很满意优化前后的数据对比放在一起更直观指标优化前优化后核心查询接口P991260 ms82 ms接口平均延迟340 ms36 ms数据库连接占用峰值91376慢查询数量1小时13704数据同步延迟15分钟以上3秒以内单机QPS2602100这个结果是在同配置的单台4C8G服务器上测出来的。数据库连接占用从900多降到70多说明连接池上限和SQL优化同时起到了作用。慢查询从一千多条降到个位数说明索引和缓存策略真正解决了问题的根源。6.3 监控告警怎么配没有监控优化就只能靠猜。DBShadow.net最后落地的监控指标可以分为三组第一组是服务体验指标QPS、P50/P95/P99、错误率、平均响应时间。这些直接反映用户有没有感知到慢。第二组是数据库指标连接数、慢查询数、锁等待、主从延迟。第三组是同步链路指标Binlog消费延迟、消费者堆积数、写入失败数。告警规则也要结合实际。P99超过500ms持续2分钟、5xx错误率超过1%、同步延迟超过30秒这类规则都要配到位。尤其是同步延迟建议级别调高因为DBShadow整个产品定位就是“查询准实时数据”同步延迟是核心可用性指标。7. 回头看那些最值得记住的坑和经验7.1 几个印象最深的坑整套优化下来我发现自己踩过的坑特别有代表性整理成速查表分享给大家问题现象根因解决数据库连接数打满接口大量报连接失败MaxOpenConns没设上限设置连接池上限与合理Lifetime深分页超时报表接口越翻越慢LIMIT offset过大全表扫描游标分页联合索引缓存穿透不存在的ID请求打垮DB未命中也不缓存空结果布隆过滤器空值缓存大事务拖慢主库同步延迟飙升单事务写1万行每500行一个事务日志刷盘阻塞高峰期请求被拖住同步写日志到磁盘异步写日志连接被服务端断开偶发invalid connectionConnMaxLifetime未设置设置30分钟主动重建这里我想多说两句深分页。很多人以为慢SQL就是查询条件复杂其实“翻页太深”也是一个很隐蔽的杀手。页面往下翻到几百页之后数据库要扫描的数据量会指数级增长。DBShadow的列表接口如果不改成游标分页就算加了联合索引调用方只要把页数翻大还是会打出一个慢SQL来。7.2 优化方法论沉淀一段时间的折腾之后我沉淀出一套自己的性能优化节奏分享给同样被线上问题追着跑的朋友第一步是量化。任何优化动手之前先把当前的状态量化出来。延迟是多少、QPS是多少、慢SQL几条、CPU和IO用多少没有这些数据后面每一步都是在猜。第二步是定位。沿着请求链路一层层排查客户端、网关、服务、数据库、磁盘网络每层都要有数据可看。第三步才是优化。而且每次只改一个变量改完做对照验证不要同时调十个参数不然出了问题根本不知道是哪个改动引起的。性能优化没有银弹。很多方案看起来是“高效方案”但用在错误的地方反而更糟。比如布隆过滤器如果biz_id集合无限增长内存占用就不划算再比如tcp_tw_reuse用在错误的场景里可能把网络可靠性搞坏。方案选型的核心永远是对业务场景的理解而不是追求某个流行技术。我个人在实际操作中的体会是性能数据一定要存档。我每次上线优化方案前都会把当前版本的压测数据截图存档优化后再跑一遍同样的压测用数据说话给团队看。这套习惯帮我避免了很多“感觉变快了”的无效返工。DBShadow.net的性能优化之路还没走完量级越大新的坑一定还会出现。但现在至少知道了该怎么系统地面对它们而不是每次都在线上事故里慌乱救火。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价