1. 价格力业务到底卡在哪里:为什么传统加工链路撑不住
先说个背景。淘天的价格力业务,不是大家想象中"调个价、打个折"那么简单。它背后有一整套围绕商品价格竞争力的场景:前台的价格表达(到手价、券后价、套装价、满减价),中台的比价看板,运营侧的降价提醒,以及风控侧的异常价格校验。这些场景有一个共同特点——它们都要求能快速感知价格变化,而且数据模型高度复杂。一个SKU的价格不是一张表里一个字段能描述的,它可能是"商品原价 + 营销优惠 + 会员折扣 + 运费模板 + 区域差异"共同算出来的结果。
我接手这个项目时的痛点非常直接:价格数据的加工链路太长了。上游是交易、营销、商品等多个业务域的ODS表,经过数仓层层加工,最后落到应用能直接查询的表里,整个链路往往要经过五六层调度。每一层调度都有等待时间,叠加起来就是T+1甚至T+2的数据时效。业务方提需求时说的是"我要今天实时看到某个商品的价格竞争力变化",但数仓给到的是"昨天的数据"。
这个矛盾在价格力场景里会被无限放大,因为价格是最敏感的变量。别的业务看T+1数据,最多是"决策慢了一点";价格业务看T+1数据,等于在价格战里闭着眼睛打仗。竞对凌晨改了价,我们第二天中午才发现,再去跟价就已经错失窗口了。
传统链路还有第二个问题:中间表太多了。为了支撑不同场景的查询,数仓给每个场景都物化了一张宽表。同一份价格事实数据,在比价场景里要一张表,在价格校验场景里要一张表,在运营看板里又要一张表。数据冗余不说,更麻烦的是多个任务都在重复加工同一份基础数据,任何一个上游字段变更,所有下游任务都需要跟着改。我们统计过,当时价格力相关的加工任务总数超过200个,真正独立的逻辑可能只有四五十个,剩下全是重复。
第三个问题是查询侧的扩展性。价格力场景的查询有一个特点:过滤条件极度多样。运营要查"某品类下价格竞争力Top100的商品",风控要查"某段时间内价格异常波动的SKU",前台要查"某个具体商品当前到手价"。这些查询如果都落到一个通用大宽表上,索引设计很难兼顾,要么存储膨胀,要么查询超时。
我当时就意识到,这个场景缺的不是"再加一层表"或者"再优化一个SQL",而是一种全新的加工范式:链路要短,数据要新,模型要灵活。这也是我们后来引入Hologres Dynamic Table的核心原因。
2. Dynamic Table是什么:它和普通物化视图、实时计算到底有什么本质区别
在最开始接触Dynamic Table时,团队内部有过一轮讨论:这不就是物化视图吗?或者更进一步说,拿Flink做实时ETL不就行了?这两个疑问都很合理,但实际深入之后会发现,Dynamic Table解决的是这两者之间的空心地带。
2.1 物化视图的"半自动"困境
传统数仓的物化视图,核心能力是把一段查询逻辑固化下来,预计算好结果。但它有两个关键短板:第一,它通常基于批量调度触发刷新,刷新粒度粗,对上游变更的感知是被动的;第二,复杂的多表JOIN物化视图,在增量刷新场景下往往退化成全量重算,数据量一大,刷新成本直线上升。熟悉数仓的读者应该都知道,很多数据库的物化视图在JOIN场景下根本不支持增量刷新,只能"删了重建"或者"全量覆盖"。
所以在价格力业务里,如果用普通物化视图,我们很快会撞上那堵墙:上游表一分钟变一次,物化视图刷新一次要十分钟,刷新过来数据又过期了。而且价格计算涉及的表多了以后,物化视图之间的依赖关系基本要靠人肉维护,和写调度任务没有本质区别。
2.2 实时计算的“杀鸡用牛刀”困境
那我直接用Flink做实时ETL行不行?技术上当然行,Flink可以做到秒级延迟。问题出在运维成本和开发效率上。价格力场景里有大量"逻辑相近但细节不同"的加工任务,如果用Flink,每一条链路都要独立开发、独立部署、独立监控,还要管理Checkpoint、处理状态后端、处理数据回溯。更麻烦的是,上游表结构一变,Flink作业就要重启改代码,这个迭代成本对业务侧来说很难接受。
我们当时评估过一个比价场景的实时化改造,开发周期预估要三周,还得配一个专职实时开发。而同样是这个场景,用Dynamic Table只花了一个下午就写完了DDL。
2.3 Dynamic Table的核心机制:自动依赖、增量刷新、检索加速
Dynamic Table把"物化视图的便捷"和"实时计算的时效"做了一个比较务实的折中。我们来看它底层做了哪几件事:
- 自动依赖发现:创建Dynamic Table时指定刷新周期(比如5分钟),Hologres会自动分析SQL里引用了哪些上游表,建立血缘关系。上游表结构变化时,系统能自动感知并提示你需要重建动态表。
- 增量刷新引擎:Dynamic Table内部会捕获上游表的增量变更(Hologres的Binlog机制),在刷新周期内只处理变更的数据,而不是整表重算。这一点在JOIN场景下特别重要——它会把一张动态表拆成多个子任务,每个子任务维护各自的增量状态,尽可能避免全量扫描。
- 查询加速一体:动态表本身还是一张Hologres表,建好后直接就能用索引、分区、Bitmap等能力做查询加速,不需要像Flink那样把结果再导一次到OLAP引擎。
打个比方,传统物化视图像"每天早上定时打扫房间",实时计算像"请了个保洁员坐在房间里,地上掉一根头发马上捡起来",Dynamic Table则是"装了智能传感器,每五分钟自动扫一遍,哪里脏了清理哪里"。它不是最实时的,但它是成本、时效、维护三者的平衡点。
3. 价格力场景的Dynamic Table落地设计:从数据模型到DDL实操
理论说完了,讲点实际的。我们落地时没有把Dynamic Table当成一个"新玩具"直接接到现有链路上,而是先做了两步关键设计:模型改造和刷新策略设计。这两步如果没想清楚,直接把原来的SQL套过来建动态表,性能大概率不会比原来好,甚至可能更差。
3.1 模型改造:把大宽表拆成星型模型
价格力业务过去习惯用大宽表,一张表里把商品、价格、优惠、店铺信息全部冗余进去,查询方便但更新代价极大。Dynamic Table场景下,我强烈建议先把模型改成星型结构:事实表放价格事件,维度表放商品、店铺、活动等属性,动态表在最上层做JOIN和聚合。
这样做有两个原因。第一,动态表的增量刷新效率取决于上游表的变更频率,如果上游是一张大宽表,任何字段变了都会触发整行变更,动态表要处理的“变更噪声”会非常大。拆成星型后,价格事实表只负责价格相关的变更,商品属性变了只动维度表,互不干扰。第二,星型模型天然适合动态表按需刷新——你可以让核心的价格动态表5分钟刷一次,让商品画像动态表30分钟刷一次,让店铺维表1小时刷一次,而不是一荣俱荣一损俱损。
3.2 核心DDL设计与参数选择
下面是我们一个典型的价格竞争力看板场景的DDL骨架,我脱敏后分享出来,重点看设计思路:
-- 价格事实表:记录每个SKU在某个时间点的最终到手价 CREATE TABLE fact_sku_price ( sku_id TEXT NOT NULL, item_id TEXT NOT NULL, shop_id TEXT NOT NULL, biz_date TEXT NOT NULL, final_price DOUBLE PRECISION, origin_price DOUBLE PRECISION, discount_amount DOUBLE PRECISION, price_change_time TIMESTAMPTZ, PRIMARY KEY (sku_id, biz_date) ) PARTITION BY LIST (biz_date); -- 商品维度表 CREATE TABLE dim_item ( item_id TEXT PRIMARY KEY, category_id TEXT, brand_id TEXT, title TEXT, status INT, -- 其他属性字段 ); -- 动态表:按商品维度聚合价格竞争力指标 CREATE DYNAMIC TABLE dws_item_price_power WITH ( refresh_mode = 'auto', -- 自动模式,系统按需决定刷新频率 refresh_interval = '300', -- 刷新周期,单位秒,即5分钟 append_only = 'false', -- 非追加表,需要更新已有行的聚合结果 max_retry_times = '5' -- 刷新失败最大重试次数 ) AS SELECT item_id, category_id, COUNT(DISTINCT sku_id) AS sku_cnt, MIN(final_price) AS min_price, MAX(final_price) AS max_price, ROUND(AVG(final_price), 2) AS avg_price, SUM(CASE WHEN final_price <= origin_price * 0.8 THEN 1 ELSE 0 END) AS discount_sku_cnt, MAX(price_change_time) AS last_change_time FROM fact_sku_price JOIN dim_item ON fact_sku_price.item_id = dim_item.item_id WHERE biz_date = CURRENT_DATE GROUP BY item_id, category_id;这个DDL里有几个参数值得专门讲一下。
refresh_mode和refresh_interval的关系。refresh_mode='auto'意味着Hologres会根据上游变更量和查询压力自动判断是否提前刷新,但刷新周期仍然受refresh_interval约束。如果业务要求"最多接受5分钟延迟",那refresh_interval设成300;如果业务要求严格保证30秒内感知,那就必须设成30,但也要接受背后更大的计算开销。我们实际测下来,在"价格变更比较集中"的时段(比如大促预热期),auto模式会在300秒周期内自动触发多次增量刷新,整体延迟大约在1~3分钟,效果比固定周期好不少。
JOIN的顺序问题。DDL里是先JOIN维度表再聚合,这个顺序对Dynamic Table的增量处理很关键。如果维度表很大且频繁变化,JOIN产生的变更量会被放大;所以我们把维度表过滤条件(status、category等)尽量前推,让参与JOIN的维度行数尽量小。这个优化在普通SQL里看不出来,但在动态表的每次增量刷新中收益非常明显。
append_only的取舍。如果你的动态表只需要插入新数据(比如按时间累积的流水型指标),可以设置append_only='true',那样刷新性能更好,因为是纯追加。但价格力场景大多数是"更新已有商品的最新价格状态",必须设成false,接受它的更新开销。
3.3 刷新策略:不同场景用不同节奏
我们在项目中形成了三档刷新节奏,这里整理给各位参考:
| 场景类型 | 典型用途 | 刷新间隔 | 说明 |
|---|---|---|---|
| 强时效 | 价格异常告警、竞对跟价 | 30s - 60s | 代价高,仅用于关键业务线 |
| 均衡型 | 价格竞争力看板、运营分析 | 5min - 15min | 性价比最高,覆盖80%场景 |
| 弱时效 | 月度趋势报表、复盘分析 | 1h - 1d | 用于低频聚合,减少资源消耗 |
有一点必须提醒:Dynamic Table不是越短越好。刷新间隔太短,上游一有风吹草动就重算,资源消耗会线性上升。我们在压测时发现,同样的SQL逻辑,30秒刷新和5分钟刷新,前者带来的CPU开销大约是后者的4倍(因为增量批次的调度、状态合并、写表都有固定开销)。所以选刷新间隔前,先问业务一句:"你能接受的最高延迟是多少?",然后取那个值,而不是直接拍脑袋选最短。
4. 上线实测:延迟、成本、稳定性这三本账是怎么算平的
模型设计完了,DDL写完了,最终还是要用数据说话。上线前我们做了压测,也跑了线上对比,三个维度的结果都超出预期,但中间也踩了几个坑说给你听。
4.1 延迟对比:分钟级时代确实来了
改造前,价格力看板的数据链路是这样的:上游ODS表 -> 离线任务(T+1)-> Hologres结果表 -> 应用查询。全程数据产出延迟稳定在20小时以上。改造后,链路变成:上游ODS表 -> Dynamic Table(增量刷新)-> 应用直接查动态表。实际线上运行时,价格变更到看板可见的平均延迟大约在2~4分钟,大促高峰期由于变更事件密集,大部分增量刷新会被auto模式提前触发,延迟能进一步压到1分钟以内。
这个延迟水平对于比价和跟价场景已经完全够用了。业务方从"看昨天的价格"变成了"看几分钟前的价格",这个体验跨越是质变。
4.2 成本对比:一个让人意外的结论
上线前我一度担心Dynamic Table的增量刷新会带来额外计算成本,但实际账单出来后反而省了钱。核心原因有两个:一是原先200多个加工任务里大量重复计算被合并掉了,不需要再为每个场景各跑一遍相同的JOIN;二是增量刷新确实比我们想象中“克制”,它并不是频繁地全量扫描,而是基于Binlog定位变更分区后局部刷新。
这里给出一个脱敏后的成本参考数据(按我们集群当时的价格折算):原离线加工链路每日计算成本约为X CU·时(换算成费用大约是每月若干万),Dynamic Table上线后,新增的动态表刷新计算成本大约只有原来的15%~20%,同时因为下线了一大批重复的离线加工任务,总算力成本反而降了30%左右。算下来,这个项目不仅提升了时效,还省了钱。
4.3 稳定性表现与踩坑记录
任何新技术上线都不会一帆风顺,我们前前后后也遇到了三个比较有代表性的问题,值得展开说说。
坑一:DDL变更引发的动态表重建风暴。Dynamic Table的SQL逻辑一旦变更(比如加一个字段、改一个JOIN条件),系统要求重建动态表。在我们的环境里,一张大动态表重建需要全量回刷,期间不能再刷新,这就造成了一段空窗期。最初我们没有建“影子动态表”的习惯,每次改逻辑都要业务侧停查几分钟。后来我们总结的规矩是:任何逻辑变更都先建一张动态表V2,跑一段时间对比数据无误后再做切换。这个习惯救了我们好几次,特别是大促前改配置的时候。
坑二:维度表高频更新的放大效应。有一张商品属性维度表变更频率很高(每秒钟有大量UPDATE),而它又JOIN了主价格动态表。这导致每次维度表变更,动态表都要重新处理关联行的增量状态,一度造成刷新任务堆积。排查后发现是我们的问题——这张维度表把“真正变化的属性”和“每次写入都会变化的时间戳字段”放在了一起,实际上属性值没变,但行版本变了,Binlog里看起来就是一次变更。后来我们把时间戳字段拆到单独的扩展表,只让动态表JOIN真正需要的属性列,问题立刻缓解。这一点建议所有用Dynamic Table的人提前检查:你的上游维度表是真的在变,还是只是“看起来在变”。
坑三:同一张表被多个动态表引用时的资源竞争。价格事实表被大约七八个动态表同时引用,刷新任务高峰期会短暂竞争IO和CPU。我们后来按动态表的重要程度设置了不同刷新周期,让它们错峰刷新,资源曲线就平滑了很多。这里运营商用了一个技巧:低优级的看板类动态表刻意把刷新周期从5分钟拉到15分钟,业务影响几乎为零,但集群高峰期压力明显下降了。
5. 运行半年后的复盘:Dynamic Table适用的边界在哪里
项目上线跑了半年多,回过头来给这套方案做个评述,也说说哪些场景不适合用它。这可能是市面上文章很少讲的部分。
先说适合的场景,我归纳为三个特征:
- 数据链路有明显的“多次读取、重复加工”现象,同一个基础数据被多个下游使用;
- 时效要求是分钟级而不是秒级,秒级场景请老老实实用流计算;
- 业务逻辑是相对稳定的(SQL不经常改,或者允许通过“V2影子表”的方式平滑变更)。
这三个特征价格力业务全中,所以落地效果比较理想。
再看不适用的场景,也给大家避个雷:
- 有复杂窗口计算的场景,比如要维护"过去30天每个小时的最高价"这种带形态的状态计算,Dynamic Table的增量刷新模型处理起来会很别扭,这类用Flink的状态管理更合适;
- 上游数据质量极差、频繁大范围回撤的场景,动态表基于增量变更刷新,一旦上游发生大规模回刷,动态表需要跟着重算大量数据,资源开销不可控。如果你所在业务的数据链路经常半夜回刷数据,建议先在ODS层加一个"数据稳定标记",避免动态表被无效刷新拖垮;
- 需要事务性强一致的场景,比如价格修改后下游必须严格一致读到最新值,Dynamic Table的异步刷新模型不满足这种强一致诉求,这种情况得走在线接口直查,不能依赖离线链路。
我们内部后来还展开过一次讨论:Dynamic Table到底算“物化视图的进化”还是“轻量版流计算”?最后的结论是,它更应该被理解为**“数仓和OLAP之间的一个自动化加工层”**。它消化掉了大量原本需要人工维护的中间加工逻辑,让数据从产生到可查的路径尽量变短,又不至于像流计算那样需要长期贴身运维。对于淘天价格力这种“模型多、链路杂、时效要求又不算极致”的业务,它确实是一味对症的药。
最后再分享一个我现在坚持的做法:任何一张动态表上线前,我都会手工查一遍它依赖的所有上游表的“真实变更频率”分布,而不只是看建表SQL。因为Dynamic Table的所有性能、成本、稳定性,几乎都取决于上游的变更形态。这一条想明白了,后面能少走很多弯路。