1. 项目背景与方案选型——商品推荐为什么要用Doris
最近在做一个零售科技方向的推荐系统改造,核心场景并不复杂:用户打开小程序或者门店导购后台,系统需要根据用户的历史行为、实时兴趣、商品库存状态和促销档期,在几百毫秒内给出一批“TA可能想买的东西”。听起来是各大电商玩了十年的套路,但落到线下零售和B2C混合业态里,难点反而不在算法本身,而在于数据怎么高效流动、实时特征怎么算、几十万SKU的动态状态怎么过滤。
这个项目落地时,我选了Apache Doris作为推荐链路的OLAP引擎。Doris这个词在数据库圈子里不陌生,它是一个MPP架构的分析型数据库,主打极速的多维分析查询和实时OLAP场景。但在推荐系统里用Doris当核心存储和计算引擎,很多同行第一反应是“这不是数仓干活用的吗,怎么跑到推荐服务链路里来了”。实际做完一版之后,我的体会是:Doris在零售商品推荐这种数据量大、请求维度多、实时性要求高的场景里,比传统ES、Redis+离线表的组合顺手很多,尤其在特征宽表、候选圈选和实时召回这三个环节,属于那种“刚开始怀疑,用完就回不去”的组件。
这篇内容我会从选型思路一路聊到部署架构、表模型设计、SQL实现、性能调优和上线后的真实问题排查。不打算写成Doris官方文档的复述版,而是把我在零售科技推荐项目里踩过的坑和验证过的方案拆开讲,适合正在做类似推荐系统、或者在Doris和消息队列之间纠结选型的工程师参考。
1.1 零售商品推荐系统里,Doris到底扮演什么角色
先看一张逻辑分层:零售推荐系统的数据链路可以拆成四段。第一段是数据源,包括交易订单、商品中心、会员档案、浏览日志、库存快照;第二段是数据集成层,负责把MySQL、Kafka和日志文件里的数据同步到分析引擎;第三段是特征与候选集层,这是推荐系统的弹药库,需要支撑“用户最近30天看过什么类目”“这件商品当前是否参加满减”“同城门店是否还有现货”这类查询;第四段是服务层,推荐引擎拿到特征后做召回、排序、重排,最终输出给前端。
Doris在这个链条里承担的是第三段的核心,同时兼顾一部分第二段和第四段的边界职责。我们最早试过用ES做商品筛选,再用Redis存用户实时序列,但问题很快暴露:ES擅长全文检索和倒排,但做多条件聚合、Join用户画像分群、按时间窗口做去重统计都很别扭;Redis能扛高并发,但存不了复杂的分析型查询。而Doris的特点是SQL接口完整、查询优化器成熟、支持极速的聚合和Join,非常适合把“用户画像分群”“商品动态属性”“实时行为序列”这三类数据放在一起做组合查询。
说白了,推荐系统需要的不是简单的KV查询,而是带着过滤条件、聚合窗口和排序逻辑的分析型查询。Doris把这类查询的延迟压到了几十到两三百毫秒,单集群吞吐也能支撑店面小程序这种万级QPS以下的量级。这就够了。
1.2 为什么是Doris,而不是ClickHouse、StarRocks或者直接上离线数仓
选型阶段,团队内部确实吵过几轮。我们列了几个候选:ClickHouse、StarRocks、Doris,外加原本在用的Hive数仓。Hive先被淘汰,原因是推荐特征有强实时性要求,用户上午刚浏览过某件商品,下午你再推荐一个老库存,转化率会非常难看。Hive按天级调度产出物化宽表,对“小时级以下”的玩法完全没有响应能力。
ClickHouse的强项是单表聚合极其恐怖,比如计算全站点击量TOP100,它能秒出结果。但在零售推荐这个场景里,我最需要的是多表关联、精确去重和灵活的增量更新。ClickHouse的Join能力偏弱,数据更新走Mutation路径很笨重,做bitmap精确去重需要自己维护墓碑,复杂度完全不可控。我们试过把行为日志和商品标签表做常规Join,查询稍复杂一点,CPU就飙得很难看。
StarRocks是Doris的一个分支,能力上两者很接近,也是优秀的MPP分析库。我们最终选Doris的原因更多是可控性:社区版本开源节奏稳定,部署形态清晰,FE/BE的分层架构和云原生部署不那么“重”,我们云上的机器配置不用调整就能跑起来。另外,Doris在新版本里引入了Bitmap索引、Ngram BloomFilter索引、异步物化视图,这几个能力正好能解决零售推荐里最头疼的“多条件过滤”“模糊匹配”“预聚合加速”问题。
所以结论是:Doris赢在“能分析、能关联、能实时更新、SQL生态友好”这四个象限的综合分。它不是单项冠军,但在这个场景里是最均衡的选择。
1.3 项目目标与技术指标
简单交代一下这个项目要交付的目标。整体架构要支撑一个中型零售科技平台的推荐能力:对接商品系统、订单系统和埋点日志,覆盖线上小程序商城和线下门店导购两个入口。目标指标有三个。
第一,交卷速度。推荐接口P95响应时间不超过500毫秒,其中特征组装和候选集查询的耗时预算只有200毫秒。第二,新鲜度。用户从浏览商品到行为特征出现在推荐结果里的时间差不超过5分钟。第三,灵活性。运营配置的促销活动、新品打标、门店库存状态能实时影响推荐结果,不能等T+1离线加工。
这些目标单靠MySQL或者离线数仓都做不到,而Doris给出的方案是:行为数据走Kafka实时导入,商品主数据和库存快照走周期性批量同步,在Doris里建好明细表、汇总表和维表,然后用SQL直接产出推荐候选集和特征集。听着很直接,但落地的时候有不少细节需要磨,下面分章节展开。
2. 部署架构与表模型设计——从集群规划到推荐主题层
2.1 Doris集群部署与FE/BE规划
Doris的架构核心是两个角色:FE负责元数据管理、查询解析和调度,BE负责数据存储和计算执行。一个MySQL协议端口就能连上,对应用层非常友好。实际部署上,我用了5台云主机:FE部署3个节点组成高可用组,BE部署5个节点,数据和计算混部。
很多人纠结FE要不要单独部署,我的经验是:小规模项目FE和BE可以先不混部,但FE的Java Heap要留足。FE吃内存不主要在数据上,而是JVM里的元数据信息和查询会话。如果查询并发上来,FE的GC停顿会直接拖垮查询响应,那体验相当难受。推荐的最小配置是FE独立4C8G起步,BE按照每节点16C64G来规划,磁盘用SSD,尤其是存放最近热数据的SSD,性能收益是实打实的。
部署完成之后有一个优化项容易忽略:BE的be.conf里storage_root_path参数可以配置多块盘,多个目录用分号分隔。我们一开始图省事只配了一个目录,结果单块盘IO饱和,查询和导入互相拖累。后来把系统盘、数据盘分开,每个BE节点配两块SSD做数据目录,IO压力立刻缓解了。
2.2 推荐核心表的模型设计:明细表、聚合表、Unique表怎么选
Doris建表时最重要的决策是数据模型。零售推荐场景里,最核心的三张表分别是:用户行为明细表、商品实时状态表、用户特征聚合表。它们各自对应不同的写入模式。
行为明细表用的是Duplicate模型,也就是明细模型。每一行就是一条独立的用户行为,比如点击、收藏、加购、下单。Duplicate模型不做任何合并,适合保留全部原始数据。在这张表上做时间窗口查询和去重统计时,用的是Doris的bitmap或count distinct函数,灵活性最高。
商品实时状态表用的是Unique模型,主键是商品ID。商品的价格、库存、促销标签、上下架状态都在变,每次更新只需要按主键覆盖旧记录。Unique模型在Doris里的实现是写时合并或读时合并,新版本默认走写时合并,查询性能明显更好。但要注意,写时合并对导入频率有一定的额外开销,如果导入太频繁,建议在PROPERTIES里设置合理的sequence列和enable_unique_key_merge_on_write参数。
用户特征聚合表用的是Aggregate模型。它存的是用户在不同维度上的聚合特征,比如近7天点击次数、近30天加购次数、偏好类目前三。用Aggregate模型的话,Doris会在导入过程中自动按维度列做SUM聚合,查询时直接返回预聚合结果,不需要现场跑GROUP BY。这个设计对推荐特征查询是降维打击式的优化,几百毫秒的查询能压到几十毫秒。
建表时有一组和容量、性能直接相关的参数值得盯住:分桶数和分桶键。Doris的底层数据是按分桶存储的,分桶键的选择直接影响查询裁剪和Join时数据的本地性。我们的经验是:分桶键选用户ID或者用户ID+日期组合,避免选商品ID。原因是请求侧几乎总是“给定用户ID,查TA的行为和特征”,按用户ID分桶能让查询落到少数几个桶里,而不是全表扫描。
一个实用的建表示例(关键结构):
CREATE TABLE user_behavior_detail ( user_id LARGEINT NOT NULL, item_id LARGEINT NOT NULL, behavior_type VARCHAR(16), category_id BIGINT, shop_id BIGINT, event_time DATETIME, ext_info JSONB ) DUPLICATE KEY(user_id, item_id, event_time) PARTITION BY RANGE(event_time)() DISTRIBUTED BY HASH(user_id) BUCKETS 48 PROPERTIES("replication_num" = "2");这里要说明的是,DISTRIBUTED BY HASH(user_id)是让所有相同用户的记录落到同一个分桶,后续做用户级Join和聚合时可以避免跨节点数据重排。而分区用event_time按天或按周划分,方便查询时做时间裁剪,也方便清理过期数据。
2.3 数据导入策略:Routine Load消费Kafka,离线同步刷维表
Doris的数据导入有好几种姿势:Stream Load适合一次性批量导入,Broker Load适合离线文件,Routine Load适合消费Kafka,Insert Into适合程序直接写入。我们在推荐场景里用得最多的是Routine Load和Stream Load。
用户行为日志在Kafka里,topic按业务分成点击流、加购流、订单流。Doris里为每个topic建一张明细表,然后起一个Routine Load任务持续消费。Routine Load的用法不复杂,一句SQL就能拉起:
CREATE ROUTINE LOAD job_user_click ON user_behavior_detail COLUMNS(user_id, item_id, behavior_type, category_id, shop_id, event_time) PROPERTIES ( "desired_concurrent_number" = "3", "max_batch_interval" = "10", "format" = "json" ) FROM KAFKA ( "kafka_broker_list" = "broker01:9092,broker02:9092", "kafka_topic" = "ods_user_click_log", "kafka_partitions" = "0,1,2", "kafka_offsets" = "OFFSET_BEGINNING" );这里有个经验:desired_concurrent_number不要盲目调大。它代表Routine Load并行消费分区的个数,分区总数固定时调大这个参数只会增加调度负担,不会提升吞吐。另外max_batch_interval控制攒批时间,建议保持5到10秒,太大就失去实时的意义,太小请求碎片化严重,BE的导入合并压力会变大。
商品主数据和库存快照则不走实时流。商品变更频率不高,但每次全量刷新又太重,我们用的是Stream Load配合定时任务,每天凌晨全量拉一次,白天的变更通过监听MySQL Binlog增量同步到Unique模型表里。这种“全量+增量”组合让数据延迟控制在1分钟以内,对推荐结果的时效性来说完全够用。
3. 核心推荐实现——特征计算、候选召回与排序特征组装
3.1 特征宽表:一次查询拿到推荐模型需要的全部特征
推荐模型侧需要的特征往往是宽表形态:一行代表一个“用户×商品”对,列包括用户侧特征、商品侧特征、交叉特征和实时行为特征。按照常规思路,特征拼接在Java服务里做,先查用户画像、再查商品标签、最后查行为序列,三次网络来回,延迟基本超过预算。Doris的解决方案是直接建一张特征宽表,把能预计算的特征都物化进去。
我用的是Unique模型,主键是user_id + item_id的组合。宽表里的列可以分成几组:用户固定特征(性别、年龄段、会员等级)、用户兴趣特征(近7天点击类目分布、近30天购买价格带)、商品静态特征(类目、品牌、风格标签)、商品动态特征(当前价格、库存水位、是否参加活动)、交叉特征(该用户最近浏览这个商品的时间、该用户是否买过同品牌商品)。
预计算通过定时任务或实时触发更新。查询端就像这样简单粗暴:
SELECT * FROM feature_wide_table WHERE user_id = 1000234 AND item_id IN (102345, 105678, 109012)这条SQL在Doris里返回的是完整特征向量,服务端拿到后直接灌给排序模型。预计算把复杂关联全部折叠进导入过程,让在线查询路径变得极短。这里的取舍是:特征宽表会占用较多存储空间,而且特征逻辑变化时,重建表的过程很痛苦。所以设计中要预留版本号字段,模型迭代时更好切换。
3.2 基于Doris的商品候选召回:不止是热门榜,还要千人千面
纯离线算好的用户推荐列表,Doris根本不需要上场。Doris发挥最大价值的场景是“实时个性化候选召回”。
第一路召回是“看了又看”。用户当前浏览某件商品,需要找出同类目、同价格带、同风格的其他商品,排除自己已购、已下架、当前门店无货的SKU。这类逻辑用SQL表达极其自然:
SELECT i.item_id, i.title, i.price, i.sales_30d FROM item_dim i WHERE i.category_id = 20801 AND i.price BETWEEN 199 AND 599 AND i.status = 'on_sale' AND i.stock_qty > 0 AND i.item_id NOT IN (SELECT item_id FROM user_behavior_detail WHERE user_id = 1000234 AND behavior_type = 'buy') ORDER BY i.sales_30d DESC LIMIT 200;这类查询单次耗时在Doris上实测大约80到150毫秒,完全可以塞进推荐接口的同步链路里。
第二路召回是“实时热门修正”。用Doris基于最近1小时的加购数据统计类目实时热门榜,再叠加地域维度。线下零售场景里地域属性非常重要,某地门店畅销品在另一个城市可能完全卖不动,不能只用全网的热门榜。
SQL里用窗口函数或者普通聚合就能完成:
SELECT category_id, item_id, COUNT(*) AS add_cart_cnt FROM user_behavior_detail WHERE behavior_type = 'add_cart' AND event_time >= NOW() - INTERVAL 1 HOUR GROUP BY category_id, item_id ORDER BY add_cart_cnt DESC LIMIT 50;这类“短时间窗口聚合”计算量相当大,但Doris的向量化执行引擎能扛。我们设置了几个常用时间窗口的物化视图自动刷新,查询命中物化视图后延迟能降到20毫秒以内。
第三路召回是向量召回,也就是把用户和商品的Embedding存到Doris里,通过计算向量相似度来召回。新版本的Doris支持了向量检索能力,但这一路我们没有完全依赖Doris,原因是系统还在早期,向量召回放在Doris里做需要维护向量索引重建,复杂度偏高。我们采用的做法是先用Doris管理Embedding表的更新和特征关联,真正算相似度时通过外部向量检索引擎完成,Doris负责把模型侧需要的向量和其它特征合并成最终输出。这套组合在稳定性和性能之间找到了平衡点。
3.3 推荐服务层的组装与缓存设计
服务层做的工作本质上是“拼接”和“决策”。每次推荐请求进来,先判断请求用户是新客还是老客。新客没有行为历史,走的是基础热门榜加新品加权;老客则走完整链路。
完整链路的实现流程是:先根据用户ID和上下文(当前浏览的商品、所在门店、促销活动ID)并行发起几路候选召回,包括Doris里的实时个性化召回、协同过滤候选、运营配置的固定商品池;各路召回结果做合并去重,得到一个几百规模的候选集;拿到候选集后,去Doris特征宽表批量取特征,拼装成排序模型需要的输入格式;排序模型打分之后,再做业务规则重排,比如排除库存不足的商品、控制同类目商品的出现比例、插入运营配置的置顶商品。
这里Doris承担了最后一步的关键能力:批量特征查询。推荐模型一次要给几百个商品打分,每个商品都要带特征,如果用逐条SQL查询,延迟直接爆炸。我们的做法是把候选集拼成一个大IN条件,用一条SQL取回所有候选商品的特征向量,实测300个候选商品的特征查询耗时在40到60毫秒。这个性能非常关键,它保证了排序模型有足够的特征输入,而不是像很多离线方案那样只用几个稀疏特征凑合。
缓存层面,业务侧用Redis缓存用户维度的推荐结果,TTL设为3分钟。Doris侧本身不承担请求级缓存职责,它负责的是“实时准”的特征查询,但服务层会加一层结果缓存防止冷启动和突发流量打垮查询链路。线上真实情况是,推荐接口P95响应时间稳定在300毫秒左右,其中Doris侧特征查询加候选召回合计控制在120毫秒以内,余量都给排序模型和服务开销。
4. 性能调优与常见问题排查实录
4.1 查询压测和四个必须盯住的调优点
上线前我们对Doris集群做了为期一周的压测,压测工具用的是JMeter模拟推荐接口流量。压测过程中发现了几个典型的性能瓶颈,整理了四个调优方向,效果非常明显。
第一,存储层关健:关闭不需要的索引和物化视图。Doris里每个索引和物化视图都会增加导入和查询开销。我们犯了“把所有列都加索引”的毛病,导致导入吞吐掉了将近一半。后来只保留了高频过滤字段的Bitmap索引和短时间窗口查询的物化视图,导入效率立刻回升。
第二,分桶数的选择需要结合数据量和BE节点数。分桶过少会导致单桶数据量过大,查询并行度上不来;分桶过多则元数据膨胀、查询时调度开销变大。我们的行为明细表总数据量约2亿行,48个分桶能平均分配到5个BE节点上,这是实测下来查询效率最好的配置。
第三,查询中的谓词下推和Join顺序。Doris的优化器在标准TPC-H测试里表现很好,但面对我们这种“多张小表Join一张超大明细表”的模式,偶尔会有执行计划不理想的情况。遇到慢查询,第一件事是用EXPLAIN看执行计划,检查Join的左右表顺序是否合理。我们用过leading这个查询Hint强制指定Join顺序,效果立竿见影,将一个3秒的查询优化到了400毫秒。
第四,内存控制和查询超时。Doris默认的单个查询内存上限是2GB,如果推荐接口的批量查询涉及大量数据,很容易触发内存超限报错。我们根据实际需求,在会话级别设置了合理的exec_mem_limit,同时设了3秒的查询超时上限,这样最坏情况也只是返回空结果,不会拖垮服务。这里强调一点:不要把BE的总内存配置得过大,预留操作系统Page Cache的空间往往能带来更好的IO效率。
4.2 数据倾斜与查询超时的四个典型场景
运行期间遇到的四个问题很有代表性,分享排查过程。
第一个问题是用户行为表的用户ID分布严重不均匀。头部用户的行为量是普通用户的几百倍,导致按用户ID哈希分桶的某些分桶数据量暴涨,查询时这些桶的计算压力远超其他桶,拖慢整体响应。我们的解法是分桶键改用“用户ID的哈希值+日期”组合,或者直接对超大用户做单独的预处理分桶。对于特别豪横的零售APP大客户,还可以在查询前加条件过滤,只查近90天的数据,大幅度减少计算量。
第二个问题是Routine Load消费组lag持续增长。排查下来发现是Kafka单分区积压导致并行消费无法扩容,同时BE的写入合并能力达到了瓶颈。优化方案:增加Kafka分区数、调整Routine Load的攒批时间、给BE节点增加CPU资源,最终lag在半小时内追平。
第三个问题是特征宽表更新频率过高导致查询变慢。我们用Unique模型做特征宽表,每秒都有大量小事务更新写入,写时合并的代价逐渐暴露。考虑到在线特征更新其实不需要秒级实时,把更新频率降为每30秒批量合并一次,同时开启表级别light_schema_change特性,查询性能恢复稳定。
第四个问题是物化视图与基础表数据不一致。排查发现某个物化视图的自动刷新策略设置错误,导致部分店铺维度的聚合数据滞后了两小时,推荐结果里出现了已下架商品。这个问题教训深刻:物化视图虽然能大幅度提升查询性能,但刷新机制必须结合业务容忍度设计好,关键场景宁可查询慢一点,也不能接受数据不一致。
4.3 一套常用问题速查表
根据这段时间的运维记录,整理了零售推荐场景下Doris最常遇见的问题和对应处理方案。
| 现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 查询偶发超时 | 大表扫描时内存超限 | EXPLAIN查看执行计划,检查扫描行数 | 设置合理exec_mem_limit,增加过滤条件 |
| 导入速度慢 | 分桶太多或索引冗余 | 查看BE监控导入吞吐指标 | 降低分桶数,移除低频索引 |
| 高频查询无缓存命中 | 物化视图未命中或缺失 | 查看物化视图匹配日志 | 调整物化视图的刷选条件,检查查询SQL是否能命中 |
| 用户数据倾斜 | 用户分布不均匀 | 统计最大分桶数据量 | 调整分桶键,增加日期维度 |
| 数据延迟高 | Routine Load积压 | 查看Routine Load的lag指标 | 增加Kafka分区,调整攒批参数 |
| 查询结果与业务库不一致 | 同步链路中断 | 对比源表和Doris表的时间戳 | 重新对齐增量同步位点,优先处理全量刷新 |
此外有两个运维工具值得养好习惯:一个是通过SHOW PROC '/backends'快速查看各BE节点的工作状态和数据分布是否均匀;另一个是通过SHOW ROUTINE LOAD查看实时导入任务的运行状态。这两个命令在排查问题时的使用频率极高。
5. 上线效果评估与推荐场景的后续扩展方向
5.1 业务效果验证的三个核心维度
推荐系统上线后,效果的验证不只是一个点击率数字,而要综合业务维度来看。我们主要围绕三个维度评估。
第一个是技术链路稳定性指标:推荐接口P95响应时间、Doris查询成功率、数据新鲜度。(实测结果:接口P95从初版的620毫秒优化到300毫秒左右,Doris侧查询成功率保持在99.9%以上,行为数据的端到端延迟从分钟级压缩到30秒内。)
第二个是推荐质量直接指标:曝光点击率、加购转化率、支付转化率。经过A/B测试,与老版的离线规则推荐相比,新版实时推荐方案的点击率提升了18%左右,加购转化率提升了11%。其中贡献最大的其实是动态库存过滤和实时促销状态的重排,它们让用户看到的永远是“现在能买到、有优惠”的商品,这几个点比模型效果的影响更立竿见影。
第三个是业务可解释性和运营易用性指标。这是容易被忽略却非常重要的维度。推荐系统不只是算法团队的事,运营要有能调整的入口。由于Doris的SQL能力,运营同学可以自己写条件配置推荐商品池,比如“近七天销量前100的夏季连衣裙,排除无库存商品,按利润从高到低排序”,分钟级就能生效。这让推荐系统从“黑盒”变成了业务可以干预的“白盒”,投入使用后运营侧的满意度非常高。
5.2 后续可以沿着哪几个方向演进
这个项目的Doris体系建设完成第一版后,我个人觉得还有三个可以继续推进的方向。
第一个是Doris内做更完整的向量召回。随着推荐模型升级到向量召回阶段,可以把Embedding的存储和检索能力都收进Doris,省掉外部向量引擎的跨服务调用延迟。前提是数据量控制在中等规模,且对向量索引管理有足够的把握。
第二个是行为数据的自动化治理。目前行为明细表实时导入的重复数据偶有出现,靠ELECT查询时手工去重兜底。下一步要把数据质量校验加进导入链路,比如设置合理的序列键、在Doris侧做去重策略,从源头保证一致性。
第三个是引入Doris的多租户资源隔离能力。目前推荐查询和BI报表查询共享同一集群,高峰期偶尔互相影响。Doris的资源组功能可以按工作负载设置CPU和内存权重,把推荐查询的优先级提高,同时限制报表大查询的并发与内存占用。这个功能上线后,真正的稳定性和可控性会再上一个台阶。
最后分享一个个人认为最重要的体会:在零售科技领域做推荐系统,不能只盯着模型调参和下架优化,数据引擎的选型和数据链路的稳定性往往决定了项目的下限,而一个好用的分析型数据库能否嵌入业务侧的运营实践,往往决定了最终效果的上限。Doris在这个项目里帮我们解决的,不仅是查询性能的问题,更是把数据和业务决策打通的那道关键闸门。这套实践思路后续放到电商、同城零售、会员营销等场景,内核完全可以复用,只是在表模型和导入策略上做相应调整就行。