这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 订单分析是典型的 OLAP 场景:数据量持续增长,查询以时间范围、维度分组和指标计算为主,写入以追加和状态更新混合。Elasticsearch 可以很好地支持近实时看板和灵活探索,但也必须明确精确性边界和查询成本。
本章设计一套订单分析系统,覆盖指标建模、文档设计、聚合查询、漏斗留存、报表加速和上线治理。
21.1 分析需求
订单团队通常关注四类问题:
| 类型 | 问题 | 示例指标 |
|---|---|---|
| 规模 | 今天卖了多少 | GMV、订单数、付款人数 |
| 转化 | 用户是否完成下单付款 | 浏览-加购-下单-付款漏斗 |
| 结构 | 哪些品类贡献最大 | 类目 GMV、品牌 GMV、客单价 |
| 质量 | 用户是否留存 | 复购率、7 日留存、退款率 |
先定义核心指标:
- GMV:支付成功订单的金额汇总;
- 订单数:去重订单 ID 数量;
- 客单价:GMV / 支付人数;
- 支付转化率:支付订单数 / 下单订单数;
- 复购率:周期内下单次数大于 1 的用户占比;
- 退款率:退款金额 / 支付金额。
指标必须写清口径,尤其是时间字段用下单时间还是支付时间、金额是否含运费、是否剔除退款、是否只看有效门店。
21.2 订单文档设计
订单事件有几种建模方式:
| 方式 | 特点 | 适合场景 |
|---|---|---|
| 订单快照 | 每次更新覆盖一个订单文档 | 查当前状态简单 |
| 事件流 | 每个状态变化一条事件 | 可回放历史,但查询复杂 |
| 快照 + 事件 | 当前订单一个索引,状态事件一个索引 | 兼顾看板与审计 |
推荐主看板使用订单快照,审计和漏斗使用事件流。
PUT orders-v3
{
"settings": {
"number_of_shards": 6,
"number_of_replicas": 1,
"refresh_interval": "5s"
},
"mappings": {
"dynamic": "strict",
"properties": {
"order_id": { "type": "keyword" },
"user_id": { "type": "keyword" },
"order_status": { "type": "keyword" },
"pay_status": { "type": "keyword" },
"channel": { "type": "keyword" },
"platform": { "type": "keyword" },
"shop_id": { "type": "keyword" },
"city_id": { "type": "keyword" },
"category_id": { "type": "keyword" },
"brand_id": { "type": "keyword" },
"product_id": { "type": "keyword" },
"sku_id": { "type": "keyword" },
"quantity": { "type": "integer" },
"original_amount": { "type": "scaled_float", "scaling_factor": 100 },
"pay_amount": { "type": "scaled_float", "scaling_factor": 100 },
"discount_amount": { "type": "scaled_float", "scaling_factor": 100 },
"refund_amount": { "type": "scaled_float", "scaling_factor": 100 },
"created_at": { "type": "date" },
"paid_at": { "type": "date" },
"completed_at": { "type": "date" },
"first_order_flag": { "type": "boolean" },
"is_test_order": { "type": "boolean" },
"updated_at": { "type": "date" }
}
}
}
其他设计要点:
- 订单文档 ID 使用
order_id,幂等重写不会产生重复订单; - 明细如果需要按 SKU 分析,建议一条订单明细一个文档,而不是把 SKU 放进 nested;
- 时间字段分别保留,便于按业务口径切换;
- 测试订单、内部订单必须打标,默认聚合过滤;
- 金额使用
scaled_float保留分。
21.3 状态更新与幂等
订单会经历创建、支付、发货、完成、退款等状态。推荐由订单消费服务统一处理 Kafka 消息,并按事件版本更新。
POST orders-v3/_update/order_10001?retry_on_conflict=3
{
"doc": {
"order_status": "paid",
"pay_status": "success",
"pay_amount": 5999.00,
"paid_at": "2026-08-25T10:12:33Z",
"updated_at": "2026-08-25T10:12:35Z"
}
}
为了防止乱序消息覆盖新状态,可以在文档里保存事件版本或状态时间:
POST orders-v3/_update/order_10001?retry_on_conflict=3
{
"script": {
"lang": "painless",
"source": """
if (ctx._source.event_version == null || ctx._source.event_version < params.version) {
ctx._source.order_status = params.status;
ctx._source.updated_at = params.updated_at;
ctx._source.event_version = params.version;
}
""",
"params": {
"version": 12,
"status": "completed",
"updated_at": "2026-08-25T12:00:00Z"
}
}
}
如果更新非常频繁,可以降低同步频率或使用局部状态表异步合并,再定期刷新到 ES。
21.4 GMV 与订单数
按小时统计 GMV:
GET orders-v3/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "term": { "pay_status": "success" } },
{ "term": { "is_test_order": false } },
{ "range": { "paid_at": { "gte": "now-1d/d", "lte": "now" } } }
]
}
},
"aggs": {
"per_hour": {
"date_histogram": {
"field": "paid_at",
"fixed_interval": "1h",
"min_doc_count": 0,
"time_zone": "Asia/Shanghai"
},
"aggs": {
"gmv": { "sum": { "field": "pay_amount" } },
"orders": { "cardinality": { "field": "order_id" } },
"buyers": { "cardinality": { "field": "user_id" } }
}
}
}
}
如果一条文档就是一个订单且 order_id 是文档 ID,value_count 比 cardinality 更便宜。cardinality 是近似算法,用户量很大时需要在准确率和内存成本之间取舍。
21.5 维度分析
按类目统计 GMV、退款率和客单价:
GET orders-v3/_search
{
"size": 0,
"query": {
"bool": {
"filter": [
{ "term": { "pay_status": "success" } },
{ "range": { "paid_at": { "gte": "now-7d/d", "lte": "now" } } }
]
}
},
"aggs": {
"categories": {
"terms": {
"field": "category_id",
"size": 50,
"order": { "gmv": "desc" }
},
"aggs": {
"gmv": { "sum": { "field": "pay_amount" } },
"refund": { "sum": { "field": "refund_amount" } },
"buyers": { "cardinality": { "field": "user_id" } },
"refund_rate": {
"bucket_script": {
"buckets_path": {
"refund": "refund",
"gmv": "gmv"
},
"script": "params.refund / params.gmv * 100"
}
},
"avg_order_value": {
"bucket_script": {
"buckets_path": {
"gmv": "gmv",
"buyers": "buyers"
},
"script": "params.buyers == 0 ? 0 : params.gmv / params.buyers"
}
}
}
}
}
}
注意 bucket_script 不能用于已排序的 terms 聚合作为排序依据时破坏近似语义,具体取决于版本。生产报表如果要严格排序,建议先取足够大的 size,再由应用层或预聚合表完成最终排序。
21.6 转化漏斗
漏斗需要事件数据,例如 order_events-v1:
PUT order_events-v1/_doc/event_001
{
"event_id": "event_001",
"user_id": "u_1001",
"event_type": "submit_order",
"order_id": "order_10001",
"event_time": "2026-08-25T10:00:00Z"
}
简单漏斗可以用 filter 聚合实现:
GET order_events-v1/_search
{
"size": 0,
"query": {
"range": {
"event_time": { "gte": "now-1d/d", "lte": "now" }
}
},
"aggs": {
"users": {
"filter": { "term": { "event_type": "view_product" } },
"aggs": {
"view_users": {
"cardinality": { "field": "user_id" }
},
"add_cart_users": {
"filter": { "term": { "event_type": "add_cart" } },
"aggs": {
"distinct_users": {
"cardinality": { "field": "user_id" }
}
}
},
"submit_users": {
"filter": { "term": { "event_type": "submit_order" } },
"aggs": {
"distinct_users": {
"cardinality": { "field": "user_id" }
}
}
},
"pay_users": {
"filter": { "term": { "event_type": "pay_success" } },
"aggs": {
"distinct_users": {
"cardinality": { "field": "user_id" }
}
}
}
}
}
}
}
| 这种写法只适合简单口径。若要求“先浏览、后加购、再支付”的顺序,以及窗口期约束,通常更适合使用事件分析引擎、数据仓库 SQL,或 ES | QL 的 sequence 能力。 |
21.7 留存分析
留存分析先确定 cohorts:
- 注册日期、首次购买日期、活动参与日期;
- 观察 N 天内是否再次购买;
- 按渠道、城市、品类或用户等级拆分。
一种常见做法是在用户宽表中冗余首购日期和最近购买日期:
PUT user_order_profiles-v1/_doc/u_1001
{
"user_id": "u_1001",
"first_paid_at": "2026-08-01T10:00:00Z",
"last_paid_at": "2026-08-20T09:00:00Z",
"order_count_30d": 3,
"pay_amount_30d": 899.00,
"category_id": "category_1",
"channel": "app"
}
按首购月份和购买次数分层:
GET user_order_profiles-v1/_search
{
"size": 0,
"aggs": {
"first_paid_month": {
"date_histogram": {
"field": "first_paid_at",
"calendar_interval": "month",
"time_zone": "Asia/Shanghai"
},
"aggs": {
"repeat_users": {
"range": {
"field": "order_count_30d",
"ranges": [
{ "to": 2 },
{ "from": 2, "to": 5 },
{ "from": 5 }
]
}
}
}
}
}
}
严格留存需要完整事件历史,通常由离线数仓计算后写入 ES,供看板查询。
21.8 报表加速
当看板查询同时满足这些条件时,就该考虑预聚合:
- 查询范围经常覆盖 30 天以上;
- 维度组合固定;
- 查询 QPS 较高;
- 原始分片查询延迟或 CPU 明显偏高;
- 用户能接受分钟级数据延迟。
预聚合表设计:
PUT order_reports_1m-v1
{
"mappings": {
"dynamic": "strict",
"properties": {
"stat_time": { "type": "date" },
"channel": { "type": "keyword" },
"category_id": { "type": "keyword" },
"city_id": { "type": "keyword" },
"gmv": { "type": "double" },
"order_count": { "type": "long" },
"buyer_count": { "type": "long" },
"refund_amount": { "type": "double" },
"updated_at": { "type": "date" }
}
}
}
文档 ID 可以设计为:
stat_time + channel + category_id + city_id
例如 202608251010|app|category_1|city_1。这样每分钟重复计算可以幂等覆盖,不会产生重复统计。
21.9 精确性边界
Elasticsearch 聚合默认为分布式近似设计,需要清楚边界:
| 能力 | 精确性 | 建议 |
|---|---|---|
| sum / min / max / avg | 数值精确 | 适合订单金额 |
| terms | 可能截断 | 设置合理 size 和 shard_size |
| cardinality | 近似 | 对账时使用精确去重或离线计算 |
| percentile | 近似 | 用于观察分布,不用于财务结算 |
| top_hits | 每个 bucket 内部结果 | 不等价于全局 Top N |
财务结算、发票、佣金、对账等场景不要用 Elasticsearch 作为最终依据。它适合看板和探索,账实核对应以交易数据库或数仓为准。
21.10 上线清单
- 所有指标都有明确定义、负责人和口径文档;
- 订单写入有幂等键,消息乱序可处理;
- 测试订单和退款订单默认排除规则明确;
- 高频看板已评估预聚合;
- 大范围查询有索引分区和 time filter;
- 高基数聚合有白名单;
- cardinality 精度与内存已评估;
- 查询延迟和查询淘汰有监控;
- 财务级指标与离线数仓定期比对;
- 集群压力过大时可切换只读快照或降级查询。
本章小结
订单分析的重点是指标口径、文档粒度、时间维度和查询成本。当前状态看板适合订单快照,转化和留存适合事件数据,长期高频报表适合预聚合。把精确性边界讲清楚,比追求所有查询都实时更重要。
思考题
- 订单快照、订单事件、订单明细三种文档分别适合什么问题?
- 为什么财务结算不能依赖
cardinality和percentile? - 如果 GMV 看板查询 30 天数据很慢,你会如何优化?
- 订单状态乱序到达时,如何避免旧状态覆盖新状态?
- 预聚合表的文档 ID 应该怎么设计,才能支持幂等更新?