Elasticsearch
wow-elasticsearch 实现 Elasticsearch EventStore、SnapshotStore 以及事件流/快照查询后端。适合已经运维 Elasticsearch,且读侧需要全文、聚合或大批量游标查询的场景;不要仅为事件持久化而引入搜索集群。
架构概述
Wow 负责索引名、模板、文档形状、版本保护、query schema 与 storage binding;Elasticsearch 负责 mapping、分析器、分片、副本、refresh、PIT、search_after 和 bulk 执行。模块在 classpath 上不等于已装配,event 或 snapshot storage 必须实际选择 elasticsearch。
安装
直接依赖:
implementation("me.ahoo.wow:wow-elasticsearch")
implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")Starter capability:
implementation("me.ahoo.wow:wow-spring-boot-starter") {
capabilities { requireCapability("me.ahoo.wow:elasticsearch-support") }
}配置
spring:
elasticsearch:
uris: http://localhost:9200
wow:
eventsourcing:
store:
storage: elasticsearch
snapshot:
storage: elasticsearchwow.elasticsearch.enabled=true、auto-init-template=true、query.batch-size=10000、query.keep-alive=1m;compatibility-version 与 index-prefix 默认为空。event/snapshot batch 默认关闭,启用后默认 max-size=128、max-delay=1ms、max-pending-*=4096、lane-count=1。
Spring Data Elasticsearch 配置
连接、认证、TLS 和 client timeout 由 spring.elasticsearch.* 管理。Wow 使用 Spring 创建的 reactive client/operations,不复制这些属性。
Wow 配置
query.batch-size 必须在 1..10000,keep-alive 至少 1ms。只在部署拓扑确实需要 REST compatibility header 时设置 compatibility-version,并由目标集群验证该版本。
写入批处理
EventStore batch 使用 Bulk create;SnapshotStore direct/batch 都以 _source.version 做原子保护,旧快照不能覆盖新快照。只有吞吐证据需要时才启用 batch;队列上限、关闭排空和 partial bulk failure 都会成为新的运行边界。
索引命名规则
默认事件索引为 wow.${contextAlias}.${aggregateName}.es,快照索引为 wow.${contextAlias}.${aggregateName}.snapshot。索引名参与存储与查询路由,重命名属于数据迁移。
多个部署共用一个集群
索引名按聚合固定,因此同一服务的两个部署(例如 dev 与 staging)共用一个集群时会读写对方的索引。用 wow.elasticsearch.index-prefix 给每个部署一个前缀:
wow:
elasticsearch:
index-prefix: staging.前缀原样加在 Wow 推导出的每个名称之前,覆盖写入路径、事件存储与快照读取、查询后端及其 schema:
| 对象 | 未设置(默认) | index-prefix: staging. |
|---|---|---|
| 事件流索引 | wow.sales.order.es | staging.wow.sales.order.es |
| 快照索引 | wow.sales.order.snapshot | staging.wow.sales.order.snapshot |
| 事件流模板 | wow-event-stream-template(wow.*.es) | staging.wow-event-stream-template(staging.wow.*.es) |
| 快照模板 | wow-snapshot-template(wow.*.snapshot) | staging.wow-snapshot-template(staging.wow.*.snapshot) |
未设置或为空白时,所有名称与以前完全相同。索引定义保留不带前缀的文件名(wow.sales.order.snapshot.json),创建的是带前缀的索引;定义中声明的 alias 按原样创建,需要自行为每个部署取不同的名称。视图存储的索引同样使用该前缀。
前缀必须小写,不含 \ / * ? " < > | , # : 或空白,且不以 -、_、+ 或 . 开头;无效前缀使启动失败。前缀也不能以 wow. 开头:其模板 pattern 会与不带前缀的模板在同一优先级上重叠,Elasticsearch 会拒绝。同理,同一集群上的两个前缀不能是另一个加上 wow.(dev. 与 dev.wow.)。
为运行中的部署设置或修改前缀会使其改用新的空索引;请先 reindex 旧索引(在带前缀的模板与索引定义就绪后,POST _reindex 从 wow.sales.order.snapshot 到 staging.wow.sales.order.snapshot)。按名称或 pattern 授予的索引权限必须覆盖带前缀的名称,模板权限须覆盖带前缀的模板。
快照查询字段解析
存储适配器(ElasticsearchQuerySchemaAdapter,一个 QueryStorageAdapter)读取目标索引 mapping,把它报告为存储事实:为 exact match、range、sort、presence、projection 等绑定的物理路径;QuerySchemaCatalog 把这些事实与逻辑模型编译成 QueryModelSchema;准入按这些 binding 解析每个字段引用,编译器消费得到的 ResolvedField。multi-field、runtime field 和禁用 object 服从 Elasticsearch mapping;不要在 HTTP 层猜测 .keyword。
带 ignore_above 的 keyword
ignore_above 不小于 8191 的 keyword 视为索引了全部值,拥有无上限 keyword 的全部操作:精确匹配、in/notIn、前缀与包含、范围、排序和 terms 聚合。Wow 的默认模板正是给 tags.*(ABAC 标签)、id、*Id 和事件流的动态字符串设了这个上限,它是能放进 Lucene 32766 字节词项上限的最多字符数;没有它,一个更长的值会让整个文档写入失败。限制是:长于 ignore_above 的值保留在 _source 中但不进索引,因此没有过滤条件能匹配它(以该值做 eq、in、前缀或包含都查不到;ne 和 notIn 会包含该文档),排序时按缺失处理,也不进入任何 terms 桶。存在性过滤(exists、isNull、isEmpty)仍通过 Elasticsearch 的 _ignored 字段看到该值,因此 ABAC 标签超长的资源不会被当作无标签的公开资源;在 nested 元素内,这样的值按缺失处理。_ignored 同样记录被 ignore_malformed 丢弃值的字段(无法解析的数字或日期),因此 isNull、notExists 和 isEmpty 也把这样的值视为存在。flattened 字段不在 _ignored 中记录任何内容,因此带 ignore_above 的 flattened 字段不提供存在性过滤。
更小的 ignore_above(例如 Elasticsearch 自身的动态映射为未映射字符串在 text 下生成的 keyword 子字段,ignore_above: 256)可能丢掉查询需要的值,因此该字段没有查询操作;例外是每个声明值都在上限内的字符串枚举。这类字段请显式映射(见字符串字段的可聚合性)。Wow 9.1.x 拒绝除这类枚举之外所有带 ignore_above 的 keyword。
自 9.2 起,Wow 的 snapshot template 把 state 下的动态字符串映射为 text,并带一个上限为 8191 的 keyword 子字段(string_as_text_with_keyword 规则):其 .keyword 拥有上述操作,text 上的全文搜索对 9.1 节点保持不变。动态浮点值映射为 double(floating_as_double),而不是 Elasticsearch 默认的 32 位 float。模板只在创建索引时生效:升级之前创建的索引保留 256 的上限和 float 字段,直到重建索引(见重建已有索引)。
整数字段上的小数
逻辑模型声明为小数的值(JSON Schema 的 number,例如 BigDecimal 金额)只在浮点字段上支持过滤、排序和聚合:double、float、half_float 或 scaled_float。在整数字段(long、integer 等)上,Elasticsearch 会截断写入索引的小数,10 与 12.75 之和读作 22;动态映射在首个值为整数时就会生成这样的字段。这样的路径只提供存在性过滤:其他操作都以 UNSUPPORTED_CAPABILITY 拒绝,服务端对每个索引和路径记录一条指出该字段的警告。请在索引定义中把小数金额映射为 scaled_float(带 scaling_factor,例如以分计时取 100)或 double。
首次写入之前
Wow 在聚合首次写入时创建它的索引,所以新聚合在此之前没有索引。查询不存在的索引不返回任何记录:列表、分页和游标为空,计数为 0,没有分组的聚合返回空汇总,与快照加载不存在的索引时一致。不存在的索引的查询 schema 由该索引将被创建时的 mapping 编译:即 Elasticsearch 为该索引名模拟出的匹配索引模板(POST _index_template/_simulate_index/<index>),没有模板匹配时则没有字段。这需要 manage_index_templates 集群权限,auto-init-template 同样需要;没有该权限时,schema 在索引存在前保持不可用(QuerySchemaUnavailable,HTTP 503)。
模拟出的 mapping 尚未映射的字段(动态 mapping 将新增的字段,或 ABAC 标签键)按其逻辑类型允许的全部操作放行,因此对它的过滤、排序或分组不返回记录,而不是被拒绝;模板已映射的字段保持其 mapping 所能证明的能力。这个临时 schema 从不发布:它只保留 2 秒(DefaultQueryModelSchemaProvider.DEFAULT_PROVISIONAL_TTL),因此从未写入的聚合不会在每次查询时都模拟模板,过期后的第一次查询会重新编译它。首次写入后 2 秒内即编译并发布该索引自身的 mapping(含首批文档新增的字段),无需等待重新校验。有索引定义(见下文)的聚合在启动时即有索引,不会走到这条路径。
重新校验运行时查询 Schema
mapping 变化后,运行时 schema 必须重新解析。每个实例按 wow.query.schema.revalidate-interval(默认 5 分钟)定期重新校验查询 schema,所以动态 mapping 给已有索引新增的字段、alias 背后新加入的索引,最长要这么久才能查询;需要立即生效时,在每个实例上调用 wowQuerySchema actuator 端点。重新校验只更新内存 schema,不回填历史文档或修改 mapping。
配置事件流索引模板
auto-init-template=true 时,IndexTemplateInitializer 确认 event template;请求失败、空响应或未确认会使存储装配失败。若平台外部管理模板,关闭自动初始化前要保留模板版本与部署证据。
event template 不索引事件的 body(enabled: false)。需要查询事件字段(body.body.*)的应用为该聚合的事件流提供具体索引定义:META-INF/wow/elasticsearch/wow.sales.order.es.json 或 config/wow/elasticsearch/wow.sales.order.es.json,规则与下文快照索引定义相同。把 body.body 映射为 dynamic: false 的 object,只列出要查询的字段,其他事件的 body 不会新增字段。
配置快照索引模板
snapshot template 定义系统字段与动态状态映射基线。模板只影响新索引或后续 mapping 行为,不会自动修复已有索引。
通用 snapshot template 是仅存储快照的后备方案。可查询快照应在 META-INF/wow/elasticsearch/wow.sales.order.snapshot.json 或 config/wow/elasticsearch/wow.sales.order.snapshot.json 提供包含业务 mapping 的具体索引定义:
{
"mappings": {
"properties": {
"state": {
"properties": {
"status": { "type": "keyword" }
}
}
}
}
}资源键是 Wow 计算出的不含 index-prefix 的索引名;设置前缀后,创建的是带前缀的索引。工作目录文件会替换 classpath 文件;没有工作目录文件时,重复的 classpath 文件会导致启动失败。资源缺失时仍使用通用模板行为。已有索引会被跳过,因此 mapping 变更需要显式 reindex 或迁移:已有索引对定义中某些路径的映射与定义不同时(例如定义发布之前仅由模板创建的索引),启动时记录一条列出这些路径的警告,然后继续启动。资源 JSON 遵循 Elasticsearch client 与集群的校验语义。无论 storage routing 如何配置,只要资源存在就会请求创建索引。
差异检查只比较 properties:逐个路径比较每个字段自身的参数(类型、ignore_above、scaling_factor、对象的 dynamic 等);mapping 的根参数(例如根上的 dynamic)和索引 settings 不参与比较。
权限。 启动时会读取并创建带定义的索引,因此宿主使用的 Elasticsearch 用户需要对这些索引拥有 view_index_metadata(判断索引是否存在,以及差异检查读取 _mapping)和 create_index。auto-init-template=true 时还需要集群权限 manage_index_templates,它同时让缺失索引的查询 Schema 能从模板模拟得到(见首次写入之前)。读写文档需要各自通常的权限。
重建已有索引
Wow 不会修改已有索引的 mapping。要让索引获得新定义或新模板的 mapping,请在应用停止时通过一个临时索引重建:
- 以请求体
{"mappings":{"enabled":false}}执行PUT <index>-new,再从<index>POST _reindex到它。临时索引名不匹配任何 Wow 模板(wow.*.es、wow.*.snapshot),只有请求体为它定义 mapping:只映射部分字段的请求体(例如只用定义本身)会让 Elasticsearch 动态映射其余字段,遇到字段形状不同的文档时重建失败。禁用 mapping 后它只保存_source,而回迁只需要_source。 - 删除
<index>,用其定义的请求体重新PUT(其余部分由模板补齐;没有定义的索引可以留给首次写入创建,或以空请求体创建),再从<index>-newPOST _reindex回来。 - 删除
<index>-new并启动应用:差异警告消失。
宿主使用 Elasticsearch 的检查清单
- 应用查询其状态(或事件)的每个聚合,都在
META-INF/wow/elasticsearch/下提供索引定义;启动时会在首次写入之前创建索引。 - 需要逐元素过滤或展开的对象数组映射为
nested:Elasticsearch 会把普通对象数组拍平,两个字段上的条件可能匹配到两个不同的元素。 - 小数映射为
scaled_float或double(见整数字段上的小数)。 - 已有索引保留原有 mapping:新的定义或 Wow 更新的模板规则只能通过重建索引作用到它;启动警告会列出不一致的路径。
- 授予启动所需的权限。
全文搜索
全文能力来自目标字段的 text mapping 与 analyzer,不是 wow-elasticsearch 对所有字符串的默认承诺。
为状态字段添加全文索引
在平台拥有的 index template/component template 中声明 analyzer 与 text/multi-field,并确认不会覆盖 Wow 必需系统字段。更新后验证新旧索引 mapping。
执行全文搜索
只有运行时 schema 为字段发布相应 query capability 时才通过 Wow 查询 API 使用。原生 Elasticsearch DSL 不自动成为公共 Wow 请求模型。
聚合查询
Wow aggregation AST 编译为 Elasticsearch aggregation。嵌套元素、数值/时间类型与缺失值语义由公共合同和 mapping 共同决定;使用真实后端 TCK/集成测试验证。
多数分组和指标直接在映射字段上执行。以下几类改为执行 Painless 脚本:
- 逐文档执行(请求内的 runtime field):
DATE_PART分组;以DATE_DIFF或算术表达式为输入的分组、指标和指标过滤;以及 epoch 字段上的DATE_HISTOGRAM。例外是以毫秒存储、单值、映射为long(或其他整数类型)且按 UTC 分组的字段。数值字段不接受time_zone,所以 epoch 毫秒字段按其他时区分组时同样执行脚本; - 逐桶执行:派生指标的
bucket_script,开销远小于逐文档脚本。
限制内联脚本的集群(script.allowed_types、script.allowed_contexts)会拒绝这些聚合;设置 search.allow_expensive_queries: false 时,runtime field 上的过滤也会被拒绝。把字段映射为原生类型(例如 epoch 毫秒映射为 long),其聚合就不需要脚本。
去重计数(cardinality)在每个桶 40000 个不同值以内是精确的(precision_threshold,Elasticsearch 的上限),超过后为估算值,schema 在 approximateMetrics 中声明这一点。每个桶在阈值内约为每个不同值占用 8 字节。
索引设计建议
从查询、写入、保留和恢复目标设计索引,不要为每个状态字段默认增加 text/keyword 双映射。
分片策略
分片、副本和 routing 属于 Elasticsearch。上线前以真实 shard size、写入并发和查询 fan-out 验证,不由 Wow 自动选择。
索引生命周期管理 (ILM)
EventStore 是权威历史时,ILM 删除事件会破坏重放。只有数据职责与恢复方案明确允许时才配置 rollover/delete;快照索引也要与重建路径一致。
性能优化
观察 bulk latency/error、refresh、segment、heap、PIT 数与查询耗时,再调整 batch、mapping 或索引拓扑。
批量索引
batch options 必须满足 max-size>1、正 max-delay、pending 不小于 batch size、lane-count>0。同一聚合保持同一 lane;增加 lane 只解决已证明的并发瓶颈。
查询优化
全量查询使用 PIT + search_after,每批大小和 keep-alive 来自配置。batch-size 还不能高于目标索引 index.max_result_window;mapping 与查询模式优先于盲目增大批次。
偏移分页要读 from + size 条记录,Elasticsearch 以索引的 index.max_result_window(索引未设置时为 10000)为上限。page × size 超过它的分页查询在检索前就被拒绝,报 pagination 上的 SIZE_OUT_OF_RANGE(HTTP 400);更深的数据请用游标查询。
查询 schema 以 limits.maxPageWindow 报告这个上限:取查询预算的窗口与索引 index.max_result_window 中较小的一个。预算窗口为 0 表示预算本身不设上限,索引的上限仍然生效。
故障排查
已验证失败包括 template 请求失败/空/未确认、非法 query/batch 参数、bulk item error、旧快照版本保护和 mapping/schema 冲突。
常见问题
保留 index/alias、resolved mapping、请求、响应 item error 和 runtime schema 作为证据。
1. 查询报字段未映射、能力不兼容或 multi-field 存在歧义
检查目标索引实际 mapping 与 runtime schema。不要硬编码 .keyword 修补所有字段;修正模板/mapping 或显式公共字段合同后重新校验 schema。
2. 重新校验端点不可用或重新校验失败
确认 Spring Boot Actuator 在 classpath 上并在管理面暴露 wowQuerySchema 端点,且 query factory 已装配,QuerySchemaCatalog 才有模型可重新校验;Catalog 的 wow.query.schema.refresh 指标记录每次结果。mapping 读取失败应保持失败,不应回退为“所有字段都可查”。
3. alias 或 data stream 无法解析
当前 converter 生成具体索引名,写入也落在这些索引上。名称解析到多个索引时(rollover 索引上的 alias、data stream),查询只能使用其后每个索引都以相同方式映射的字段;某个索引缺少或映射不同的字段,在所有索引映射一致之前保持不可查询。_source 设置不一致的索引按失败处理。若把写入改为 alias/data stream,仍须提供与读取、写入、mapping resolver 一致的迁移设计。
4. 更新索引模板并重新校验后,历史数据仍无法查询
模板不重写历史 mapping/data。需要 reindex 或显式迁移;schema 重新校验只重新读取当前后端能力。
5. runtime field 查询被拒绝
runtime field 的 projection 与部分查询能力受 mapping resolver 限制。以 runtime schema 暴露的 capability 为准,不绕过公共查询验证。
完整配置示例
spring:
elasticsearch:
uris: ${ELASTICSEARCH_URIS}
wow:
elasticsearch:
auto-init-template: true
query:
batch-size: 10000
keep-alive: 1m
event-store-batch:
enabled: false
snapshot-store-batch:
enabled: false
eventsourcing:
store:
storage: elasticsearch
snapshot:
storage: elasticsearch最佳实践
- 显式选择 event/snapshot storage,并核对生成的 binding;
- 由平台管理 mapping、模板、ILM、备份和 reindex;
- 保留快照版本保护与 bulk item 级失败;
- 用真实集群验证 mapping、PIT、aggregation 和升级。
聚焦检查:
./gradlew :wow-elasticsearch:check