这是一篇技术复盘。如果你也曾被”一个需求做大半个月、排查就花数天”的经历毒打过,那这篇文章大概能让你找到共鸣。
你有没有遇到过这种情况:一个看起来”还行”的需求,做着做着发现不对劲——数据对不上、接口超时、日志里出现了一堆你解释不了的东西,然后你开始怀疑人生?
我最近就经历了一次。一个关于推荐商品销量展示的功能,前前后后搞了快三周,排查问题就耗了三天。现在回过头看,有些坑其实是可以提前避开的,有些思路也是值得沉淀下来的。
这篇文章就是这次事件的完整复盘。同时在技术细节之外,也会聊聊排查问题的心路历程。如果你正在做类似的统计类需求,希望这篇文章能帮你少走一些弯路。
读到这里,不妨先想想:你上一次排查超过两天的问题,根因是什么? 是代码逻辑?是数据?还是——时区?(后面你就知道为什么我特意提这个了)
故事的背景是这样的:业务侧希望在推荐模块中展示商品的销量数据,帮助运营判断哪些款式值得重点推广。具体来说,需要展示两个核心指标:
同时还需要调用图像检索服务,根据商品图片匹配相似款。
需求本身并不复杂。但有两个关键挑战:
好了,了解完背景,你觉得这个需求大概要花多长时间?三天?一周?
实际答案是:21 天。是不是很离谱?往下看你就知道时间都花在哪了。
一开始的目标很简单:功能跑通就行。
但随着上线后接口耗时飙到 30 秒以上,目标升级为:把接口响应时间优化到可接受的范围内(目标 2 秒以内)。
拆解下来有三个核心任务:
你猜这三个里面哪个最难?反正我一开始完全猜错了。我以为销量查询是最难的,结果——数据对齐才是最折磨人的。
最开始的排查方式非常原始:不停打注释,看是哪段逻辑导致超时。
因为本地环境没办法模拟线上的数据量,每次改完代码发版就要等 6~8 分钟,在这期间只能干等着刷抖音(哈哈)。后来在同事的帮助下接入了线上日志,才算有了”眼睛”。
看日志之后发现了几个诡异的现象:
第一个问题后来定位到是循环逻辑的问题,第二个问题最终排查到是代理对象在异步线程中失效——缓存注解用的是 ThreadLocal,异步线程拿不到代理对象,缓存自然不生效。
这里想问问你:你的项目里有没有用到
ThreadLocal传递上下文的场景?如果丢到线程池里跑,还能正常 work 吗? 这个坑值得提前排查一下。

第三个问题是线程池被占满,大量任务排队等待。初步优化后,销量查询从 30 秒降到了 2~4 秒,图像检索通过缓存 + 异步也稳了下来,接口勉强可以用了。
但故事远没有结束。
上线后发现:在查大量商品时,接口依然要 30 秒左右。
这次明确了瓶颈:销量查询。即使单次查询降到了 2~5 秒,但一次请求可能要查上百个商品的销量,串行下来累积延迟就很可观了。
一位经验丰富的同事建议用物化视图做优化。思路是这样的:
效果立竿见影:
查询耗时从秒级直接降到了毫秒级,可以说是一次质变。
但兴奋了没五分钟,新问题就来了。
用物化视图跑出来的数据,和原来直接查订单表的数据对不上。不是差一点半点,而是有的多、有的少,毫无规律。

这就很诡异了——按理说,物化视图只是把原始数据按天聚合了一下,每天刷新一次,怎么会和原始口径不一致呢?
我开始了漫长的排查之路。
建物化视图时,为了拿到商品的产品 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 的视图。各司其职,口径清晰。
修完 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 年之前,物化视图里根本没有这段数据。
我们讨论了两个方案:
sku_id + channel + total_count + shelf_date + computed_at。最终选择了方案 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 day 比 BETWEEN 或者 <= end 更不容易出现 off-by-one 错误。统一这个规范能避免很多因边界理解不一致导致的口径偏差。
4. 异步线程 + ThreadLocal = 缓存失效
这个坑比较具体但很实用:如果你的缓存框架依赖 AOP 代理,而代理对象存储在 ThreadLocal 里,那么异步线程是拿不到代理的,缓存注解自然不生效。改用显式的缓存操作或者确保线程上下文中传递了必要的代理引用。
正在加载评论...