首页 文章 精选 留言 我的

精选列表

搜索[Spring Framework],共10000篇文章
优秀的个人博客,低调大师

Spring boot使用Swagger2,在Spring cloud 注册的时候,status的显示端口和链接端口不一致

前言: 一般网关应用,都会进行集成Swagger2进行接口处理。 问题: 昨天发现了一个问题,就是注册到Eureka的时候,发现控制台status的显示端口和里面链接端口不一致,不管端口怎么修改,链接的端口都是显示8080,但是实质项目的真正端口,是修改后的端口。 分析: 查看了其他项目,都是显示正常的,只有这个网关应用是出现异常。首先和其他项目对比,发现网关服务只是多了Swagger2的集成,于是注释了集成的配置,测试之后,发现正常了。 解决方法: 首先是查看Swagger2的版本,发现是2.6.0,马上查看最新版本,发现是2.6.1,所以马上进行版本升级,测试之后,正常。(其他版本不知道会不会出现这个问题) 提示:一般发现问题,我会先进行版本的升级,之后再进行其他处理。

优秀的个人博客,低调大师

《Spring ES实战》第11招 索引怎么安全升级

# Elasticsearch 生产级 Schema 迁移:从 Mapping 冲突到 Alias 原子切换 ```text 旧方案:直接改 Mapping/Analyzer → 冲突:字段类型不可变、分词器变更需重建 → 后果:线上查询报错、数据不一致、回滚困难 ``` 很多团队第一次把 Elasticsearch 当 MySQL 用,习惯在应用启动时自动创建索引、修改 Mapping。 这个习惯在测试环境看不出问题,一旦进入生产,就会撞上 Elasticsearch 最核心的约束:已经写入数据的字段类型不能直接修改,Analyzer 变更也不会追溯已分词内容。 我是老李,过去半年在深圳一家电商公司负责搜索服务。商品索引从单字段全文匹配,升级到拼音、同义词、IK 分词组合检索时,我们经历了从“改不动”到“不敢改”的全过程。 为什么生产环境不能让应用启动时随意创建和修改核心索引? 是继续在旧索引上打补丁,还是建立新索引、重建数据、切换流量? 如果选错,线上搜索会不会大面积报错,回滚时又发现数据已经不一致? # 01、故事 ```text 周四 23:40 → 搜索服务发布 → 任务:商品搜索支持拼音与同义词 → 冲突:启动脚本改 Mapping 失败,线上开始报 mapper_parsing_exception ``` 那天晚上十一点多,搜索服务按计划发布。任务是让商品标题支持拼音搜索和同义词扩展,运营已经催了两周。发布脚本里有一段启动时自动更新 Mapping 的逻辑,测试环境跑过没问题。 生产环境刚滚动到第二台实例,监控就开始告警。商品索引写入失败,错误是 mapper_parsing_exception,提示字段类型冲突。紧接着搜索接口开始出现 400,用户端搜索无结果。 我第一时间想回滚,但发现第一台实例已经把 Mapping 改了一半。旧数据还在,新写入却进不来,索引状态变成了黄灯。 > 深夜告警声催人醒, > 索引字段改不成。 > 回滚按钮在发抖, > 数据一半新一半旧。 # 02、问题 ```text 需求变化:单字段全文 → 拼音+同义词+IK → 冲突:Analyzer 变更不追溯、字段类型不可变 → 后果:写入失败、查询语义分裂、回滚不干净 ``` 根本原因是需求从“能搜到”升级为“搜得准”。原来 title 字段用 standard 分词器,现在要换成 IK 加拼音过滤器,还要增加一个同义词字段。 但 Elasticsearch 里已经写入的字段类型不能修改,Analyzer 只对新写入的文档生效。 旧文档还是按 standard 分词,新文档按 IK 分词,查询时同一个词在两批数据里命中逻辑完全不同。 可验证的验收标准是:全量商品数量一致,拼音搜索“shouji”能命中“手机”,同义词搜索“手机壳”能命中“手机保护套”,且切换过程中写入不中断。 > 旧索引像一堵墙, > 字段类型焊死在场。 > 新需求要穿墙过, > 只能另起一栋房。 # 03、原理 ```text 核心机制:Mapping 不可变 + Analyzer 不追溯 → 唯一安全路径:新索引 + 全量重建 + Alias 切换 ``` Elasticsearch 的 Mapping 一旦写入,字段类型和分词器就固化在 Lucene 的倒排索引里。修改 Mapping 只能增加新字段,不能改变已有字段类型,也不能让旧文档重新分词。 反直觉的是:即使你“成功”更新了 Mapping,旧文档也不会自动重建。查询时新旧文档可能返回完全不同的相关度排序,用户看到的结果像两个搜索引擎拼在一起。 所以 Elasticsearch Schema Migration 的标准思维不是“修改旧索引”,而是“建立新索引、重建数据、切换流量”。Alias 是切换流量的关键,它让应用永远只认一个逻辑名字。 > 倒排索引已固化, > 改字段是镜中花。 > 新索引里重建数据, > 别名一换流量达。 # 04、架构 ```text 输入:商品数据源 + 新 Mapping/Analyzer 定义 → 本模块:product_v2 创建 + Reindex + Alias 原子切换 → 输出:products 别名指向 v2,v1 保留回滚 ``` 整体分四层:数据源层从 MySQL 或消息队列读取商品; 索引层维护 product_v1 和 product_v2 两个物理索引; 路由层用 Alias products 指向当前生效版本; 应用层只认 products,不感知版本。 收益是切换原子化,回滚只需改 Alias 指向。代价是重建期间双倍存储和写入放大,需要保证数据源可重放。 适用条件是数据量在可控范围内,且业务能接受短暂只读或双写。超大集群需要配合滚动重建和流量灰度。 > 应用只认别名, > 版本藏在身后。 > 新索引重建完毕, > 切换只需一挥手。 # 05、第一版实战 ```text 环境:Elasticsearch 8.11 + Java 17 → 配置:product_v2 Mapping/Analyzer → 代码:Bulk 写入 → 启动:创建索引 → 验证:数量与查询 → V1:v2 就绪但未切换 ``` 先创建 product_v2 索引,定义 IK 分词器和拼音过滤器。版本号需按当前官方文档核验,以下以 8.11 为例。 ```json PUT /product_v2 { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "analyzer": "ik_pinyin_analyzer", "filter": { "pinyin_filter": { "type": "pinyin", "keep_first_letter": true, "keep_full_pinyin": false } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "title_pinyin": { "type": "text", "analyzer": "ik_pinyin_analyzer" }, "price": { "type": "double" }, "category": { "type": "keyword" }, "updated_at": { "type": "date" } } } } ``` 然后写一个 Java 客户端程序,从 MySQL 分页读取商品,用 Bulk 写入 v2。 ```java BulkRequest bulkRequest = new BulkRequest(); for (Product p : page) { Map doc = Map.of( "title", p.getTitle(), "title_pinyin", p.getTitle(), "price", p.getPrice(), "category", p.getCategory(), "updated_at", p.getUpdatedAt() ); bulkRequest.add(new IndexRequest("product_v2").id(p.getId()).source(doc)); } BulkResponse response = client.bulk(bulkRequest, RequestOptions.DEFAULT); ``` 启动后验证数量: ```bash curl -s 'http://localhost:9200/product_v2/_count' ``` 示例输出: ```text {"count":128456,"_shards":{"total":3,"successful":3,"failed":0}} ``` 再验证拼音查询: ```bash curl -s 'http://localhost:9200/product_v2/_search' -H 'Content-Type: application/json' -d '{"query":{"match":{"title_pinyin":"shouji"}}}' ``` 示例输出命中“手机”相关商品。此时 v2 数据完整,但 Alias 仍指向 v1,线上流量未切换。 > 新索引里灯火明, > 数据一条不少。 > 别名还在旧路上, > 切换只等一声令。 # 06、排查 ```text 诊断树:写入失败 → 怀疑 Mapping 冲突 → 检查 _mapping → 证据:字段类型不一致 → 根因:启动脚本直接改 Mapping → 修复:移除启动时修改逻辑 ``` 第一个问题是发布时写入失败。怀疑是 Mapping 冲突,先看当前索引的 Mapping 状态。 ```bash curl -s 'http://localhost:9200/product_v1/_mapping?pretty' ``` 证据显示 title 字段已经是 text,但启动脚本试图把它改成 keyword,直接报错。根因是应用启动时执行了 PUT Mapping 请求。 修复方式是删除启动脚本里所有修改核心索引 Mapping 的代码,改为只读校验。 第二个问题是 Reindex 过程中出现 version_conflict_engine_exception。怀疑是数据源重复推送,检查 MySQL binlog 发现同一主键被更新两次。 **错误尝试:** 一开始用外部版本号强制覆盖,结果把更新的数据写成了旧版本。后来改为按 updated_at 排序,取最新一条再写入。 > 启动脚本手太长, > 半夜改字段闯祸。 > 删掉自动修改, > 索引才恢复沉默。 # 07、优化 ```text V1:手动 Bulk 写入,无断点续传 → 证据:中途失败后重复写入、版本冲突 → V2:Reindex + 幂等写入 + 进度检查 ``` 第一版用 Java 程序手动 Bulk,中途失败后不知道写到哪,重跑又产生重复和版本冲突。 改为用 Elasticsearch 自带的 Reindex API,从 v1 迁移到 v2,配合脚本填充新字段。 ```json POST /_reindex { "source": { "index": "product_v1" }, "dest": { "index": "product_v2" }, "script": { "source": "ctx._source.title_pinyin = ctx._source.title" } } ``` 对于需要从 MySQL 补充的字段,用业务主键做幂等写入,写入前先查 updated_at,避免旧数据覆盖新数据。 ```java GetRequest get = new GetRequest("product_v2", id); GetResponse existing = client.get(get, RequestOptions.DEFAULT); if (existing.isExists()) { Date old = (Date) existing.getSource().get("updated_at"); if (old.after(p.getUpdatedAt())) continue; } ``` > 手动 Bulk 断了线, > 重跑数据乱成团。 > 换成 Reindex 加幂等, > 进度可查心不悬。 # 08、演进 ```text V2:Reindex + 幂等写入 + Alias 切换 → 能力:原子切换、短期回滚 → 遗留:v1 占用存储、双写未自动化 ``` 用同一组请求验证前后差异。切换前,products 别名指向 v1,拼音查询“shouji”无结果。 ```bash curl -s 'http://localhost:9200/products/_search' -H 'Content-Type: application/json' -d '{"query":{"match":{"title_pinyin":"shouji"}}}' ``` 示例输出: ```text {"hits":{"total":{"value":0}}} ``` 执行原子切换: ```json POST /_aliases { "actions": [ { "remove": { "index": "product_v1", "alias": "products" } }, { "add": { "index": "product_v2", "alias": "products" } } ] } ``` 再次查询,示例输出命中 128 条。回滚只需反向操作,把 Alias 指回 v1。 遗留问题是 v1 还占着存储,双写逻辑没有自动化,后续需要引入索引生命周期管理和数据同步管道。 > 别名一换流量走, > 拼音搜索终出头。 > 旧索引留作退路, > 双写自动化还待修。 # 09、洞见 ```text 从现象到判断:Mapping 改不动不是限制,是保护;Alias 不是便利,是契约 ``` ## 反直觉判断:Mapping 不可变是保护,不是缺陷 ```text MySQL ALTER TABLE → 直接改 → 看似灵活 ES Mapping 不可变 → 强制重建 → 看似笨重 ``` MySQL 的 ALTER TABLE 让开发者觉得改结构是常态,但大表变更会锁表、主从延迟、回滚困难。ES 强制你走新索引,反而把变更变成可测试、可回滚的发布流程。 ## 反直觉判断:Alias 是应用与索引之间的契约,不是便利工具 ```text 应用直连索引名 → 改结构必改代码 应用只认 Alias → 结构变更对应用透明 ``` 很多人把 Alias 当可有可无的别名。实际上它是生产环境的流量开关,没有它,每次 Schema 变更都要发版改代码,回滚更是灾难。 ## 判断:Schema 变更必须进 Git 和 CI,不能靠启动脚本 ```text 启动时自动建索引 → 环境漂移 → 生产事故 Schema 文件进 Git → CI 校验 → 可审计可复现 ``` 索引定义、Analyzer 配置、Mapping 文件都应该像代码一样管理。CI 里跑 dry-run 校验,生产发布前先建 v_next,验证通过再切 Alias。 > 改不动是护城河, > 别名是流量闸。 > Schema 进 Git, > 生产不再怕。 # 10、系统落地 ```text 入口:商品管理后台/搜索 API → 服务:搜索服务只认 products Alias → 本模块:product_v2 + Reindex + Alias 切换 → 存储:v1/v2 双索引 → 未实现:Template 与 CI 自动校验 ``` 原来系统里应用启动时自动创建索引,Schema 散落在代码里。本篇增加了 product_v2 的显式创建、Reindex 流程和 Alias 原子切换。 现在搜索服务只认 products,Schema 变更不再需要改应用代码。回滚从“重新发版”变成“一次 Alias 操作”。 下一篇缺的是 Index Template 和 CI 校验:把 Mapping、Analyzer、Settings 放进 Git,用 CI 在测试集群上演练建索引和 Reindex,通过后才允许生产执 行。官方文档见 Elasticsearch 官网的 Index Aliases 和 Reindex API 页面。 > 入口只认别名, > 服务不再操心。 > 本篇建好新索引, > 下篇 CI 守门。 # 11、小结 ```text 三问三答:为什么不能启动时改 → 因为不可变且不追溯;怎么选 → 新索引+重建+Alias;选错后果 → 写入失败、查询分裂、回滚不干净 ``` 第一问:为什么生产环境不能让应用启动时随意创建和修改核心索引?因为 Mapping 不可变,Analyzer 不追溯,启动时修改会造成写入失败和查询语义分裂。 第二问:是继续在旧索引上打补丁,还是建立新索引、重建数据、切换流量?必须选后者,这是 Elasticsearch Schema Migration 的标准路径。 第三问:如果选错会怎样?线上会大面积报 mapper_parsing_exception,新旧数据查询结果不一致,回滚时发现 Mapping 已经改了一半,数据状态无法还原。 当前状态:product_v2 已上线,Alias 指向 v2,v1 保留作为短期回滚。 > 三问三答已分明, > 不可变是铁律。 > 新索引重建切换, > 生产从此有底气。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 Reindex 之后旧索引的查询结果不会自动更新? 因为 Reindex 是把文档从源索引拷贝到目标索引,目标索引按新 Mapping 重新分词建倒排。旧索引的倒排索引没有变化,查询旧索引仍然按旧分词器命中。 ## 实战题:用 Reindex API 把 product_v1 迁移到 product_v2,并验证数量一致。 先执行 POST /_reindex,source 为 product_v1,dest 为 product_v2。 完成后分别对两个索引执行 _count,确认数量相同。 再对 v2 执行拼音查询,确认新 Analyzer 生效。 ## 排障题:Reindex 过程中出现 version_conflict_engine_exception,如何定位和解决? 先看报错里的文档 ID,再到源索引查该文档的 _version 和 updated_at。 通常是数据源重复推送或并发更新导致。 解决方式是按业务主键幂等写入,写入前比较 updated_at,旧版本跳过。 ## 架构判断题:应用启动时自动创建索引并更新 Mapping,是否适合生产环境? 不适合。启动时自动创建会让 Schema 变更不可审计、不可回滚,且多实例滚动发布时可能产生 Mapping 冲突。 生产环境应把 Schema 文件纳入 Git,由 CI 校验,通过 Alias 切换完成变更。 > 理解倒排不追溯, > 实战 Reindex 不糊涂。 > 排障版本冲突有解, > 架构判断守门户。 # 13、升华 ```text 工程结论:Schema 迁移的本质是发布流程,不是数据操作 ``` Elasticsearch 的 Schema 迁移不是一条命令,而是一套发布流程:定义新版本、重建数据、验证、切换、保留回滚。 这套流程和代码发布一样,需要版本管理、CI 校验、灰度切换和回滚预案。把 Schema 当代码管,把 Alias 当流量开关用,生产环境才不会在深夜告警中手忙脚乱。 下一篇将把 Index Template、Settings 和 Schema 文件接入 Git 与 CI,让每一次索引变更都像发版一样可预期。 > 迁移不是改数据, > 是发布流程在走。 > Schema 进 Git, > 生产从此无忧。 **系列项目:** 深圳电商搜索服务 **已完成:** 商品索引基础检索与数据接入 **本篇:** product_v2 索引版本化、Reindex 与 Alias 原子切换 **下一篇:** Index Template 与 Schema 文件接入 Git/CI 自动校验

优秀的个人博客,低调大师

《Spring ES实战》第14招 向量进入搜索主线

# 向量不是新终点:Elasticsearch 语义搜索与 BM25 的工程取舍 ```text 旧方案:BM25 只匹配字面词,语义相近但用词不同就召回失败 冲突:用户搜“适合经常出差程序员的轻便背包”,商品描述写“通勤电脑包” 后果:语义相关商品大量漏召回,转化率下降,运营被迫手工维护同义词表 ``` 很多团队把向量搜索单独做成 AI Demo,演示完就搁置。真正的问题是:它如何进入现有搜索主链路,而不是另起炉灶。 我是老李,这几年在电商搜索团队里反复遇到同一个矛盾:业务方希望搜索“懂意思”,但线上故障又总来自“懂意思”带来的不可控。 为什么精确词匹配仍然不能被向量召回替代? 当语义召回与关键词召回冲突时,应该选择合并、分层还是替换? 如果选错,线上搜索会出现什么后果? # 01、故事 ```text 周二 14:30→搜索项目室→任务:上线语义搜索提升长尾查询召回→冲突:测试集召回提升,但品牌词开始串味 ``` 那天下午两点半,搜索项目室里投影仪亮着。任务是让“适合经常出差程序员的轻便背包”这类长尾查询,能召回描述为“通勤电脑包”的商品。 我打开 Kibana 跑了一版 kNN 查询,语义结果确实漂亮。可运营同学立刻发来截图:搜“ThinkPad X1 Carbon 背包”,第一条变成了某品牌通用内胆包。 会议室安静了几秒。语义搜索把品牌词“ThinkPad X1 Carbon”也当成语义向量去近似匹配了。 > 长尾查询像雾, > 字面匹配漏掉真意。 > 向量补上缺口, > 品牌词却开始漂移。 # 02、问题 ```text 需求变化:从“字面匹配”到“理解意图”→冲突:语义召回与精确召回互相干扰→后果:品牌、型号、订单号等精确查询失准 ``` 旧方案里,BM25 对“ThinkPad X1 Carbon 背包”能精确命中标题和属性字段。引入向量后,如果只按 kNN 分数排序,精确词信号会被稀释。 影响很直接:用户搜品牌和型号时,看到的是“差不多”的商品,而不是“就是它”。这会造成客诉和退款率上升。 可验证的验收标准是:品牌词、型号词、订单号查询的 Top1 命中率不得低于旧版;长尾语义查询的召回率至少提升 20%。 > 字面是锚, > 语义是帆。 > 锚若被拔起, > 船会漂向无关。 # 03、原理 ```text 核心机制:BM25 做精确召回,dense_vector 做语义召回,两者分数独立计算后再融合 ``` Elasticsearch 的 dense_vector 字段存储 embedding,kNN 查询用相似度函数找近邻。 semantic_text 字段则把 embedding 生成和查询转换封装得更简单。 反直觉的一点是:向量相似度高不代表用户想找它。用户搜“轻便背包”,向量可能召回“轻便行李箱”,因为语义接近,但类目已经错了。 所以向量召回必须配合 Metadata Filter,例如 tenant、category、brand 等结构化条件,把语义搜索限制在业务边界内。 > 向量量的是近, > 不是量的是对。 > 近是数学, > 对是业务。 # 04、架构 ```text 输入:用户查询字符串 + tenant/category 过滤条件→本模块:BM25 召回 + kNN 语义召回 + 分数融合→输出:带业务过滤的排序结果 ``` 架构上分三层:查询解析层负责识别精确词和语义意图;召回层同时走 BM25 和 kNN;融合层用 RRF 或加权分数合并。 收益是长尾查询召回明显提升,代价是索引体积增大、查询延迟增加、embedding 模型需要维护。 适用条件是:有大量自然语言描述文本,且用户查询用词与商品描述用词经常不一致。纯 SKU 或订单号查询场景不适合。 > 一条查询两条路, > 字面走倒排, > 语义走向量。 > 最后在融合处会合。 # 05、第一版实战 ```text 环境:Elasticsearch 8.15+→配置:dense_vector 索引→代码:写入 embedding→启动:执行 kNN 查询→验证:语义召回→V1 ``` 先确认环境版本。Elasticsearch 8.15 已支持 semantic_text,但 dense_vector 更通用。以下用 dense_vector 演示,版本需按当前官方文档核验。 创建索引: ```json PUT /products { "mappings": { "properties": { "name": { "type": "text" }, "description": { "type": "text" }, "description_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" }, "tenant": { "type": "keyword" }, "category": { "type": "keyword" }, "brand": { "type": "keyword" } } } } ``` 写入文档前,需要把 description 转成 768 维向量。这里用 Python 调用本地 embedding 模型生成向量,再写入 ES: ```python from elasticsearch import Elasticsearch from sentence_transformers import SentenceTransformer model = SentenceTransformer('BAAI/bge-base-zh-v1.5') es = Elasticsearch("http://localhost:9200") docs = [ { "name": "轻量通勤电脑包", "description": "适合经常出差的程序员使用,可容纳14英寸笔记本,轻便防泼水", "tenant": "tenant_a", "category": "背包", "brand": "CityPack" }, { "name": "商务双肩包", "description": "大容量旅行背包,适合通勤和短途出差", "tenant": "tenant_a", "category": "背包", "brand": "UrbanGo" } ] for doc in docs: doc["description_vector"] = model.encode(doc["description"]).tolist() es.index(index="products", document=doc) ``` 执行 kNN 查询: ```json POST /products/_search { "knn": { "field": "description_vector", "query_vector_builder": { "text_embedding": { "model_id": ".multilingual-e5-small", "model_text": "适合经常出差程序员的轻便背包" } }, "k": 10, "num_candidates": 50, "filter": { "bool": { "must": [ { "term": { "tenant": "tenant_a" } }, { "term": { "category": "背包" } } ] } } } } ``` 示例输出: ```text 命中:轻量通勤电脑包,分数 0.91 命中:商务双肩包,分数 0.78 ``` V1 成功标准:语义相近但字面不同的商品能进入 Top10,且 tenant/category 过滤生效。未在当前环境实测,以下为预期结果。 > 向量写入索引, > 查询转成数字。 > 过滤先划边界, > 语义只在圈内。 # 06、排查 ```text 诊断树:语义召回异常→先查过滤条件→再查向量生成→最后查相似度阈值 ``` 第一个问题:搜“ThinkPad X1 Carbon 背包”时,通用内胆包排到第一。怀疑是 kNN 分数主导了排序。 检查查询: ```json GET /products/_search { "query": { "match": { "name": "ThinkPad X1 Carbon 背包" } } } ``` 证据:BM25 查询能精确命中品牌商品,但 kNN 查询把“ThinkPad”当成语义概念,召回一堆笔记本配件。根因是纯 kNN 查询没有保留精确词信号。 修复:把 BM25 和 kNN 合并,用 RRF 融合。 第二个问题:部分商品 description 为空,写入时报错。检查发现 embedding 模型对空字符串返回了 None。 ```bash curl -s "http://localhost:9200/products/_count" -H 'Content-Type: application/json' -d '{"query":{"exists":{"field":"description"}}}' ``` 证据:空 description 的文档没有写入 description_vector。根因是写入脚本缺少空值保护。修复:空 description 跳过向量生成,只写文本字段。 **错误尝试:** 一开始把 kNN 的 k 调到 100,想让语义结果更多。结果延迟翻倍,精确词问题更严重。 > 先查边界, > 再查向量。 > 最后查阈值, > 顺序别乱。 # 07、优化 ```text V1:纯 kNN 查询→证据:品牌词漂移、延迟升高→V2:BM25 + kNN 双路召回 + RRF 融合 ``` 改动核心是查询结构。不再单独发 kNN,而是用 bool 查询同时走 match 和 kNN,再用 RRF 合并。 ```json POST /products/_search { "query": { "bool": { "should": [ { "match": { "name": { "query": "ThinkPad X1 Carbon 背包", "boost": 2.0 } } }, { "knn": { "field": "description_vector", "query_vector_builder": { "text_embedding": { "model_id": ".multilingual-e5-small", "model_text": "ThinkPad X1 Carbon 背包" } }, "k": 10, "num_candidates": 50, "boost": 0.5 } } ], "filter": [ { "term": { "tenant": "tenant_a" } }, { "term": { "category": "背包" } } ] } }, "rank": { "rrf": { "window_size": 50, "rank_constant": 20 } } } ``` 为何改:BM25 保留精确词信号,kNN 补充语义召回,RRF 不依赖分数绝对值,避免向量分数和 BM25 分数不可比的问题。 > 两条路都走, > 分数各自算。 > 排名再合并, > 精确词不散。 # 08、演进 ```text V2:双路召回 + RRF→能力:精确词与语义词都能召回→遗留:embedding 模型更新、阈值调优、跨语言场景 ``` 用 V1 同一组请求验证:搜“适合经常出差程序员的轻便背包”,语义商品仍在前排;搜“ThinkPad X1 Carbon 背包”,品牌商品回到 Top1。 收益是长尾查询召回提升,精确查询不退化。代价是查询复杂度增加,需要维护两套召回逻辑。 遗留问题:embedding 模型升级后需要重建向量;相似度阈值需要按类目调优;跨语言语义搜索需要多语言模型。 > 能力增加了, > 代价也来了。 > 不是替代, > 是补位。 # 09、洞见 ```text 从现象到判断:向量召回不是新终点,而是倒排索引的新召回方式 ``` ## 向量搜索不能替代关键词搜索 ```text 精确词→BM25 锚定;语义词→kNN 补充 ``` 品牌、型号、订单号等精确词,用户期望的是“完全匹配”,不是“差不多”。BM25 在这类查询上更可靠,向量只适合自然语言描述类查询。 ## 反直觉判断:语义相似不等于业务相关 ```text 向量近→数学近;业务对→类目对 ``` 向量模型只衡量语义距离,不懂业务边界。没有 Metadata Filter,语义搜索会把“轻便背包”召回成“轻便行李箱”。 ## 反直觉判断:分数融合比单一召回更稳定 ```text BM25 分数与向量分数不可比→RRF 只比排名 ``` 直接加权相加会因为分数分布不同而偏向某一方。RRF 用排名融合,避免分数尺度问题,工程上更稳。 ## 向量是召回方式,不是搜索终点 ```text 倒排索引→精确召回;dense_vector→语义召回;融合→最终排序 ``` 把向量当成一种新的召回信号接入现有搜索链路,比另起炉灶做 AI 搜索更务实。 > 向量不是终点, > 是倒排的新枝。 > 精确词仍靠锚, > 语义词靠帆。 # 10、系统落地 ```text 入口:搜索 API→服务:查询解析与融合→本模块:BM25 + kNN + RRF→存储:dense_vector 索引→未实现:模型版本管理与自动重建 ``` 原来只有 BM25 字面搜索。本篇增加了 dense_vector 字段、embedding 生成、kNN 查询和 RRF 融合。 现在能做什么:长尾语义查询能召回字面不同但语义相关的商品,同时品牌词等精确查询不退化。 下一篇缺什么:embedding 模型版本管理、向量索引自动重建、相似度阈值自动调优。官方文档见 Elasticsearch 官网文档首页。 > 入口还是那个入口, > 服务多了一条路。 > 存储多了向量, > 模型管理还缺。 # 11、小结 ```text 三问三答:精确词为何不能替代→语义与精确冲突怎么办→选错会怎样 ``` 第一问:精确词匹配不能被向量替代,因为品牌、型号、订单号需要完全匹配,向量只保证语义近似。 第二问:语义与精确冲突时,选择双路召回加 RRF 融合,而不是替换或简单加权。 第三问:选错会导致品牌词漂移、客诉上升、延迟翻倍。当前状态是 V2 双路召回已上线,精确词 Top1 命中率保持,长尾召回提升。 > 三问有了答, > 路也走通了。 > 不是替代, > 是补位。 # 12、作业 ```text 理解→实战→排障→架构判断 ``` ## 理解题:为什么 BM25 在品牌词查询上比 kNN 更可靠? BM25 基于词项匹配,品牌词在标题和属性字段中通常以精确词形式出现,倒排索引能直接命中。kNN 把品牌词转成向量后,会与语义相近但不完全相同的商品混淆,导致排序漂移。 ## 实战题:给 products 索引增加 semantic_text 字段,并实现一个带 category 过滤的语义查询。 semantic_text 字段会自动处理 embedding 生成和查询转换。 创建索引时把 description 映射为 semantic_text,写入文档时无需手动生成向量。 查询时用 semantic 查询并加 filter 条件限制 category。 需按当前官方文档核验具体语法。 ## 排障题:语义搜索上线后,某类目商品全部消失,如何排查? 先检查 filter 条件是否把该 category 排除,再查 embedding 生成是否失败导致向量字段为空,最后确认 kNN 查询的 num_candidates 是否太小导致召回不足。 用 exists 查询检查向量字段覆盖率。 ## 架构判断题:订单号查询是否应该引入向量召回? 不应该。订单号是精确标识符,用户期望完全匹配。向量召回会引入无关订单,增加延迟且降低准确率。订单号查询应继续使用 term 或 keyword 精确匹配。 > 理解是根, > 实战是干。 > 排障是枝, > 架构是果。 # 13、升华 ```text 工程结论:向量搜索的价值在于补位,不在于替代 ``` 有依据的结论是:在电商搜索中,BM25 负责精确召回,向量负责语义召回,两者通过 RRF 融合,才能在提升长尾召回的同时不伤害精确查询。 下一篇可以继续做 embedding 模型版本管理与向量索引自动重建,把语义搜索从“能跑”推进到“可维护”。 > 倒排是根, > 向量是枝。 > 根深枝才茂, > 搜索才可靠。 **系列项目:** 电商搜索系统,服务运营与终端用户 **已完成:** BM25 字面搜索、类目与租户过滤 **本篇:** dense_vector 语义召回、kNN 查询、BM25+kNN 双路融合 **下一篇:** embedding 模型版本管理与向量索引自动重建

优秀的个人博客,低调大师

《Spring Mongo实战》第8招 更新别总save

## 01、痛点 凌晨三点,运营群炸了:某爆款商品库存明明还有 50 件,后台却显示负数,超卖了几十单。我是老李,被拉起来救火。第一反应:是不是代码在并发下把库存扣成了负数?翻出核心逻辑,发现是典型的 find → Java 修改 → save:先按 id 查出商品文档,拿到 stock,减去 1,再保存回去。这个流程在压测下必然出问题。 更具体地看,很多 Java 项目里会这么写: Document doc = productRepo.findById(skuId); int currentStock = doc.getInteger(stock); doc.put(stock, currentStock - 1); productRepo.save(doc); 这段逻辑单线程跑没问题,一上并发就露馅。假设库存还剩 10 件,两个请求几乎同时到达: - 线程 A 执行 findById,读到 stock = 10 - 线程 B 执行 findById,也读到 stock = 10 - 线程 A 在内存里把 stock 改成 9,写回数据库 - 线程 B 在内存里也把 stock 改成 9,后写回数据库 最终数据库里的库存是 9,而不是正确的 8。少扣的那一件就是超卖。如果并发量再大一点,100 个请求同时读,库存可能只被扣掉几次,系统眼睁睁看着超卖。 浏览量也是同样的问题:文章详情页每次打开都先读取 viewCount,加 1,再保存。高流量下,几十次浏览只增加一次计数,数据完全不准确。点赞、收藏、评论数、SKU 数组的局部修改,凡是走“读出来、改一改、存回去”的路线,都躲不开这个坑。 问题的本质不是 Java 代码写得不好,而是应用层读改写之间有一个时间窗口。这个窗口里没有锁,后写会把先写的结果覆盖掉,这就是丢失更新。更糟的是,很多时候开发同学为了修这个 bug,第一反应是加分布式锁,结果系统越写越复杂,吞吐还上不去。其实 MongoDB 早就给了更优雅的答案。 ## 02、是什么 MongoDB 一个非常重要的能力,是直接针对 Document 字段进行原子更新,而不是每次读取完整对象再保存。换句话说,你不需要把文档拉到应用层改完再写回去,而是把“改哪个字段、怎么改”用更新表达式发给数据库,数据库在服务端完成原子操作。 这套更新表达式通常叫 Update DSL,核心操作符有几个: $set:给指定字段赋值。比如把某个商品的状态改成“下架”,其他字段不受影响。 $inc:对数值字段做原子加减。正数增加,负数减少。非常适合计数、库存、余额等场景。 $push:向数组追加元素。比如给某个文档的 SKU 数组新增一个规格,或者给用户消息列表追加一条消息。 $pull:从数组移除满足条件的元素。比如从购物车删除某个商品,或者从标签集合移除某个标签。 upsert:update 时如果查询条件没有匹配到文档,就插入一条新文档。常用于“有则更新,无则插入”的幂等操作。 findAndModify:原子地查找并修改一个文档,可以返回修改前或修改后的文档。在 Java 驱动里通常对应 findOneAndUpdate。 单文档原子性是这里的基石。MongoDB 保证对单个文档的所有写操作是原子的,要么全部生效,要么全部不生效。两个并发更新同一文档时,数据库会串行化执行,不会出现前文那种读改写互相覆盖。 举个最简单的例子,把库存减 1 可以写成: db.products.updateOne( { _id: skuId }, { $inc: { stock: -1 } } ) 这条命令不需要先查询库存,也不需要把文档读回 Java 内存。数据库自己知道要把 stock 字段减 1,整个操作在文档级别原子完成。 ## 03、为什么 为什么 $inc、$set 这些更新表达式能解决覆盖问题?因为“读出旧值、计算新值、写入新值”这三个步骤被压缩到了数据库内部。应用层只发送一个意图:把 stock 减 1。至于当前 stock 是多少,数据库拿着文档级锁或并发控制机制去处理。 在 find → Java 修改 → save 的模式里,读和写是两次独立操作,中间隔着网络往返和应用逻辑。这个窗口越长,并发越高,丢更新越严重。而服务器端原子更新把整个操作变成一次请求、一次执行,天然没有应用层窗口。 这样做至少带来四个好处: 第一,并发安全。两个 $inc 操作先后到达,MongoDB 会串行化处理,最终 stock 被扣两次,结果正确。 第二,网络开销小。读改写需要两次请求,原子更新只需要一次请求,延迟更低。 第三,锁粒度小。MongoDB 单文档操作只锁一个文档,其他文档的读写照常进行,吞吐不受太大影响。 第四,代码更简洁。不用写复杂的事务或分布式锁,更新意图直接表达在 DSL 中。 当然,这里要强调“单文档”三个字。如果是跨多文档的改动,比如从 A 文档扣库存、再往 B 文档写订单,单文档原子性管不了。这种场景需要多文档事务。但在很多业务中,如果数据结构设计合理,把需要一起更新的字段放在同一个文档里,就能享受单文档原子更新的红利。 ## 04、架构 MongoDB 的单文档原子性由存储引擎保障。以 WiredTiger 为例,它采用文档级并发控制。同一时刻对同一个文档的写操作会排队,对不同文档的写操作可以并行。更新请求到达 mongod 后,会解析更新表达式,然后发给存储引擎。存储引擎在文档级锁或乐观并发控制下完成原子更新。 大致的链路可以这样理解: 应用 -> mongod -> WiredTiger -> 文档级原子操作 当副本集部署时,主节点负责处理写操作。单文档原子更新在主节点执行完成后,再通过复制机制同步到从节点。因为复制的是操作结果或者 oplog,单个文档的原子性在复制过程中仍然保持。 findAndModify 是另一个值得留意的能力。它在一个操作里完成“查找 + 更新 + 返回”三件事,并且返回的文档与更新结果一致。例如把订单状态从“待支付”改成“已支付”,同时把更新后的订单返回给前端,这个操作不会出现两个请求都支付成功的情况。因为它也是单文档原子操作,底层与 updateOne 类似,只是多了返回文档的能力。 需要注意的是,MongoDB 的原子更新是在单个文档范围内,而不是整个集合或整个库。因此数据建模时,如果能把相关字段聚合到同一个文档,原子更新的威力会更大。比如商品文档里同时包含库存、浏览量、SKU 数组,扣库存、加浏览量、修改 SKU 都可以在同一次更新里用多个操作符完成。 ## 05、实战 看完概念,我们具体看三类常见业务的落地。 一、库存 stock -= 1 库存扣减是典型的高并发计数。基本写法是: db.products.updateOne( { _id: skuId, stock: { $gte: 1 } }, { $inc: { stock: -1 } } ) 这里过滤条件里加了 stock: { $gte: 1 },确保库存大于等于 1 才执行扣减。如果 modifiedCount 为 0,说明库存已经不足,前端可以直接提示“已售罄”。这样从源头上避免了库存变成负数。 在 Java 驱动中,同样用 updateOne 方法,传入过滤条件和更新表达式即可。重点是把业务判断下推为查询条件,而不是在应用层判断。 二、浏览量 viewCount += 1 浏览量场景更简单: db.articles.updateOne( { _id: articleId }, { $inc: { viewCount: 1 } } ) 每次打开文章,直接发这条命令,数据库负责原子加 1。不用担心丢失,也不用先查出来。如果还想顺便记录浏览用户,可以再配合 $push 将用户 id 追加到 viewUsers 数组,并配合 $addToSet 去重。 三、SKU 数组修改 数组修改是平时很容易踩坑的地方。如果先把整个 SKU 数组读出来,在 Java 里改完再保存回去,并发下整个数组都可能被冲掉。正确做法是使用数组更新操作符。 新增一个 SKU: db.products.updateOne( { _id: productId }, { $push: { skus: { skuId: newId, price: 9.9, stock: 10 } } } ) 删除一个 SKU: db.products.updateOne( { _id: productId }, { $pull: { skus: { skuId: oldId } } } ) 更新数组中某个元素的字段,可以用位置操作符 $: db.products.updateOne( { _id: productId, skus.skuId: targetId }, { $set: { skus.$.price: 8.8 } } ) 如果数组里有多个元素需要更新,可以使用 arrayFilters 配合 $[element] 表达式。 upsert 的实战示例:用户登录时更新登录次数。如果没有该用户记录,则插入一条新文档,同时记录创建时间: db.users.updateOne( { openId: openId }, { $setOnInsert: { createTime: now }, $inc: { loginCount: 1 } }, { upsert: true } ) findAndModify 实战示例:订单支付状态流转。查询条件带 status: 待支付,更新成已支付,并返回更新后的文档: db.orders.findAndModify({ query: { _id: orderId, status: 待支付 }, update: { $set: { status: 已支付 } }, new: true }) 这里用单文档原子操作防止两个支付回调同时把订单改成已支付,配合条件查询可以避免重复支付。 ## 06、洞见 MongoDB 的优势之一,就是把业务原子操作尽可能下推到单 Document。这句话听起来简单,但背后是一种数据建模和业务逻辑的重新思考。 很多从关系型数据库转过来的工程师,习惯把 MongoDB 当成一个对象存储,读写方式还是先查再改。这带来的问题不仅是并发覆盖,还把数据库的性能浪费在应用层逻辑上。真正用好 MongoDB,应该让数据库多做它擅长的事:原子地更新字段、数组、计数。 要做到这一点,有几个指导原则: 第一,能用一个文档表达的状态,就不要拆成多个文档。比如电商商品,把库存、浏览量、SKU 数组放在一个文档里,扣库存、浏览量加 1、改 SKU 都可以在同一次更新里完成,避免跨文档事务。 第二,单个字段的自增自减,永远优先用 $inc。库存、点赞数、评论数、积分、余额,这些场景用 $inc 最合适。 第三,数组的局部修改,永远优先用 $push、$pull、$set 配合位置操作符。不要整组读出来改,那样既危险又低效。 第四,状态流转用 findAndModify 或 updateOne 带条件更新。把前置状态放在查询条件里,避免并发下状态被重复推进。 第五,跨文档事务是最后手段。MongoDB 支持多文档事务,但事务的成本比单文档原子更新高得多。如果可以通过嵌入数据模型把多个操作压缩到一个文档,就不要轻易开启事务。 说到底,MongoDB 给了我们一个机会:让数据库成为原子操作的执行者,而不是把一致性压力全部丢给应用层。这个能力一旦用起来,很多并发 bug 会从根源上消失。 ## 07、行动 看完文章,最重要的事情是把库存、点赞、计数三个“读改写”流程改造成服务器端原子更新。这里我列一个可以立马执行的行动清单: 第一步,梳理现有读改写。在代码库中搜索 findById / findByXxx + save 的组合,找出所有库存、点赞、计数、状态流转的读改写点。 第二步,库存扣减改成带条件更新。用 updateOne 搭配 stock: { $gte: 1 } 过滤,再 $inc: { stock: -1 }。根据 modifiedCount 判断是否扣减成功,避免库存为负。 第三步,点赞数、收藏数、评论数等计数场景,全部改成 $inc。点赞还可以配合 $addToSet 防止重复点赞,取消点赞用 $pull 移除。 第四步,SKU 数组修改改用 $push、$pull、$set 和位置操作符。不要在应用层整体读写数组。 第五步,需要返回更新后文档的场景,使用 findOneAndUpdate 或 findAndModify。比如支付状态流转、抢购成功返回最新库存。 第六步,补上并发测试。用 JMeter 或压测脚本模拟同一时间大量并发请求,验证库存不为负、计数不丢失、SKU 数组不被覆盖。 第七步,建立代码规范。在团队 wiki 里明确:禁止对单文档字段做“读取-修改-保存”,所有数值增减、数组修改必须使用更新 DSL。 这三类流程只是起点。掌握了单文档原子更新后,你会发现更多适合下推的原子操作,比如积分变动、余额扣减、库存回补、状态机流转。越早用上,越能避免那些半夜救火的经历。 ## 08、小结 MongoDB 的单文档原子更新,是解决高并发读改写覆盖问题的第一选择。别再手动 find → Java 修改 → save 了,这种做法在并发下必然丢更新。 用 $inc 做计数,用 $set 做字段赋值,用 $push / $pull 做数组修改,用 upsert 做有则更新无则插入,用 findAndModify 做原子查找并更新。把业务原子操作下推到数据库,代码更简单,性能更好,数据更一致。 下半夜再被库存超卖电话吵醒的概率,会小很多。 ## 09、链接与概念说明 概念说明: 单文档原子性:MongoDB 保证对单个文档的所有写操作是原子的,不会出现部分写入或并发冲突。 Update DSL:描述文档字段如何更新的表达式,包含 $set、$inc、$push、$pull 等操作符。 upsert:update 时如果查询条件无匹配文档,则插入一条新文档。 findAndModify:原子地查找并修改文档,并返回修改前或修改后的文档。 WiredTiger:MongoDB 默认存储引擎,提供文档级并发控制和单文档原子写。 官方参考链接: MongoDB Update Operators:https://www.mongodb.com/docs/manual/reference/operator/update/ findAndModify:https://www.mongodb.com/docs/manual/reference/command/findAndModify/ upsert 行为说明:https://www.mongodb.com/docs/manual/reference/method/db.collection.updateOne/ WiredTiger 存储引擎:https://www.mongodb.com/docs/manual/core/wiredtiger/ 建议把官方文档里的更新操作符通读一遍,尤其是 $inc、$push、$pull、$set、数组位置操作符和 arrayFilters。这些操作符看起来多,但常用的就几个,吃透之后,很多业务逻辑会变得更简单。

资源下载

更多资源
腾讯云软件源

腾讯云软件源

为解决软件依赖安装时官方源访问速度慢的问题,腾讯云为一些软件搭建了缓存服务。您可以通过使用腾讯云软件源站来提升依赖包的安装速度。为了方便用户自由搭建服务架构,目前腾讯云软件源站支持公网访问和内网访问。

Nacos

Nacos

Nacos /nɑ:kəʊs/ 是 Dynamic Naming and Configuration Service 的首字母简称,一个易于构建 AI Agent 应用的动态服务发现、配置管理和AI智能体管理平台。Nacos 致力于帮助您发现、配置和管理微服务及AI智能体应用。Nacos 提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据、流量管理。Nacos 帮助您更敏捷和容易地构建、交付和管理微服务平台。

优质分享App

优质分享App

近一个月的开发和优化,本站点的第一个app全新上线。该app采用极致压缩,本体才4.36MB。系统里面做了大量数据访问、缓存优化。方便用户在手机上查看文章。后续会推出HarmonyOS的适配版本。

Sublime Text

Sublime Text

Sublime Text具有漂亮的用户界面和强大的功能,例如代码缩略图,Python的插件,代码段等。还可自定义键绑定,菜单和工具栏。Sublime Text 的主要功能包括:拼写检查,书签,完整的 Python API , Goto 功能,即时项目切换,多选择,多窗口等等。Sublime Text 是一个跨平台的编辑器,同时支持Windows、Linux、Mac OS X等操作系统。

用户登录
用户注册