做搜索引擎的都知道,Elasticsearch基本是绕不开的那一个。不管是给站内做商品搜索、日志检索,还是给业务系统做数据聚合分析,ES靠一套HTTP JSON接口就能把海量数据的存储、索引和检索全部包揽下来。很多人第一次接触ES时容易被一堆名词弄晕——索引、文档、分片、副本、mapping、分词器,看着好像都能理解,真到自己上手建索引、写数据,就各种抓瞎:为什么我的中文搜不出来?为什么字段类型选错了改都改不掉?为什么写入的数据查不到?这篇文章我就从最基础的“索引创建、数据插入、请求示例”讲起,把ES日常使用中最常碰到的那几个环节完整过一遍。适合刚入门的开发者,也适合部署完ES之后还没理清读写流程的运维同学。我会把每个请求都贴出来并解释清楚,保证你可以照着敲一遍就通。
1. 先把关键概念搞明白:索引、文档、倒排索引
1.1 ES的“索引”和MySQL的“索引”根本不是一回事
很多从MySQL转过来的用户第一次用ES都会在这里栽跟头。MySQL里的索引是表结构上的一个辅助对象,用来加速查询的B+树,而ES里的索引是一个完整的逻辑存储空间,它包含了一系列的配置信息(settings)、字段结构定义(mapping)以及真正落盘的数据(shard,也就是分片)。
打个比方,如果你把MySQL里的“数据库”和“表”合并成一个东西,那大概就是ES里的索引。在7.0版本之前,ES里还有个type概念,一个索引下面可以分多个type,结果把数据结构搞得不清不楚,官方后来干脆把type给废掉了。现在一个索引基本就等价于一张二维表,但它存的不是行,而是JSON文档。
每个文档都有一个_id,相当于主键。文档里的字段类型靠mapping来定义,比如字符串字段要选text还是keyword,数字字段用什么精度,日期字段的格式是什么,这些都直接影响检索和聚合的结果。所以,创建索引这件事的本质,其实就是先规划好数据的存储结构,再加上分片和副本策略。
1.2 倒排索引:为什么ES搜索能这么快
ES能成为搜索引擎的老大哥,核心武器就是倒排索引。传统的正排索引是按“文档→关键词”的方向存储,比如一篇文档里有哪些词,查询时得从头扫描所有文档才能知道谁命中了关键词。倒排索引反过来,它维护的是“关键词→文档ID列表”的映射,几乎每个词都对应一串包含它的文档ID,查询时只要找到这个词,就能瞬间定位到所有相关文档。
具体到实现上,ES会对每个text字段做分词,再把分词结果写进倒排表。你搜“搜索引擎”的时候,ES会先把“搜索引擎”拆成“搜索”“引擎”(具体拆法取决于分词器),再拿着这些词去倒排表里找。这也是为什么ES对中文搜索的效果高度依赖分词器——标准分词器处理中文时基本是一整句话当做一个词,搜起来非常痛苦,生产环境基本都会换用IK分词器或者其他的中文分词方案。
理解了这点,你就能明白为什么ES适合搜索而不适合做事务型存储:它为了查询效率牺牲了复杂事务能力和强一致性,但反过来,你给ES丢几千万条数据进去,它依然能在几百毫秒内把结果给你捞出来,这种能力在传统关系型数据库里是做不到的。
2. 动手之前:环境准备与启动
2.1 Windows和Linux下的启动方式
Elasticsearch是Java写的东西,所以环境里得有JDK。好消息是ES在8.x版本之后内置了捆绑的JDK,你不需要自己再折腾JAVA_HOME,只要把安装包解压出来就能用。Windows下直接双击bin/elasticsearch.bat,Linux下执行bin/elasticsearch,等几秒钟看到started字样就代表启动成功了。
Linux上有一个必须注意的坑:ES拒绝用root账号启动。你如果用root执行启动命令,控制台上会直接报can not run elasticsearch as root。这不是bug,是安全策略,ES要求使用非root用户运行。生产环境建议单独建一个用户,比如adduser esuser,然后把ES目录的属主改成这个用户再切过去启动。另外如果你准备部署集群或多节点,还需要把elasticsearch.yml里的network.host从默认的127.0.0.1改成内网地址,同时配置discovery.seed_hosts和cluster.initial_master_nodes,否则节点之间互相找不到。
启动完成后验证方式很简单:浏览器或者curl访问http://localhost:9200,返回一段包含cluster_name、version等信息的JSON,就说明ES已经起来了。
2.2 安装IK分词器和准备好用的调试工具
中文场景下IK分词器几乎成了标配。安装它没有复杂的流程,直接从GitHub release页下载和ES版本严格对应的zip包,解压后放到ES安装目录的plugins/ik文件夹下,重启ES就生效了。注意版本必须严格一致,ES 8.15的版本就对应IK 8.15.x的插件包,装错版本的话ES会因为插件校验失败直接拒绝启动。
调试ES请求我最推荐Kibana的Dev Tools控制台。它会自动补全DSL语法,还带历史记录,比在终端里敲curl舒服太多了。如果你不想装Kibana,Postman也可以,但注意ES部分接口对Content-Type有严格要求,比如application/json没设对,ES会直接返回Content-Type header [application/x-www-form-urlencoded] is not supported之类的错误。
还有一点,8.x版本默认开启了安全认证,首次启动会生成一个随机密码让你配置。如果你只是在本地学习使用,想省去这层麻烦,可以在elasticsearch.yml里关闭安全模块:把xpack.security.enabled设为false,同时把自带的TLS加密也关掉,也就是把xpack.security.transport.ssl.enabled设为false,然后重启。当然生产环境千万别这么干,这是自己学习的偷懒办法。
3. 索引创建:一次完整的索引设计
3.1 先设计字段类型,再写创建请求
我见过一句话总结得很准确:ES的mapping是设计写在前面,后悔留给后面。因为字段类型一旦写入,是不能直接修改的,想改只能重建索引。所以创建索引之前,把业务字段捋清楚特别重要。
假设我现在要做一个简单的博客文章搜索功能,包含文章标题、正文内容、发布时间、作者ID、标签列表、浏览量这几个字段。跟MySQL建表类似,ES每个字段都要选一个类型,核心的对应关系如下:
- 标题和正文:需要被全文检索,用
text类型并配置中文分词器 - 发布时间:用
date类型,同时指定格式,比如yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis(多个格式用双竖线分隔) - 作者ID:不需要分词,用
keyword - 标签列表:也是
keyword的数组,ES原生支持数组存储 - 浏览量:用
integer或long,后续可能要排序和聚合
字段类型选错的典型后果是:你把一个keyword字段硬拿来全文检索,结果发现怎么搜都搜不到;或者你把数字存成了text,聚合统计时报Text fields are not optimised for operations that require per-document field data。
3.2 创建索引的请求示例与参数说明
创建索引用的是PUT请求,路径直接写索引名,请求体里带上settings和mappings两块配置。看一个具体示例:
PUT /blog_article { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "refresh_interval": "5s" }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "publish_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" }, "author_id": { "type": "keyword" }, "tags": { "type": "keyword" }, "views": { "type": "integer" } } } }这部分有几个关键点要解释清楚。
第一,number_of_shards是分片数,它决定了索引的数据在物理上被切成了几块。分片数在索引创建后就没法修改了,因为ES是根据_id的哈希值来路由文档到分片的,改分片数意味着所有数据的存放位置都要重算。所以规划分片数时,不要太随意,也绝不要为了图以后扩容方便就上来设个几百个分片。ES官方建议单个分片容量控制在30GB到50GB之间,比如预估数据总量300GB,设置6到10个分片是合理的。单分片性能有上限,分片过多又会导致集群元数据膨胀、查询聚合的开销变大。
第二,number_of_replicas是副本数,默认1。副本既保证数据高可用,又分担读请求压力。和分片数不同,副本数在索引创建后可以动态调整,不需要重建索引。
第三,refresh_interval决定了索引的“可见性”刷新周期。这后面讲数据插入的时候会细说,但配置成5s或10s对写入频繁的场景是更稳的选择。
analyzer和search_analyzer这里我分别配置了ik_max_word和ik_smart。ik_max_word做最细粒度拆分,“中华人民共和国”会被拆成“中华人民共和国”“中华人民”“中华”“华人”“人民共和国”“人民”“共和国”等多个词,适合索引阶段,提高召回率;ik_smart做粗粒度拆分,适合查询阶段,提高精准率。这是比较经典的组合方式。
3.3 mapping设计中的常见坑
第一,不要对keyword字段做全文检索。keyword不会分词,存储时是一个完整字符串,你拿一个长句子去match搜索,怎么也匹配不上,只有用term做精确匹配才会命中。
第二,text字段默认不能用于聚合、排序和脚本操作。ES在聚合时对分词后的text字段无能为力,会直接报Fielddata is disabled on text fields by default。你需要聚合的字段一定要用keyword类型,或者建一个keyword类型的子字段。
第三,动态映射是个双刃剑。ES默认开着动态映射,你插入一个索引里没有定义过的字段时,它会根据JSON值自动推断字段类型并写进mapping。这在开发期很省事,但在生产环境特别容易出问题。比如你第一次插入的age字段是个数字,ES建成了long,后来有个文档把age传成了字符串,写入直接报错。另一个更隐蔽的问题是,日志类数据字段特别多,动态映射会产生大量字段,导致mapping膨胀、内存飙升。所以生产环境我建议要么关掉dynamic,要么把dynamic设为strict只允许显式定义的字段写入,至少也要对关键索引做严格的mapping管控。
4. 数据插入:从单条到批量
4.1 单条插入的两种写法
索引建好了,接下来就是往里面塞数据。ES插入单条文档有两条路。
第一条是不指定ID,让ES自动生成随机ID,用POST请求:
POST /blog_article/_doc { "title": "Elasticsearch入门指南", "content": "本文介绍Elasticsearch的基础概念和使用方法……", "publish_time": "2024-06-01 10:30:00", "author_id": "a1024", "tags": ["Elasticsearch", "搜索"], "views": 321 }第二条是指定业务ID,用PUT请求或POST请求加在路径上:
PUT /blog_article/_doc/1001 { "title": "Elasticsearch入门指南", "content": "本文介绍Elasticsearch的基础概念和使用方法……", "publish_time": "2024-06-01 10:30:00", "author_id": "a1024", "tags": ["Elasticsearch", "搜索"], "views": 321 }两种写法怎么选?如果你有自己的数据库主键,强烈建议把它作为ES的_id。这样后续做增量同步时,用同样的ID重复写入不会产生重复文档,而是覆盖更新。同时文档路由也依赖_id,固定的ID会让数据分布更可控。让ES自动生成随机ID,更适合大量导入且没有业务主键的日志型数据,字符串ID比自增数字ID的分布更均匀,能避免热点分片。
插入成功后的返回结果大概是:
{ "_index": "blog_article", "_id": "1001", "_version": 1, "result": "created", "_shards": { "total": 2, "successful": 1, "failed": 0 } }注意看_shards,total是副本加主分片的总数,successful表示成功写入的分片数。这里total为2、successful为1,是因为我设置了1个副本,但单机环境下副本无法分配到其他节点,所以副本分片是未分配的(unassigned)状态,只有主分片成功写入。这在单机学习中很正常,无需紧张。但如果你在集群中看到successful长期小于total,就要排查副本是否分配失败了。
4.2 批量插入:bulk接口的使用姿势
单条插入方便理解,但生产环境往ES灌数据,肯定不能一条一条地发请求。每发一次HTTP请求都有网络开销,而ES写入单个文档的耗时其实非常短,真正慢的是请求传输。批量写入能把这些开销摊薄,写入吞吐量能差出几个数量级。
ES的批量接口是_bulk,请求格式比较特殊,要求每两行一组:一行是操作元信息,一行是文档数据。
POST /blog_article/_bulk {"index":{"_id":"1002"}} {"title":"ES批量写入实践","content":"bulk接口使用示例……","publish_time":"2024-06-02 09:00:00","author_id":"a1026","tags":["ES"],"views":120} {"index":{"_id":"1003"}} {"title":"搜索质量优化","content":"查询调优与排序策略……","publish_time":"2024-06-03 11:20:00","author_id":"a1024","tags":["搜索"],"views":88}这个格式如果手写很容易多一个逗号或者少一个换行,报错又不明显。实际项目中一般由客户端SDK在内存中拼装好NDJSON数据再一次性提交,日志采集工具比如Logstash、Filebeat也是用这种方式推送的。
有人会问,bulk一次到底该提交多少条?太多会占用过大内存,太少又起不到批量效果。常规经验值是单次bulk请求体控制在5MB到15MB之间,文档数在1000到5000条左右。具体最优值得靠压测去找,你可以用curl反复调整这个参数,观察ES监控面板里写入延迟和拒绝率的变化。
除了index操作,bulk里还能混用create、update、delete。区别在于create在文档已存在时会报版本冲突,index则直接覆盖,适合幂等写入的场景。
4.3 写入后为什么可能查不到?refresh机制详解
很多初学者第一次往ES写数据,紧接着就去搜索刚才的内容,结果搜不到,第一反应就是出bug了。其实这是ES的refresh机制在起作用。
ES写入数据时,文档不会立刻落盘到不可变的磁盘段(segment)中,而是先进入内存缓冲区和translog日志。refresh操作会把内存缓冲区的数据生成一个新的segment,让这部分数据变得可搜索。默认的refresh_interval是1秒,也就是说写入成功后,最多等1秒就能搜到。
你可以在搜索请求上加refresh=true参数强制先刷新再查询:
POST /blog_article/_doc/1004?refresh=true { "title": "强制刷新测试", "content": "这个文档写入后立即可见", "publish_time": "2024-06-04 00:00:00", "author_id": "a1030", "tags": ["测试"], "views": 1 }但请注意,refresh=true并不适合生产环境高频写入时使用。每次写入都立即刷新会让内存中的小segment数量暴涨,触发频繁的segment合并,写性能和磁盘IO都会很难看。常规做法是:实时性要求高的业务,保持默认的1秒刷新;后台周期性批量导数据的场景,反而可以把refresh_interval调大,甚至导入期间临时设为-1关闭刷新,等数据全部导入完再恢复刷新,这样能大幅加快写入速度。
批量导入期间关闭刷新后,内存缓冲区的数据不会被刷成可搜索段,但也不会丢,数据在translog里有完整记录。导入完成后再执行一次POST /blog_article/_refresh强制刷新,所有数据就能被搜到了。
4.4 更新与删除文档的原理
更新和删除看着是两类操作,底层其实是同一个机制。ES的segment是不可变的,所以不存在“改某条数据”这种物理操作。更新一个文档,真实发生的事情是:新版本文档先写入内存缓冲区,旧版本文档不会被立刻物理删除,而是被打上一个删除标记,等后台segment合并时才会真正清理。
POST /blog_article/_update/1001 { "doc": { "views": 999 } }上面这个请求会做一次部分字段更新,只修改views字段,其他字段不动。如果字段不存在就新增,如果文档本身不存在会报document_missing_exception。如果想在文档不存在时自动创建,可以在请求体里把doc_as_upsert设为true。
删除就更直接了:
DELETE /blog_article/_doc/1001删除后你会看到result字段变成deleted。如果删除一个不存在的文档,result会是not_found,但HTTP状态码依然返回200,这点和MySQL里DELETE影响行数为0不同,ES不会把“没删到”当作错误处理,代码判断时要留意。
因为更新删除都是标记位机制,频繁更新删除后再大量写入,会导致磁盘上存在很多“虚”数据,查询时会扫描到这些带删除标记的文档再过滤掉,影响查询性能。常规维护手段是定期执行POST /blog_article/_forcemerge强制合并,把segment里的删除标记彻底清掉。
5. 用一次搜索验证索引和数据
5.1 最常用的search请求示例
数据插进去之后,最直接的事就是搜一下。看最典型的match查询:
POST /blog_article/_search { "query": { "match": { "title": "Elasticsearch" } } }返回结果的hits.total会告诉你命中了多少文档,hits.hits里是命中的文档列表,每个文档都带着_score相关度分数。相关度分数是ES按照词频、逆文档频率等算法算出来的,默认按分数倒序排列。
另一个常用查询是term,它不会对搜索词做分词,而是拿整个词去倒排索引里精确匹配。所以term查询适合keyword字段,match查询适合text字段。很多人一开始搞混这两个,记住一条经验:查keyword用term,查text用match,基本不会出错。
5.2 查看索引的mapping、settings和健康状态
有时候你需要确认之前建的索引结构,或者排查为什么某个字段行为不对。几个最常用的只读接口要记住。
查看索引字段定义:
GET /blog_article/_mapping查看索引配置:
GET /blog_article/_settings一次性查看集群里所有索引的健康状态、分片数和文档数,可以用cat接口:
GET /_cat/indices?v返回结果里会包含health列,可能是green、yellow或red。单机环境下因为你只跑了1个ES节点,副本分片没有可用的第二个节点来分配,所以状态通常是yellow,这不影响正常读写。但你需要在心里清楚,yellow意味着副本处于不完整状态,此时如果主分片所在节点宕机,数据就存在丢失风险。想要真正达到green状态,至少需要起两个ES节点,让副本分片分配到别的机器上。
另外查看所有分片落在哪些节点、为什么未分配,可以用:
GET /_cat/shards?v每行会显示索引名、分片编号、是主分片还是副本、落在哪个节点、存储了多少文档。
6. 常见问题与排查技巧实录
6.1 集群状态变红,索引显示不可读写
集群变红表示有主分片缺失,这意味着部分数据彻底不可用了。最普遍的原因是节点重启后,磁盘空间不足,分片无法重新分配。排查顺序建议这样走:
第一步GET /_cat/indices?v看哪个索引是red;第二步GET /_cat/shards?v看缺失的是哪个分片;第三步GET /_cluster/allocation/explain?pretty,这个接口会直接用大白话告诉你为什么分片分配不上去,比如the node is above the disk water mark之类的提示。
如果是磁盘水位问题导致的变红,处理和防范策略有这么几条:及时清理数据或扩盘,调大节点磁盘水位阈值(不推荐,属于临时手段),同时给索引配置只读阈值以内的数据生命周期策略。还有一类比较隐蔽的情况,同一集群里有不同版本的ES节点,老节点无法读取新节点写入的segment格式,分配也会失败,这种情况需要统一集群版本。
6.2 连接拒绝:9200端口通不通
ES的HTTP服务默认监听9200端口,节点间通信走9300端口。你在浏览器访问9200觉得没问题,但Java客户端或者应用容器连不上,优先确认几件事:ES所在机器的防火墙是否放行了9200;ES的network.host是否还是localhost(只有本机能连,外部服务器当然连不上);客户端和ES版本是否匹配,Java客户端Maven坐标的版本必须和ES服务端主版本一致,比如服务端8.15,客户端必须用8.15.x,用7.x连8.x会直接报版本不兼容异常。
针对自身学习场景,如果不想管太多网络安全配置,可以保持network.host: 127.0.0.1,只在本机curl访问即可。想让自己项目远程调试时再改,同时配上ES自带的认证模块或者单独限制访问IP。
6.3 写入性能明明不高,到底卡在哪
ES写入慢,最常见的原因第一个是refresh太频繁,第二个是段合并太频繁,第三个是bulk批次太小,第四个是磁盘性能不够。如果你的写入目标量级是每秒几万条,那么:
- 把
index.refresh_interval调成10s到30s,甚至大批量导入时暂时设成-1 - 一次bulk的条数调大到几千条,把请求体撑到10MB左右
- 观察监控中的段合并指标,如果
merges线程长期繁忙,适当调大index.merge.scheduler.max_thread_count,机械硬盘建议调低到1避免IO饱和,SSD可以调高到4 - 日志型数据写入前设置
index.number_of_replicas: 0,等数据写完后再把副本调回来
整套组合拳打下来,写入吞吐量翻几倍很正常。
6.4 text字段聚合报错,怎么办
直接抄一个常见报错场景,你写了这样的请求:
POST /blog_article/_search { "size": 0, "aggs": { "by_tags": { "terms": { "field": "tags" } } } }如果tags被定义成了text类型,聚合时会报错Fielddata is disabled on text fields by default。原因前面讲过,text字段是分词后的词条数组,拿它做terms聚合得到的是拆分后的词频,不是原始字段的完整值。
解决方案有三个:一是改mapping字段类型为keyword;二是保留text主字段的同时增加一个keyword子字段,比如"tags": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },聚合时用tags.keyword;三是对已有text字段动态开启fielddata,但这个是官方明确不建议的,会大量占用堆内存,属于临时救火方案。
6.5 分片数设置不合理,索引卡死
最后提醒一个比较典型的新手陷阱——把分片数设得过多。比如数据量比较小的业务索引,上来就设了30个分片,每个分片只有几百MB数据。最终表现是集群里到处是分片,读写慢、内存紧张、集群状态不稳定。
分片数量规划的经验公式可以这样估算:一个分片容量极限大约50GB,但达到30GB后查询性能就开始下降。目标分片数 = 预计总数据量 / 30GB,再乘以1.5左右的冗余系数。分片副本数默认1即可。如果规划后发现分片太少或太多,因为创建后不能改分片数,常用的解决方案是新建一个分片数合理的新索引,用reindex接口把数据迁移过去,再把索引别名切换到新索引上,对业务做到平滑替换。
提示:索引别名是ES里非常实用的功能,通过别名读写索引可以做到零停机切换。平时操作索引先用别名,生产环境维护时就方便多了。
写在后面的一点点建议
这套流程走下来,你其实已经有能力自己搭一套基础搜索服务了:建索引、写数据、搜数据,以及处理常见的读写问题。ES的入门曲线确实陡,但最难的永远是前面这段概念混淆期。我的建议是不要急着记语法,先把索引、映射、分片、倒排索引这几个底层概念吃透,后面学查询DSL和集群运维都会顺很多。
我个人在实际操作中还有一个体会:学习ES一定要搭配Kibana一起用。它自带的Dev Tools能边敲边看响应,还能自动补全,比在终端里反复拼curl高效很多。另外新建索引的时候,哪怕只是测试,也养成写mapping的习惯,别完全依赖动态映射。等数据多了再想回头改字段类型,代价可不是一点半点。