
简介《京东零售OLAP平台建设和场景实践v3.0》是一份面向大数据分析与数据仓库方向从业者、电商数据平台研发人员的技术文档围绕零售业务场景下的OLAP平台建设展开。内容覆盖数据仓库分层设计与ETL清洗整合、OLAP立方体预计算建模、分区与索引等查询性能优化以及可视化仪表板搭建并延伸到销售分析、客户行为追踪、库存管理与营销效果评估等落地实践同时讨论平台扩展性与数据安全合规。资源包共1个PDF文件大小约7.41MB单文件便于离线阅读与查阅适合作为技术选型、方案设计时的参考资料。目前已有82人学习属于针对性较强的行业实践材料。读者可借助其中的建模思路与场景案例理解OLAP在电商高频查询与多维分析中的取舍为自身平台架构设计、性能调优和指标体系建设提供参照。1. 零售场景下的 OLAP 平台到底难在哪零售业务的报表有一条很典型的曲线白天是尖峰凌晨是低谷大促当天零点的查询量能顶平时一整天。商家后台点一下「经营概览」背后可能是好几张表的关联运营看板换一次筛选条件要从亿级订单明细里扫出几百行。这套负载用行存数据库扛不住丢给离线数仓又太慢OLAP 平台就成了零售数据链路里绕不过去的一环。这个标题指向两件事。一件是平台怎么搭——引擎怎么选、模型怎么分层、入口怎么收敛另一件是场景怎么落——大促大屏、商家分析、慢查询治理分别该用什么手段。两者不能拆开看选型错了后面靠调参救不回来场景没想清楚平台建得再整齐也没人愿意用。往下按选型、建模、导入加速、场景治理、上线验证的顺序展开每一段尽量给出能直接抄的参数和语句。适合正在做数据平台选型的技术负责人、被并发和慢查询打爆的 OLAP 开发以及需要向业务解释「这个报表为什么慢」的数据工程师。2. 京东零售 OLAP 平台的引擎选型与分层架构2.1 从查询形态反推引擎点查、宽表扫描、多表关联零售数据的查询不像数仓内部跑批那样整齐它至少混着三种形态商家点开一张订单要毫秒返回运营大盘要扫几十亿行做聚合分析师又要临时 join 七八张表。把三种负载塞进同一个引擎常见结果是点查被大查询拖慢大查询被点查挤占资源。务实做法是按负载拆引擎再用统一网关收敛入口。判断依据可以先从延迟要求和扫描量两列入手。查询形态典型场景单次扫描量延迟要求适配引擎类型主键点查订单详情、库存查询单行到几十行P99 50ms主键模型 / KV 型 MPP宽表扫描聚合交易大盘、品类日报千万到百亿行P95 3s列存 MPP多表关联经营分析、转化漏斗多表 joinP95 10sMPP 预聚合明细追溯售后排查、风控稽核精确过滤后小结果集秒级列存 二级索引选型时有三个判断点。一是能不能改数据模型能改就优先选列存 MPP宽表加预聚合的收益最大二是数据要不要实时实时链路的复杂度主要长在导入侧而不是查询侧三是运维成本多一套引擎就多一套监控、导入和扩容流程团队规模不够时宁可少拆。2.2 数据分层与模型设计ODS、DWD、DWS、ADS 在 OLAP 侧怎么落离线数仓那套分层搬进 OLAP 不能照抄。OLAP 里的表是给查询用的不是给血缘用的。常见做法压缩成三层明细层保留最细粒度、按天分区用于追溯和兜底汇总层按主题预聚合比如「商家-日-品类」服务大盘应用层面向单个页面字段和前端一一对应由调度任务直接写结果。导入方式也要跟着分层走。明细层适合批量导入加实时增量汇总层尽量交给物化视图自动维护应用层用定时任务覆盖写。这样每一层的数据新鲜度不同但对业务的承诺是清晰的。分区和分桶是建模里最容易被忽略的两个参数。分区按时间切通常按天分区裁剪能不能生效取决于查询条件里有没有写时间范围分桶按哈希切决定数据分布在多少个 tablet 上。单 tablet 建议控制在 1~10GB太大影响并行度太小元数据压力大。估算公式是tablet 数 分区数 × 分桶数 × 副本数。每天一个分区、32 个分桶、3 副本、保留 90 天就是 8640 个 tablet这个量级多数 MPP 还能接受再往上就要考虑减分桶或者缩副本。2.3 统一查询网关路由、限流、缓存引擎拆开之后接入必须收敛。常见做法是在 BI 和引擎之间加一层查询网关干三件事解析 SQL 决定路由、按业务线限流、对高频结果做缓存。路由规则一般按表名前缀或库名匹配。# 查询网关路由规则按 SQL 中出现的表名前缀决定下发到哪个引擎 routes: - name: ads_point_query match: tables: [ads_shop_order_*, ads_stock_*] engine: olap_point # 点查集群主键模型资源组独立 timeout_ms: 200 # 网关侧超时略大于引擎 query_timeout cache: true cache_ttl_s: 30 # 订单状态变动快缓存只给 30 秒 - name: dws_dashboard match: tables: [dws_trade_*, dws_shop_*] engine: olap_scan # 扫描集群列存宽表 timeout_ms: 5000 cache: true cache_ttl_s: 300 # 大盘分钟级更新5 分钟缓存可接受 - name: default engine: olap_adhoc # 分析集群不缓存、超时放宽 timeout_ms: 30000 cache: falsematch.tables 支持前缀匹配命中多条按顺序取第一条所以确定性强、延迟要求高的规则必须放在前面。timeout_ms 要比引擎自身的 query_timeout 略大否则会出现网关已经断开、引擎还在跑的浪费。cache_ttl_s 按数据新鲜度要求给商家订单详情 30 秒大盘 300 秒分析师临时查询一律不缓存避免拿到过期结果还以为自己算错。限流建议按「业务线 用户」两个维度而不是只按用户。同一个分析师可能同时开着十几个看板只按用户限流会误伤按业务线限流才能保证大促期间商家侧不被分析侧挤占。提示缓存键要把 SQL 里的时间参数归一化把 NOW()、当天日期统一替换成占位符再哈希否则同一张看板每次刷新都会穿透缓存缓存命中率会低得莫名其妙。3. 京东零售场景下的 OLAP 建表、导入与查询加速实践3.1 明细表与聚合表建表语句里的分区、分桶与索引明细表优先保证可追溯所以用明细模型加排序键把最常用的过滤字段放在键的前面。-- 明细层交易订单明细按天分区订单号哈希分桶 CREATE TABLE dwd_trade_order_detail ( dt DATE NOT NULL COMMENT 业务日期, order_id BIGINT NOT NULL COMMENT 订单号, shop_id BIGINT NOT NULL COMMENT 商家ID, sku_id BIGINT NOT NULL COMMENT 商品ID, user_id BIGINT NOT NULL COMMENT 用户ID, order_status TINYINT NOT NULL COMMENT 订单状态, pay_amount DECIMAL(18,2) NOT NULL COMMENT 实付金额, create_time DATETIME NOT NULL COMMENT 下单时间, update_time DATETIME NOT NULL COMMENT 更新时间 ) ENGINE OLAP DUPLICATE KEY(dt, order_id, shop_id) PARTITION BY RANGE(dt) ( PARTITION p20240101 VALUES [(2024-01-01), (2024-01-02)), PARTITION p20240102 VALUES [(2024-01-02), (2024-01-03)) ) DISTRIBUTED BY HASH(order_id) BUCKETS 32 PROPERTIES ( replication_num 3, storage_medium SSD, dynamic_partition.enable true, dynamic_partition.time_unit DAY, dynamic_partition.start -90, dynamic_partition.end 3, dynamic_partition.prefix p, dynamic_partition.buckets 32 );DUPLICATE KEY 只是排序键不是唯一约束同一订单被重复导入会产生重复行所以实时链路必须靠上游幂等或导入 label 兜住。BUCKETS 32 是按「单 tablet 1~10GB」倒推的日增量涨到 200GB 以上就提到 64。dynamic_partition.start -90 表示自动保留最近 90 天到期分区由后台任务删除不用再手工写 drop 脚本。replication_num 在测试环境降到 1能省掉三分之二的磁盘。汇总表要的是扫描量小所以用聚合模型把 UV 类指标交给 bitmap。-- 汇总层商家-日粒度交易汇总用聚合模型减少明细扫描 CREATE TABLE dws_shop_trade_day ( dt DATE NOT NULL COMMENT 业务日期, shop_id BIGINT NOT NULL COMMENT 商家ID, order_cnt BIGINT REPLACE_IF_NOT_NULL COMMENT 订单数, pay_user_cnt BITMAP BITMAP_UNION COMMENT 支付用户去重, pay_amount DECIMAL(18,2) SUM COMMENT 支付金额 ) ENGINE OLAP AGGREGATE KEY(dt, shop_id) DISTRIBUTED BY HASH(shop_id) BUCKETS 16 PROPERTIES (replication_num 3);REPLACE_IF_NOT_NULL 用来处理迟到数据覆盖SUM 用于累加指标BITMAP_UNION 用于去重。聚合模型的代价是不能查明细所以明细表和汇总表要同时保留靠网关路由区分谁也别想着用一张表满足所有需求。3.2 批量导入与实时导入的配置差异四种导入方式解决的是四类问题混用会造成重复数据。导入方式适用场景幂等控制端到端延迟Broker Load历史数据回刷、跨存储迁移分区覆盖写小时级Insert Into Select仓内表间加工SQL 自身去重分钟级Stream Load上游定向推送的批次数据label秒级Routine LoadKafka 持续增量消费位点秒级单批推送用 Stream Load 时label 是最省事的幂等键。# Stream Load同 label 重复提交会被直接拒绝避免重跑任务时写重 curl --location-trusted -u olap_user:****** \ -H label:order_detail_20240101_001 \ -H column_separator:, \ -H columns: dt,order_id,shop_id,sku_id,user_id,order_status,pay_amount,create_time,update_time \ -H max_filter_ratio:0.01 \ -T order_detail_20240101.csv \ http://olap-fe:8030/api/retail_olap/dwd_trade_order_detail/_stream_loadmax_filter_ratio 设 0.01 表示允许 1% 的脏数据超过就整批失败。线上建议先设 0 观察一段时间确认上游质量稳定后再放开否则脏数据会静默进表。column_separator 和 columns 必须显式声明源文件列顺序一旦调整靠默认映射会把金额写进用户 ID 字段。持续增量用 Routine Load并行度和批次参数直接决定延迟与导入压力。CREATE ROUTINE LOAD retail_olap.order_detail_load ON dwd_trade_order_detail COLUMNS(dt, order_id, shop_id, sku_id, user_id, order_status, pay_amount, create_time, update_time) PROPERTIES ( desired_concurrent_number 6, -- 与 Kafka 分区数对齐到整数倍 max_batch_interval 10, -- 10 秒延迟与批大小的折中 max_batch_rows 300000, max_batch_size 209715200, strict_mode false -- 金额字段偶有空串严格模式会丢整批 ) FROM KAFKA ( kafka_broker_list kafka1:9092,kafka2:9092, kafka_topic retail.order.detail, property.group.id olap_order_detail );desired_concurrent_number 太小会积压 Kafka lag太大则小批次频繁触发 compaction。max_batch_interval 在大促期间可以降到 3 秒换更低延迟代价是 compaction 压力上升要同步观察 compaction score。3.3 物化视图与 rollup让查询自动走预聚合汇总表不必手工维护交给物化视图自动刷新更稳。CREATE MATERIALIZED VIEW mv_shop_trade_1h REFRESH ASYNC EVERY(INTERVAL 5 MINUTE) DISTRIBUTED BY HASH(shop_id) BUCKETS 16 AS SELECT date_trunc(create_time, hour) AS dt_hour, shop_id, count(*) AS order_cnt, bitmap_union(to_bitmap(user_id)) AS pay_user_cnt, sum(pay_amount) AS pay_amount FROM dwd_trade_order_detail GROUP BY dt_hour, shop_id;查询命中基表且聚合粒度能被 MV 覆盖时优化器会自动改写。判断有没有改写成功看执行计划里有没有出现 mv_ 前缀的表名没出现就说明粒度不匹配或者谓词写得不兼容。REFRESH ASYNC EVERY 的间隔按业务容忍度给5 分钟意味着最坏情况下大盘落后 5 分钟。库里 MV 一多刷新任务会互相抢资源要给刷新任务单独配资源组别让它和线上查询抢 CPU。注意物化视图的刷新是整分区重算还是增量取决于引擎实现和谓词形态。改动基表结构后必须确认 MV 刷新任务仍在正常调度失败但不报错的刷新是排查中最费时间的一类问题。4. 大促大屏、商家经营分析与慢查询治理的场景实践4.1 大促实时大屏高并发点查与结果缓存大促零点的 QPS 能从平时几千涨到十几万但其中绝大部分是全站大盘的固定查询SQL 少、参数少、结果集小。处理思路就三条固定 SQL 模板化只允许参数化调用结果按分钟粒度缓存超时立刻降级。# 大屏查询入口模板化 多级缓存 超时降级 import hashlib, json CACHE_TTL {1m: 30, 5m: 300, 1h: 3600} def cache_key(template_id: str, params: dict) - str: # 参数排序后哈希避免字典顺序不同导致缓存击穿 raw template_id | .join(f{k}{params[k]} for k in sorted(params)) return olap:dash: hashlib.md5(raw.encode()).hexdigest() def query_dashboard(template_id, params, granularity1m, timeout_ms3000): key cache_key(template_id, params) cached redis.get(key) if cached and granularity ! 1m: return json.loads(cached) # 5m/1h 粒度直接吃缓存 try: rows execute_sql(TEMPLATES[template_id], params, timeout_ms) except TimeoutError: expired redis.get(key) # 超时降级返回上一次的值 if expired: return {**json.loads(expired), degraded: True} raise redis.setex(key, CACHE_TTL[granularity], json.dumps(rows)) return rowstemplate_id 把「SQL 形态」从不可控变成可控压测才能复现同一套负载。granularity 为 1m 的键故意不吃缓存每次都重新查保证大屏数据尽可能新。degraded 标记必须透传到前端并显示出来否则业务会以为数据没更新是系统坏了反而制造更多排查成本。timeout_ms 设 3000 是一个经验值比大多数人直觉的 1 秒更稳因为网络抖动带来的误降级比多等两秒代价更大。4.2 商家经营分析多表关联与宽表取舍商家侧分析常见的是「我的店这个月卖得怎么样哪个品类拖后腿」SQL 通常是明细 join 商品维表 join 类目维表再分组。两种做法各有代价宽表把维度属性打平进汇总层查询只扫一张表快但维度变更要重刷历史运行时 join 灵活但每次都要 shuffle。选择标准是维度变更频率。商品类目一个月变一次适合打平活动标签一天变几次适合运行时 join 或者做小表广播。-- 商家品类分析大表 join 小表强制广播维表避免 shuffle SELECT /* BROADCAST(c) */ d.shop_id, c.cate_name, sum(d.pay_amount) AS pay_amount, count(DISTINCT d.user_id) AS buyer_cnt FROM dws_shop_trade_day d JOIN dim_category c ON d.cate_id c.cate_id WHERE d.dt BETWEEN 2024-06-01 AND 2024-06-30 AND d.shop_id 10086 GROUP BY d.shop_id, c.cate_name ORDER BY pay_amount DESC LIMIT 50;BROADCAST hint 要求小表能装进单机内存一般控制在几百万行、几百 MB 以内维表一旦变大就会把 BE 打爆这时应该改回 shuffle join 或者把维表落到汇总表里。WHERE 里必须先写 dt 范围再写 shop_id顺序反了可能让分区裁剪失效几千个分区全扫一遍。GROUP BY 里带上 shop_id 是为了让引擎识别出这是单商家查询能进一步下推过滤。4.3 慢查询定位与资源隔离的参数调优没有审计表的 OLAP 平台都是裸奔。慢查询先从审计日志里捞再谈优化。-- 捞出最近一小时耗时超过 5 秒的查询按耗时倒序 SELECT query_id, user, start_time, CAST(query_time AS DECIMAL(10,3)) AS query_time_s, scan_rows, scan_bytes, LEFT(stmt, 200) AS stmt_head FROM __internal_schema.audit_log WHERE query_time 5 AND start_time NOW() - INTERVAL 1 HOUR ORDER BY query_time DESC LIMIT 100;读结果有几种典型组合。query_time 高但 scan_rows 小通常是锁等待或者 join 层数太多scan_rows 和 scan_bytes 都大说明分区裁剪或前缀索引没生效同一个 user 反复出现在榜首先找他确认是不是在跑临时探索再谈限流。定位完之后用资源组做隔离把分析型查询和线上业务彻底分开。-- 分析型查询单独一组限制 CPU 和并发避免打爆线上 CREATE RESOURCE GROUP rg_adhoc TO (user analyst_%) WITH ( cpu_core_limit 16, -- 软限制超用降权而非直接拒绝 mem_limit 40%, -- 硬限制超了会 OOM concurrency_limit 8, -- 按峰值 QPS × 平均耗时倒推 type normal ); CREATE RESOURCE GROUP rg_dashboard TO (user bi_dashboard) WITH ( cpu_core_limit 32, mem_limit 60%, concurrency_limit 64, type normal );concurrency_limit 给太紧会让正常查询排队给太松隔离就形同虚设。一个可用的起点是预计峰值 QPS 乘以平均耗时再留 1.5 倍余量。cpu_core_limit 是软限制抢不到 CPU 的查询会变慢但不会失败这点和 mem_limit 的行为不同配置的时候要分开理解。4.4 平台稳定性限流降级与数据一致性校验限流要在网关和引擎两侧同时做网关按业务线配额引擎按资源组。降级动作必须事先写死不能临场决定。触发条件降级动作恢复条件网关 QPS 超阈值 120%非核心业务排队核心链路直通回落至 90% 并持续 1 分钟单查询超时 3s返回上次缓存值并打降级标记下次请求正常返回即恢复单 BE 节点 CPU 90%停止向该节点下发新查询CPU 70% 并持续 5 分钟导入延迟 10 分钟大盘切换到分钟级兜底快照延迟回落至 2 分钟内实时链路最怕导入丢数据常规做法是定时跑对账 SQL比对 OLAP 侧和上游的计数。-- 对账按小时比对 OLAP 侧订单数与上游事件表订单数 SELECT a.dt_hour, a.olap_cnt, b.src_cnt, a.olap_cnt - b.src_cnt AS diff FROM ( SELECT date_trunc(create_time, hour) AS dt_hour, count(*) AS olap_cnt FROM dwd_trade_order_detail WHERE dt CURRENT_DATE() GROUP BY dt_hour ) a FULL OUTER JOIN ( SELECT date_trunc(event_time, hour) AS dt_hour, count(DISTINCT order_id) AS src_cnt FROM ods_order_event WHERE dt CURRENT_DATE() GROUP BY dt_hour ) b ON a.dt_hour b.dt_hour ORDER BY a.dt_hour;diff 持续为正说明 OLAP 里有多余数据通常是 label 没生效导致重复导入持续为负说明有丢失先看 Routine Load 的错误行数和 Kafka lag再看消费位点是不是被重置过。对账任务本身要跑在独立资源组里别让它在关键时刻和线上查询抢资源。提示对账只核对总量是不够的金额类指标要一起核对。行数对得上但金额对不上往往意味着部分更新把金额字段覆盖成了空值。5. 上线前验证 OLAP 平台压测口径、观测指标与灰度回滚5.1 压测按真实 SQL 分布发压而不是按平均 QPS压测最容易犯的错是用一条 SQL 打满 QPS测出来的数字漂亮但没有参考价值。真实流量里大约八成是模板化点查一成半是聚合百分之五是临时分析压测脚本要按这个分布发压。# 回放真实流量分布从目标峰值的 50% 起压每轮加 20% 找拐点 python3 replay.py \ --host olap-gateway:8080 \ --mix point80,agg15,adhoc5 \ --qps 20000 \ --duration 600 \ --warmup 60 \ --out result.csv--warmup 至少给 60 秒把缓存和 page cache 预热起来否则前 30 秒的数据全是噪声。--qps 不要一上来就打峰值找拐点比找极限更有用拐点之前延迟基本平稳过了拐点 P99 会陡增那个位置才是真正的容量边界。5.2 四个必须盯住的指标指标含义警戒线P99 query latency长尾延迟点查 200ms聚合 5sscan_bytes / scan_rows扫描放大比单行扫描字节 1KB 说明没走列裁剪单 BE tablet 数元数据压力超过 10 万要扩节点或减分桶compaction score副本合并积压持续 100 说明写入过快扫描放大比最容易被忽略。一个 20 列的宽表如果只 select 3 列scan_bytes 应该只有全表的两成左右如果接近全表就说明列裁剪没生效或者查询里写了 select *。这类问题在大屏上不明显一旦并发上来就是实打实的资源浪费。5.3 把切换做成可逆的灰度发布任何一次引擎升级、参数调整、模型重建都要能回滚。具体做法是网关路由规则版本化新版本先切 5% 流量观察 30 分钟后再逐步放大。# 灰度路由同一条规则内按权重切分流量 routes: - name: dws_dashboard_canary match: tables: [dws_trade_*] engines: - engine: olap_scan_v2 weight: 5 - engine: olap_scan_v1 weight: 95 timeout_ms: 5000weight 总和必须等于 100网关按请求哈希取模分配保证同一个用户持续落在同一侧避免同一个人刷新两次看到两套结果。灰度期间对比两边的延迟分位数和结果行数只要结果行数不一致就立刻停止放量先查 SQL 改写差异。把「重建模型」也当成一次发布来对待建新表、双写导入、对账一致后切读、最后删旧表。这样做的好处是出问题时切回来只需要改一行路由权重不需要回滚数据也不需要等下一次调度窗口。本文还有配套的精品资源点击获取