一次接口耗时从 30s 到秒开的优化复盘 —— 我做对了什么,又踩了什么坑?

2025 年 10 月 21 日

3408 字

17 分钟

工程

这是一篇技术复盘。如果你也曾被”一个需求做大半个月、排查就花数天”的经历毒打过,那这篇文章大概能让你找到共鸣。

写在前面

你有没有遇到过这种情况:一个看起来”还行”的需求,做着做着发现不对劲——数据对不上、接口超时、日志里出现了一堆你解释不了的东西,然后你开始怀疑人生?

我最近就经历了一次。一个关于推荐商品销量展示的功能,前前后后搞了快三周,排查问题就耗了三天。现在回过头看,有些坑其实是可以提前避开的,有些思路也是值得沉淀下来的。

这篇文章就是这次事件的完整复盘。同时在技术细节之外,也会聊聊排查问题的心路历程。如果你正在做类似的统计类需求,希望这篇文章能帮你少走一些弯路。

读到这里,不妨先想想:你上一次排查超过两天的问题,根因是什么? 是代码逻辑?是数据?还是——时区?(后面你就知道为什么我特意提这个了)

一个”看起来还行”的需求

故事的背景是这样的:业务侧希望在推荐模块中展示商品的销量数据,帮助运营判断哪些款式值得重点推广。具体来说,需要展示两个核心指标:

  1. 近 7 天日均销量 —— 反映商品近期的热度
  2. 商品上架 30 天内的总销量 —— 反映商品首发期的表现

同时还需要调用图像检索服务,根据商品图片匹配相似款。

需求本身并不复杂。但有两个关键挑战:

  • 销量数据量巨大 —— 订单相关表的数据量级在 1.7 亿行 级别(由于业务原因与数据产品问题没做分表),但一次查询扫全表基本等于自杀
  • 图像检索是外部服务 —— 不在本项目内,可用性、性能都无法直接控制
  • 本地几乎没法测试 —— 每次发版 6~8 分钟,出问题只能看线上日志

好了,了解完背景,你觉得这个需求大概要花多长时间?三天?一周?

实际答案是:21 天。是不是很离谱?往下看你就知道时间都花在哪了。

把 30 秒变成”能用”

一开始的目标很简单:功能跑通就行

但随着上线后接口耗时飙到 30 秒以上,目标升级为:把接口响应时间优化到可接受的范围内(目标 2 秒以内)。

拆解下来有三个核心任务:

  1. 销量查询优化 —— 这是最大的瓶颈,原始 SQL 在 1.7 亿行的表上跑,单次查询 2 分钟起步
  2. 图像检索调用优化 —— 批量调用时循环次数过多,缓存不生效
  3. 数据准确性校验 —— 优化后数据不能变,口径必须一致

你猜这三个里面哪个最难?反正我一开始完全猜错了。我以为销量查询是最难的,结果——数据对齐才是最折磨人的。

四轮排查,层层剥洋葱

第一轮:连蒙带猜的”盲人摸象”

最开始的排查方式非常原始:不停打注释,看是哪段逻辑导致超时

因为本地环境没办法模拟线上的数据量,每次改完代码发版就要等 6~8 分钟,在这期间只能干等着刷抖音(哈哈)。后来在同事的帮助下接入了线上日志,才算有了”眼睛”。

看日志之后发现了几个诡异的现象:

  1. 为什么图像检索的接口被调用了好几次,明明只应该调用一次?
  2. 为什么加了缓存之后,日志显示还是在查数据库?
  3. 为什么查数据量大的商品时,十几分钟后日志还在跑?

第一个问题后来定位到是循环逻辑的问题,第二个问题最终排查到是代理对象在异步线程中失效——缓存注解用的是 ThreadLocal,异步线程拿不到代理对象,缓存自然不生效。

这里想问问你:你的项目里有没有用到 ThreadLocal 传递上下文的场景?如果丢到线程池里跑,还能正常 work 吗? 这个坑值得提前排查一下。

image.png

第三个问题是线程池被占满,大量任务排队等待。初步优化后,销量查询从 30 秒降到了 2~4 秒,图像检索通过缓存 + 异步也稳了下来,接口勉强可以用了。

但故事远没有结束。

第二轮:物化视图 —— 从 30 秒到秒开的质变

上线后发现:在查大量商品时,接口依然要 30 秒左右。

这次明确了瓶颈:销量查询。即使单次查询降到了 2~5 秒,但一次请求可能要查上百个商品的销量,串行下来累积延迟就很可观了。

一位经验丰富的同事建议用物化视图做优化。思路是这样的:

  • 把订单数据按天、按 SKU、按渠道预聚合,生成一张日销量快照表
  • 查询时直接从快照表汇总,而不再扫订单主表
  • 每天定时刷新一次,保证数据相对新鲜

效果立竿见影:

查询耗时从秒级直接降到了毫秒级,可以说是一次质变。

但兴奋了没五分钟,新问题就来了。

第三轮:数据对不上 —— 灵异事件的真相

用物化视图跑出来的数据,和原来直接查订单表的数据对不上。不是差一点半点,而是有的多、有的少,毫无规律。

image.png

这就很诡异了——按理说,物化视图只是把原始数据按天聚合了一下,每天刷新一次,怎么会和原始口径不一致呢?

我开始了漫长的排查之路。

问题 1:多余的 INNER JOIN 在偷偷丢数据

建物化视图时,为了拿到商品的产品 ID,多 JOIN 了一张商品主表 sku_main,用的是 INNER JOIN

问题在于:订单详情里的 SKU ID,在商品主表里不一定都存在。历史 SKU 可能被归档、下架、甚至清理掉了。INNER JOIN 只要右边匹配不上,整行就丢了——这就是”丢单”的根因

当时和同事讨论这个点时,他给我打了个比方:INNER JOIN 就像两个人对暗号,对不上就当没见过;LEFT JOIN 是来者不拒,对不上就记个”未知”。你统计销量时应该”来者不拒”,而不是”挑三拣四”。

验证 SQL 非常简单:

-- 检查有多少 SKU 在商品主表里找不到
SELECT count(*) as lost_rows
FROM "order" o
JOIN order_detail od ON o.oid = od.oid
LEFT JOIN sku_main sm ON sm.id = od.sku_id
WHERE od.order_status = 1
  AND o.order_status = 3
  AND sm.id IS NULL;

结果 lost_rows > 0 —— 用 INNER JOIN 的物化视图确实丢数据了。

最终方案是把 SKU 维度的物化视图不 JOIN 商品主表,产品维度单独做一张带 LEFT JOIN 的视图。各司其职,口径清晰。

问题 2:时区 —— 国际化系统里被忽视的”日界”

修完 INNER JOIN 的问题后,数据还是对不上。

我又开始逐天、逐 SKU 地对比数据。在这个过程中,发现一个很奇怪的现象:统计的时间范围比预期多了一天。明明要的是近 7 天,实际汇总了 8 天的数据。但即便统一了时间窗口,差异依然存在。

然后我灵光一现——

物化视图里用 AT TIME ZONE 'Asia/Shanghai' 做了时区转换再取日期,而原来的查询直接用 LocalDateTime 构造区间。如果数据库的会话时区和应用时区不一致,跨午夜边界的订单就会被划到不同的”天”里。

读到这里你可能会想:“我们数据库是 UTC 的,时区统一,应该没这个问题吧?” —— 我当时也是这么想的。但实际上,订单表存储的时间戳是 timestamptz 类型(也就是带时区的 UTC 瞬时),而统计”某一天”这件事天然依赖时区定义。你的”今天”和用户的”今天”可能差 12 个小时。

数据库里存的其实是这样的:

2020-09-07 09:32:21.000000 +00:00

这是一个带时区的时间戳,存的是 UTC 时区的绝对瞬间。在业务需要按”天”做统计时,必须明确:用哪个时区来划分”一天”?

最终方案是统一使用上海时区做日界:

-- 物化视图中的日界定义
(O.order_add_time AT TIME ZONE 'Asia/Shanghai')::DATE AS sale_date

-- 查询端统一半开区间,避免 off-by-one
WHERE sale_date >= #{start} AND sale_date < #{end} + INTERVAL '1 day'

统一之后,数据——终于对上了。

时区排查过程

第四轮:和同事的深入讨论

在排查过程中,我和同事进行了好几轮深入的技术讨论(以下内容整理自当时的交流记录)。这些讨论帮我梳理清楚了几个关键问题:

讨论 1:为什么 INNER JOIN 会丢单?

同事的解释非常透彻:物化视图为了获取产品 ID 把订单明细表和商品主表做了 INNER JOIN,INNER JOIN 只取交集,而原始查询只查订单表,不依赖商品主表。所以一旦出现”订单里的 SKU 在商品主表里不存在”的情况,这些订单行在物化视图里就被过滤了——丢单。

他还特别提醒了一个细节:在 WHERE 子句里引用 LEFT JOIN 的右表列,会把 LEFT JOIN 打回 INNER JOIN 的效果。对右表的过滤条件要么放在 JOIN … ON 里,要么在 WHERE 里显式允许 NULL。

讨论 2:7 天和 30 天窗口的数据为什么有的偏大、有的偏小?

这其实说明偏差是”双向”的,不是系统性偏大或偏小。可能的原因包括:

  • 时区日界不一致(某些订单跨边界时被划到相邻日期)
  • 窗口口径不一致(含不含末日?用 <= 还是 <?)
  • 物化视图的刷新时点差(刷新后到查询前,订单状态可能变化)
  • 渠道过滤条件传参不一致

最后排查下来,最大的两个凶手就是 INNER JOIN 丢数据时区日界不一致。修完之后,大部分商品的 7 天/30 天数据就和原始口径对齐了。

讨论 3:上架超过 2 年的老商品,30 天首发期销量怎么统计?

这个问题很有代表性。物化视图只保留了近 2 年的日数据,如果某个商品 5 年前上架的,它的”上架 30 天窗口”完全落在 2 年之前,物化视图里根本没有这段数据。

我们讨论了两个方案:

  • 方案 A(推荐):建一张”首 30 天汇总表”长期保存,历史数据一次性回填,新商品增量维护。表设计很简单:sku_id + channel + total_count + shelf_date + computed_at
  • 方案 B(轻量):物化视图保留 2 年,2 年内的用物化视图,2 年前的回退到基表实时查(30 天窗口不大,带索引性能可接受)。

最终选择了方案 B 先快速落地,后续根据线上情况再评估是否升级到方案 A。


不只是性能数字

量化成果

指标优化前优化后
销量查询耗时2min+(原始)/ 20~30s(SQL 优化后)毫秒级(物化视图)
整体接口耗时30s+(经常超时)5s 以内
数据准确性与原始口径完全对齐
开发周期21 天(含 3 天排查)

沉淀下来的经验

1. 国际化系统中,“时间”不是你以为的时间

这是这次踩的最深的坑。当你的系统面向全球用户,订单可能从任何时区产生,而数据库用的是 timestamptz 类型——那么在做按”天”的统计时,必须显式指定业务时区来划分日界。靠数据库会话时区或者应用默认时区都是不可靠的。

2. 物化视图的 JOIN 要慎之又慎

物化视图本质是”快照”,但快照的口径必须和原始查询一致。建视图时多 JOIN 一张表看起来无害,但 INNER JOIN 就可能偷偷丢数据。能少 JOIN 就少 JOIN,必须要 JOIN 就用 LEFT JOIN,并且在 WHERE 里别不小心把 LEFT JOIN 退化回 INNER JOIN

3. 统计区间统一用”半开区间”

>= start AND < end + 1 dayBETWEEN 或者 <= end 更不容易出现 off-by-one 错误。统一这个规范能避免很多因边界理解不一致导致的口径偏差。

4. 异步线程 + ThreadLocal = 缓存失效

这个坑比较具体但很实用:如果你的缓存框架依赖 AOP 代理,而代理对象存储在 ThreadLocal 里,那么异步线程是拿不到代理的,缓存注解自然不生效。改用显式的缓存操作或者确保线程上下文中传递了必要的代理引用。

一次接口耗时从 30s 到秒开的优化复盘 —— 我做对了什么,又踩了什么坑?
https://momo.motues.top/blog/engineer/1/
作者
pront
发布时间
2025 年 10 月 21 日
许可协议
CC BY-NC-SA 4.0

正在加载评论...

输入关键词开始搜索