从一次性能优化说起:技术探索与实践总结

DigitalNomad
2025-06-13 15:56
阅读 739

开篇:为什么我要写这篇文章?

开篇:为什么我要写这篇文章?

在技术这条路上,每天都在面临各种各样的挑战。有时候是系统上线前的“最后冲刺”,有时候是凌晨三点突然响起的告警电话。作为团队的技术负责人,除了要保证系统的稳定运行,更要带领团队不断前行,在技术深水区中找到属于自己的路径。

今天我想分享的,并不是什么高大上的架构理论,而是一次真实项目中的性能优化经历。这个项目本身并不复杂,但其中遇到的问题和我们解决的过程,却让我对技术选型、架构设计和团队协作有了更深的理解。

希望这篇文章不仅能给你带来一些技术上的启发,也能让你在工作中多一份从容。


问题描述:系统卡顿了,用户反馈上来了

问题描述:系统卡顿了,用户反馈上来了

事情发生在我们开发的一个数据中台平台上线后的第三个月。平台主要面向公司内部的数据分析师,提供数据查询、报表生成、可视化分析等功能。系统整体采用微服务架构,前端基于 React,后端使用 Spring Cloud,数据层包括 MySQL、Elasticsearch 和 ClickHouse。

上线初期,系统表现良好,用户反馈也不错。然而随着接入的数据量逐渐增大,特别是某些高频访问的维度表数据达到千万级以后,用户开始频繁反馈:

“查个报表动不动就几十秒,根本没法用。”
“点进去等半天没反应,刷新页面又重来一遍。”

我们通过监控系统发现,某些 SQL 查询的响应时间飙到了10秒以上,甚至有超时的情况。数据库 CPU 使用率一度飙升到95%以上,日志里也频繁出现慢查询记录。

这显然已经影响了用户体验,更严重的是可能影响整个平台的稳定性。


解决方案:不是换架构,而是先看清问题根源

解决方案:不是换架构,而是先看清问题根源

面对这样的情况,有些团队可能会选择直接升级架构,引入更多的中间件或者上分布式数据库。但我认为,在没有弄清问题本质之前贸然重构,往往会陷入更大的困境

所以我们第一步是做了三件事:

1. 数据采集与瓶颈定位

  • APM 工具埋点:使用 SkyWalking 对请求进行链路追踪
  • 慢查询日志分析:开启 MySQL 的 slow log,结合 pt-query-digest 做统计
  • 前端埋点:前端接口调用时间打点,确认到底是接口慢还是渲染慢

结果如下:

模块 平均耗时(ms) 占比
接口响应 3200~8000 70%
渲染/加载时间 800~1200 25%
网络延迟 <200 5%

问题显然出在后端接口响应时间上,进一步查看接口调用链路,发现有几个核心 API 的耗时特别高:

GET /api/report?dimension=region&metrics=sales

该接口的作用是对区域销售额做一个聚合统计,但当数据量超过 1000w 后,响应时间明显变长。查看执行计划发现:

EXPLAIN SELECT region, SUM(sales) FROM sales_data GROUP BY region;

结果表明:虽然 region 字段上有索引,但由于是 group by + sum,需要做大量的磁盘排序和临时表操作,效率非常低。

2. 技术方案讨论与权衡

针对这个问题,我们召开了几次小组会议,讨论了几种可能的解决方案:

方案 优点 缺点
加缓存(Redis) 快速有效 数据实时性差,维护成本高
分库分表 扩展性强 架构复杂,需业务代码适配
引入 OLAP 引擎 面向聚合查询优化 需要额外学习成本
物化视图 + 定时更新 实现简单,维护方便 存在一定延迟

最终我们决定走一条折中路线:在现有架构基础上加一个 ClickHouse 作为轻量级 OLAP 引擎,将一部分聚合类查询迁移到 ClickHouse 上处理。

开发工具界面-2

选择 ClickHouse 的理由:

  • 我们团队中有同学接触过 CK,有一定的经验基础
  • CK 对 group by、sum 等操作优化非常好
  • 插入性能也不错,可以定期同步 MySQL 数据
  • 成本可控,无需大规模重构

代码实践:如何实现数据同步与接口切换

这部分是我们真正开始落地的部分。我来分享一下我们是怎么做的。

1. 数据同步机制

我们采用了 Canal 监听 MySQL binlog 的方式来实现增量同步。Canal 是阿里巴巴开源的一个工具,可以订阅 MySQL 的 binlog 日志并以流的方式投递出去。我们通过 Kafka 作为消息队列接收这些变更事件,然后写了一个 Flink 任务消费这些消息,把数据插入到 ClickHouse 中。

数据流向如下:

MySQL --> Canal --> Kafka --> Flink Consumer --> ClickHouse

核心代码片段(Kafka 消费部分)如下:

FlinkKafkaConsumer<CanalRowData> kafkaSource = new FlinkKafkaConsumer<>(
        "canal-sales-data",
        new CanalJsonDeserializationSchema(),
        props);

DataStream<ClickhouseSalesRecord> processedStream = env.addSource(kafkaSource)
        .filter(record -> record.getTable().equals("sales_data"))
        .map(new MapFunction<CanalRowData, ClickhouseSalesRecord>() {
            @Override
            public ClickhouseSalesRecord map(CanalRowData value) {
                // 提取字段,转换为 Clickhouse 表结构
                return convertToCH(value);
            }
        });

processedStream.addSink(JdbcSink.sink(
    "INSERT INTO ch_sales_data(region, product_id, sales_amount, sale_time) VALUES(?,?,?,?)",
    (statement, record) -> {
        statement.setString(1, record.region);
        statement.setLong(2, record.productId);
        statement.setDouble(3, record.salesAmount);
        statement.setTimestamp(4, record.saleTime);
    },
    JdbcExecutionOptions.builder()
        .withBatchSize(1000)
        .build(),
    new JdbcConnectionPoolParameters(
        "jdbc:clickhouse://ch-host:8123/default",
        "default",
        "",
        DriverManager.getConnection
    )
));

2. 查询接口切换

原本的接口调用如下:

// 旧版接口逻辑
public List<RegionSales> getSalesReportByRegion() {
    String sql = "SELECT region, SUM(sales) FROM sales_data GROUP BY region";
    return jdbcTemplate.query(sql, RowMapper...);
}

现在我们改成:

// 新版接口,查询 ClickHouse
public List<RegionSales> getSalesReportByRegionFromCH() {
    String chSql = "SELECT region, SUM(sales_amount) FROM ch_sales_data GROUP BY region";
    return jdbcTemplate.query(chSql, RowMapper...);
}

为了避免一次性迁移的风险,我们在接口中加了个开关参数 useCh=true,可以通过配置或 URL 参数切换新旧逻辑,逐步验证准确性。


踩坑经验:那些你以为没问题的地方,其实都藏着雷

技术落地从来不会一帆风顺,我们在这个过程中踩了不少坑,总结几点特别想提醒大家的:

1. 数据同步延迟导致查询不一致

最开始我们用每小时同步一次的方式进行数据更新。但在某些业务场景下,用户刚录入完数据就立即查询,会发现数据没出来。这会影响用户体验。

解决方案

  • 在点击“提交”按钮之后手动触发一次同步
  • 或者在 CK 查询失败时 fallback 到 MySQL 查询未同步的部分
  • 后来我们改成了基于 Kafka + Flink 的实时同步模式(延时控制在秒级以内)

2. ClickHouse 表结构设计不合理导致查询慢

刚开始我们照着 MySQL 的表结构建了个 CK 表,结果某些 group by 查询反而比以前还慢!

后来查资料才发现 CK 的存储引擎更适合宽表扁平化设计,不适合嵌套结构过多或大量 join 操作。于是我们把维度表和事实表做了宽表预聚合处理,查询速度提升了十几倍。

3. 不合理的分区导致导入失败

我们一开始按照天来做分区:

CREATE TABLE ch_sales_data (
  region String,
  product_id UInt64,
  sales_amount Decimal(20,2),
  sale_time DateTime
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(sale_time)
ORDER BY (sale_time);

但数据量一大以后,每个 partition 的文件数剧增,插入性能下降明显,甚至出现 merge 操作占用 CPU 过高的问题。

后来调整为按月分区,并加上主键压缩:

PARTITION BY toYYYYMM(sale_time)
ORDER BY (product_id, sale_time)

效果提升非常明显。


效果总结:性能提升了多少?用户怎么说?

经过两周左右的改造,我们的系统终于恢复了应有的响应速度。

以下是改造前后的对比数据:

指标 改造前 改造后 提升幅度
平均接口响应时间 5.2s 320ms 降低约 94%
用户反馈好评率 62% 89% 提升27%
DB CPU 使用率 95%+ 稳定在20%以下 ——
查询并发支持能力 <50 QPS 可轻松支持 300 QPS 以上 提升6倍+

最可喜的是,用户反馈变得积极多了:

“现在查报表快了好多,基本不用再等着刷新了。”
“感觉系统更稳定了,偶尔网络抖动也不像以前那样经常崩溃。”


经验分享:给同行朋友们的一些建议

在这次实战中,我深刻体会到技术落地并不是简单的“换一套架构”或者“用一个新的中间件”,而是需要结合业务场景、团队能力、运维成本等多个维度去综合考虑。

下面是我总结的一些经验和建议:

1. 别一上来就想着“重构”

很多问题其实不需要推倒重来。先搞清楚问题出在哪里,再选择最合适的“手术刀式”优化方式。

2. 技术选型不能只看“流行榜”

CK 很适合我们这个场景,但如果你们的业务是以单条查询为主,频繁更新,那可能 MongoDB 更合适。关键是选型要契合业务需求,而不是追求时髦。

3. 数据一致性是个大问题,一定要提前规划好

无论是 MySQL 到 CK 的同步,还是缓存的更新机制,都会存在一定的数据滞后。你要么接受这种延迟,要么就要设计相应的补偿逻辑。

4. 做好降级方案和灰度发布

我们在上线 CK 查询的时候,保留了原来的 MySQL 查询通道。一旦出现问题,可以快速切回去,避免影响用户体验。

5. 团队的能力边界也是选型的一部分

如果你的团队没有人懂 Spark/Flink,那你很难把实时计算玩转起来。相反,如果你有成熟的 Kafka 团队,那么完全可以利用已有的资源去做实时管道。


结语:真正的技术沉淀,藏在日常的每一次探索中

技术对比分析-1

这次优化虽然不算太大,但却让我意识到:

所谓技术成长,不一定是在学了多么高级的算法或框架,而是你在实践中一步步解决问题、积累经验的过程。

技术探索和实践从来都不是一场炫技秀,而是一种持续迭代、不断反思和打磨的过程。每一个小问题的背后,都有可能是对架构设计、技术深度和团队协作能力的全面考验。

希望我的这段经历能对你有所启发。技术之路虽远,但我们一直在路上。愿你我都能在这条路上走得更稳、更远。

如果你也有类似的经历,欢迎留言交流,我们一起探讨更多真实、实用的解决方案。

(全文约3650字)

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝