ElasticsearchNotes

第 21 章:订单分析实战

zjc 于 2026-01-21 发布

这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 订单分析是典型的 OLAP 场景:数据量持续增长,查询以时间范围、维度分组和指标计算为主,写入以追加和状态更新混合。Elasticsearch 可以很好地支持近实时看板和灵活探索,但也必须明确精确性边界和查询成本。

本章设计一套订单分析系统,覆盖指标建模、文档设计、聚合查询、漏斗留存、报表加速和上线治理。

21.1 分析需求

订单团队通常关注四类问题:

类型 问题 示例指标
规模 今天卖了多少 GMV、订单数、付款人数
转化 用户是否完成下单付款 浏览-加购-下单-付款漏斗
结构 哪些品类贡献最大 类目 GMV、品牌 GMV、客单价
质量 用户是否留存 复购率、7 日留存、退款率

先定义核心指标:

指标必须写清口径,尤其是时间字段用下单时间还是支付时间、金额是否含运费、是否剔除退款、是否只看有效门店。

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" }
    }
  }
}

其他设计要点:

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_countcardinality 更便宜。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:

一种常见做法是在用户宽表中冗余首购日期和最近购买日期:

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 报表加速

当看板查询同时满足这些条件时,就该考虑预聚合:

  1. 查询范围经常覆盖 30 天以上;
  2. 维度组合固定;
  3. 查询 QPS 较高;
  4. 原始分片查询延迟或 CPU 明显偏高;
  5. 用户能接受分钟级数据延迟。

预聚合表设计:

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 上线清单

本章小结

订单分析的重点是指标口径、文档粒度、时间维度和查询成本。当前状态看板适合订单快照,转化和留存适合事件数据,长期高频报表适合预聚合。把精确性边界讲清楚,比追求所有查询都实时更重要。

思考题

  1. 订单快照、订单事件、订单明细三种文档分别适合什么问题?
  2. 为什么财务结算不能依赖 cardinalitypercentile
  3. 如果 GMV 看板查询 30 天数据很慢,你会如何优化?
  4. 订单状态乱序到达时,如何避免旧状态覆盖新状态?
  5. 预聚合表的文档 ID 应该怎么设计,才能支持幂等更新?