如果你是做行情系统的,某天运营跑过来跟你说"最近行情页越开越慢,数据也经常卡住",你大概会先怀疑网络,再怀疑客户端,最后才不情不愿地把目光转向自己的后端。但真实情况往往是:网络没问题、客户端没问题、数据库也没报错,可系统就是肉眼可见地变慢了。这个"越做越慢"的过程,不是某一次上线引入的故障,而是随着数据累积、业务迭代、架构妥协一点点沉淀下来的。我这几年代运维、做优化,在行情类系统上踩了不少坑,今天把排查思路和落地优化整理成文,希望能帮你少走点弯路。
行情系统有个很特殊的地方:它不是"用的人多了才慢",而是"数据多了就慢"。用户的并发量可能一直很平稳,但行情数据是持续滚动写入的,日积月累,查询路径上的每一个环节都会被数据量放大。慢,是必然的;好在你只要搞清楚它到底慢在哪一层,就有办法把它拉回来。
1. 行情系统变慢的信号:先分清是哪一种慢
1.1 用户感知到的慢,未必是后端的锅
接到"行情变慢"的反馈,第一件事不是查数据库,而是先复现并确认用户说的"慢"到底是哪一种。
我一般会先分三类。第一类是首屏打开慢,比如客户端从启动到第一根K线渲染出来要好几秒,这通常是初始化接口慢、前置资源加载重、或者网关路由配置拖了后腿。第二类是实时刷新卡顿,比如行情跳动不流畅、分时图一卡一卡的,这往往是推送链路的问题,跟WebSocket连接数、推送频率、客户端渲染能力都有关系。第三类是查询响应慢,比如查某只股票的历史分笔、查某段时间的分钟线,转圈圈转很久才出数据,这才和数据库、缓存、接口逻辑直接相关。
很多团队一听到"慢"就扎进SQL优化里,结果查了半天发现慢的是首页加载了一堆没必要的行情列表。所以先别急着动手,给"慢"分好类,再决定往哪个方向排查。我的经验是第一类和第三类最容易混在一起,一定要先看现象是集中在首次访问还是每次访问。
1.2 服务端指标上的慢,比用户反馈更早暴露问题
用户反馈是滞后的,系统指标才是实时的。行情系统越做越慢,通常会在监控图上提前露出马脚。
你需要盯几个关键指标:接口P99响应时间是不是在逐周缓慢上升,数据库慢查询数量是不是从每天几十条涨到每天几千条,Redis命中率是不是在往下掉,行情推送的WebSocket连接数是不是涨了一倍而机器负载没降。这些指标单独看可能都不算事故,但把它们拉到三个月的时间轴上对比,就能看到一条清晰的恶化曲线。
我最常用的做法是搭一个简单的趋势看板,把核心接口的响应时间、数据库慢查询数、缓存命中率、GC暂停时间这几项按天聚合,每周瞄一眼。行情类的系统特别适合这种"趋势发现"的思路,因为它不是突发故障,而是渐进劣化。等你从用户反馈里感知到慢的时候,监控曲线通常已经爬了两三个月了。
2. 根因拆解:行情系统为什么越跑越重
2.1 数据只进不出,存储积压成山
行情系统的数据特征是高频、海量、持续写入。每一笔成交、每一条快照、每一次五档盘口变化,都会落库。刚开始一天几百万条,分页查询随便加个索引就很流畅;等跑了一两年,单表几个亿甚至几十亿条,原来好用的SQL开始超时,原来秒开的页面开始转圈。
这就是最典型的第一重原因:数据只进不出,存储层被时间堆满了。
很多团队在设计初期没有制定归档策略,觉得"反正磁盘够大,先存着"。等到数据量上来,你会发现磁盘其实也扛得住,扛不住的是索引膨胀和查询扫描范围。一张十亿行的表,即使命中索引,B+树的层数和随机IO也会让延迟明显上升;更别提有些查询条件设计得不好,根本没法走索引,全表扫描一动就是几十秒。
提示:行情数据是典型的时序数据,"永远只查最近一段时间"是常态。如果你们还在用关系型数据库存全量行情且没有归档机制,那慢只是时间问题。
2.2 索引和表结构,没跟着数据形态一起进化
第二个高频根因是索引设计停留在系统上线初期。项目刚起步时,数据量小,随手加几个索引就能跑。等数据量上来,当初的索引可能不再是查询的最优路径,甚至成了写入的负担。
举几个我实际见过的例子:
- 联合索引的字段顺序跟现在的查询条件不匹配,导致索引失效,MySQL只能走全表扫描。
- 针对"查最近N条"这种高频查询,没有设计倒序索引或覆盖索引,每次查询都要先排序再返回,排序的数据量一大就慢。
- 行情明细表按时间建的索引,但业务侧经常按股票代码+时间范围查询,索引的最左前缀用不上。
- 分表方案是按股票代码哈希拆的,但运营后台经常按时间维度跨全表统计,一次统计把所有分表扫一遍。
这些问题在数据量小的时候都是"能跑就行",但在行情系统里,数据量增长是确定的、持续性的,所以索引和表结构必须跟着数据的增长节奏定期复盘。我见过不少团队,优化一次之后一两年不再碰索引设计,系统自然越来越慢。
2.3 缓存策略从加速器变成拖累
行情类系统几乎离不开缓存,Redis、本地缓存、CDN都会用。但缓存用不好,反而会加剧变慢。
最典型的场景是缓存穿透。比如查询某只停牌股票的历史数据,缓存里没有,数据库里也没有,每次请求都直接打到数据库。平时流量低没感觉,一旦行情波动大,用户疯狂刷新,数据库瞬间就被穿透流量打满。
还有缓存击穿和缓存雪崩。击穿是某个热点key(比如某只暴涨的股票)在缓存过期的一瞬间,大量请求同时打到数据库;雪崩是大量key在同一时段过期,数据库压力陡增。这两种情况在行情系统里尤其常见,因为热点标的的访问量高度集中,且客户端经常在整点或开盘瞬间集中刷新。
另一个容易被忽视的问题是本地缓存与Redis之间的数据一致性。行情数据更新极快,如果本地缓存没有合理的过期时间或版本控制,用户看到的行情可能滞后几秒甚至几分钟。为了"实时性"而把缓存全去掉也不行,那会让数据库直接被查询流量打垮。缓存策略需要在这种矛盾里找平衡。
2.4 高频更新场景下的GC与锁竞争
行情系统的后端服务往往是高并发、低延迟的推送型服务,这种场景对JVM的GC非常敏感。如果服务用Java编写,且长期处理高频行情推送,GC问题会随着时间推移越来越明显。
常见的情况是:行情对象在堆里堆积,老年代持续增长,Full GC频繁触发,每次Full GC都会造成毫秒到秒级的停顿。对普通接口来说,几百毫秒的停顿可能无感;但对行情推送来说,一次停顿就意味着几百上千条推送滞留,恢复后集中补推,又会造成新一轮的压力。
还有锁竞争。热点股票的数据更新非常频繁,如果代码里用全局锁保护共享缓存,或者同步块范围过大,多个线程同时更新同一只股票的快照时就会互相阻塞。线程一多,锁等待的时间占比上升,接口延迟就会肉眼可见地增长。
这些属于代码和运行层面的问题,不会像数据量那样"线性增长",但它们会在系统负载上升时被放大,表现得就像系统"突然慢了很多"。
3. 实操排查:从现象到根因的完整步骤
3.1 先做链路梳理,别一头扎进数据库
排查变慢问题,我最忌一上来就开慢查询日志。第一步应该是把一条行情请求的完整链路画出来,从客户端发起、网关转发、鉴权、业务处理、缓存读取、数据查询到结果返回,每一跳都标注耗时。
这个环节不需要复杂工具,用链路追踪中间件或者简单地打日志打印每一跳的时间戳就能完成。如果是老系统没有埋点,可以先在网关和核心服务入口各打一条日志,先确认慢的是在网关层还是业务层。
我遇到过一个案例,接口P99从200ms涨到800ms,排查了半天数据库,后来发现是网关层对某个接口做了额外的限流排队,请求都在网关排队等令牌。链路梳理不做的代价就是,你可能在一个错误的方向上耗费大量时间。
注意:链路梳理时不要只测一条链路。行情系统往往存在多种查询模式——实时快照、历史K线、分笔明细、批量列表——各自的瓶颈可能完全不同,需要分别梳理。
3.2 打开慢日志和慢查询分析
确认数据库层面确实存在慢查询之后,再打开慢日志。MySQL的慢日志配置比较简单,在my.cnf里设置slow_query_log=ON和long_query_time=1,然后观察一段时间,收集慢SQL样本。
拿到慢SQL之后,不要急着改SQL,先用EXPLAIN看执行计划。重点看三样东西:是否走索引、扫描行数、是否出现临时表和文件排序。
我经常看到的问题SQL有两类。一类是查询条件里对索引列做了函数运算,比如WHERE DATE(create_time) = '2024-01-01',索引直接失效;另一类是ORDER BY字段不在索引里,导致每次查询都要做文件排序。这两类问题在数据量不大的时候表现不明显,一旦数据涨上去,响应时间几乎是指数级变差。
3.3 拆解缓存命中率与热点分布
如果数据库层面没有明显问题,下一步看缓存。行情系统的缓存设计通常分几层:接口层的本地缓存、共享层的Redis、页面层的CDN。每一层都有命中率指标,把每一层的命中率拉出来看,基本就能定位到是哪一层出了问题。
Redis的INFO stats里可以直接看keyspace_hits和keyspace_misses,算出命中率。如果命中率持续较低,就要检查缓存Key的设计是否合理、过期时间是否设置得过短、是否有大量不可缓存的动态查询。
还可以用Redis的--hotkeys参数或者MONITOR命令临时观察热点key分布。行情系统里,少量热点股票的访问量可能占到总量的八九成,这类key必须单独设计缓存策略。
3.4 定位线程阻塞与GC问题
Java服务出现变慢时,jstack和jstat是你最好的两个工具。
jstat -gcutil <pid> 1000可以连续观察GC情况。如果看到老年代的使用率持续高位、Full GC频繁且耗时长,基本可以确定GC是元凶之一。这时候用jmap导出堆转储,配合分析工具查看是哪些对象占用了大量内存。
线程阻塞的问题,用jstack抓线程快照,重点看WAITING和BLOCKED状态的线程分布。如果大量线程阻塞在同一个锁或同一个对象上,去代码里找对应的同步块,十有八九能发现锁粒度的问题。行情数据的更新和读取如果共用一把锁,读多写多的情况下会让吞吐量直线下降。
4. 优化落地:一套可复用的改进方案
4.1 数据治理:归档、分区与冷热分离
只要行情系统还在持续运行,数据就会持续增长。所以第一步优化永远是数据治理,而不是所谓的高性能SQL技巧。
我推荐的做法是三层推进。第一层是制定归档策略:历史行情数据超过一定时间(比如一年或两年)的,定时迁移到归档表或大数据平台,业务查询默认只访问热数据表。第二层是表分区:行情明细表按时间做RANGE分区,比如按天或按月分区,查询时自动裁剪到对应分区,扫描数据量能小一个量级。第三层是冷热分离:高频访问的近期数据放在高性能存储上,低频访问的旧数据放到廉价的冷存储。
实际经验是,分区表带来的收益往往比加索引更立竿见影。比如一张按天分区的行情表,查询某只股票最近三天的分钟线,只需要扫三个分区,而不是扫全表。配合归档策略,业务库的数据量能控制在一个稳定的水位,查询延迟也就不会随着时间持续恶化。
4.2 查询优化:重建索引与SQL改写
数据治理解决的是"数据太多"的问题,索引优化解决的是"查得不够快"的问题。
索引重建的前提是搞清楚业务最核心的查询模式。行情系统里,最高频的无非这么几类:按股票代码查最新行情、按股票代码+时间范围查K线、按时间段查全市场列表。针对每一类查询,设计单独的联合索引,索引字段的顺序按照等值条件在前、范围条件在后、排序列尽量包含的原则来排。
SQL改写方面有几个实用手法。一是避免在索引列上使用函数或隐式转换,比如时间条件写成create_time >= ? AND create_time < ?而不是DATE(create_time) = ?。二是用覆盖索引减少回表,如果查询只需要少数几个字段,把这几个字段都放进索引里。三是分页优化,深分页用游标或者基于上次查询的最后一条记录来翻页,而不是LIMIT 100000, 20这种暴力跳页。
提示:加索引不是多多益善。每个索引都会拖慢写入速度,行情系统的写入量又特别大,所以索引必须精简,一个索引服务一类高频查询,用不到的索引及时删。
4.3 缓存与推送架构的调整
缓存优化的核心是针对前面提到的穿透、击穿、雪崩问题做防御,同时和推送架构结合,让数据足够新鲜。
穿透的防御手段简单直接:对缓存和数据库都不存在的数据,在Redis里缓存一个空值并设置较短的过期时间;或者在接口层用布隆过滤器快速判断,不存在的Key直接拦截掉。击穿的防御是热点Key永不过期或逻辑过期,热点数据用单独的逻辑异步刷新,而不是等缓存过期了再去数据库读。雪崩的防御是过期时间加随机值,让大量Key的过期时间错开。
行情推送这块,我的建议是"推送为主、缓存为辅"。实时行情优先通过WebSocket推送让客户端拿到增量数据,查询接口更多承担初始化拉取和兜底职责。这样可以把高频查询压力从数据库和缓存链路里卸掉一大半,系统的整体容量能上一个台阶。推送链路本身要关注连接数管理、心跳保活和消息积压指标,避免连接数失控导致内存暴涨。
4.4 代码层与JVM层面的调优
GC问题的优化方向是减少对象分配和缩短对象存活时间。行情对象尽量复用,避免每次推送都new一批对象;快照数据用池化或预分配的方式管理;缓存对象设置合理的过期时间,避免大量数据长期滞留在老年代。
如果确认Full GC频繁,可以调整JVM参数。比较实用的做法是适当调大新生代比例,让短生命周期的行情对象在新生代就被回收,不进入老年代;同时用G1收集器,设置合理的MaxGCPauseMillis目标。
锁竞争的优化,核心是缩小锁范围。把"更新整只股票快照"改成"更新快照里的单个字段"级别的细粒度锁,或者用原子数据结构配合CAS操作代替锁。行情系统的很多场景其实是单线程模型更合适,比如单只股票的行情更新放在一个独立的线程队列里顺序处理,完全避开锁竞争。
注意:JVM参数也好,线程模型也罢,改动之前一定要做压测对比。行情系统对延迟敏感,有时候参数看起来更"标准"了,实际表现反而变差。任何调优都要用数据说话。
5. 踩坑记录与排查技巧实录
5.1 常见问题速查表
下面是我在实际排查中遇到频率最高的问题和对应解法,整理成一张速查表,方便你遇到类似现象时快速对照:
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 查询最近行情越来越慢 | 单表数据量过大,无归档 | 查看表行数与慢日志 | 按时间分区+归档策略 |
| 特定股票详情页卡顿 | 索引失效或没命中 | EXPLAIN查看执行计划 | 重建索引、SQL改写 |
| 行情波动时段数据库CPU飙高 | 缓存穿透/击穿 | 查看Redis命中率 | 空值缓存、逻辑过期、布隆过滤器 |
| 推送消息延迟、积压 | WebSocket连接数失控 | 监控连接数与积压量 | 连接数上限控制、心跳优化 |
| 服务频繁卡顿数秒 | Full GC频繁 | jstat观察GC情况 | JVM参数调整、对象复用 |
| 并发上升后响应时间暴涨 | 锁竞争严重 | jstack查看线程状态 | 细粒度锁、单线程队列模型 |
| 冷启动后首次访问特别慢 | 缓存未预热 | 检查缓存击穿日志 | 启动预热、预加载热点数据 |
5.2 几次让我印象深刻的真实排查
有一次,某套行情接口的P99从300ms涨到1.5秒,持续了将近两周没人发现,直到用户投诉才拉响警报。我查了半天,发现根因是三个月前一次业务迭代,把查询条件从"股票代码+时间"改成了"股票代码列表+时间",原来的联合索引最左前缀失效,数据库一直在走全表扫描。这个问题在列表只有几十个股票的时候不明显,等列表扩大到几百个,扫描量直接翻了几十倍。
还有一次是典型的缓存雪崩。某天开盘后,所有股票的缓存Key在同一时间过期,数据库瞬间被打满,行情查询大面积超时。排查发现缓存过期时间写死成了一个固定值,没有加随机扰动。修复方式是用固定间隔加随机偏移,问题当天就消失了。
这两次经历让我养成一个习惯:每次做业务迭代,都要把涉及的数据访问模式重新过一遍,索引和缓存策略要跟着迭代走,而不是上线就完事。
5.3 预防性手段:怎么避免"越做越慢"
变慢是可以预防的,关键在于把性能治理纳入日常的迭代流程里,而不是等出了问题再去排查。
我建议做三件小事。第一,每周看一次核心接口的性能趋势,把P99、慢查询数、GC次数记下来,画个趋势图。连续三周往上走,就该主动排查了。第二,每次上线涉及查询逻辑变更,必须带EXPLAIN执行计划评审,数据量假设按未来一年的规模评估。第三,建立定期压测机制,每季度做一次全链路压测,用未来半年的数据量预估做压测水位,提前暴露容量瓶颈。
这三件事看起来简单,但很多团队就是不做,结果系统是在一次次的被动救火中慢慢劣化的。行情系统尤其如此,数据增长是确定的,性能劣化也是确定的,唯一不确定的是你什么时候正视它。
最后分享一点个人体会。行情系统变慢,从来不是单一原因造成的,它更像一个慢性病,是数据膨胀、索引失效、缓存设计、代码质量、架构演进等多个因素叠加的结果。每一次只解决单个问题,看起来都是在"救火",但真正的思路应该是定期给系统做体检,把性能治理当成运维的一部分。踩过几次坑之后你就会发现,行情系统最需要的是"预见性",在数据量和业务复杂度还没压垮系统之前,就提前把架构和策略调整到位。这也是我从一个被动修Bug的人,慢慢变成主动做优化的过程中,最大的感悟。