站内搜索是很多个人站长做流量时最容易忽略的一环。文章页做足了 SEO,站内搜索却是「LIKE '%关键词%'」一把梭:数据量小的时候一切正常,等到 content 表过了十万行、访客每天几千次搜索请求,页面开始出现两三秒的白屏,MySQL 的 CPU 曲线跟着搜索框的输入节奏一起抖。这篇文章讲的是把站内搜索换成 Elasticsearch 之后真正会遇到的几件事:中文分词怎么选、索引怎么设计才不会被一次批量导入拖垮、以及在一台 2 核 4G 的小机器上怎样把内存控制住。
一、先判断:你的站内搜索到底该不该换
不是所有站都要上 Elasticsearch。它是 Java 写的,一个空索引就要占几百 MB 内存,运维成本远超一个 MySQL 查询。判断标准可以按下面三条来对:
- 数据量:被搜索的业务表行数在 10 万以下,且字段不多,MySQL 的
LIKE加合适的索引基本够用,别折腾。 - 查询形态:只要「标题包含某词」这种前缀/包含匹配,MySQL 能扛。一旦需要「多字段混合打分」「按相关性排序」「同义词」「拼音搜索」「高亮片段」,SQL 就开始变得又长又慢,这才是换的时机。
- 写入压力:ES 的写入是近实时的(默认 1 秒 refresh),批量重建索引会吃掉大量磁盘 IO。如果站点跑在小盘 VPS 上,要先确认剩余空间够放一份完整索引。
反过来说,如果你的痛点是「搜索慢」,先别急着换组件。给 title 建前缀索引、把 LIKE '%x%' 改成 MySQL 8 的全文索引(ngram 解析器),成本是改几条 SQL,能解决七八成的慢查询。Elasticsearch 应该在「功能不够」时才上,而不是「性能不够」时第一个想到的。
二、中文分词:默认分词器几乎一定不能用
ES 自带的 standard 分词器对中文是按「单字」切分的:「服务器性能调优」会被切成「服」「务」「器」「性」「能」「调」「优」七个词。后果不是搜不到,而是搜得太滥——搜「服务」会把所有带「服」或「务」的文档全召回,相关性排序直接失效。
常见三条路,按落地难度从低到高:
# 方案 A:内置 CJK 分词器(零插件,够用)
"analyzer": {
"my_cjk": {
"type": "custom",
"tokenizer": "standard",
"filter": ["cjk_width", "lowercase", "cjk_bigram"]
}
}
# cjk_bigram 会把相邻两字组成二元组:
# 服务器性能 → 服务/务器/器性/性能cjk_bigram 的效果对中文站点来说已经相当可用,检索时同样走 bigram,匹配质量和召回率都不错,缺点是不理解词义(「计算机」和「电脑」不会互相同义,索引体积也比按词切大一些)。如果你的语料以中文短标题为主,这是最省事的选项。
方案 B 是装 analysis-ik 插件,按词典做真正的词切分,配合自定义词典把自己站点的专有名词(产品名、栏目名、人名)加进去。IK 的问题是版本必须和 ES 严格对应,升级 ES 要同步换插件包,而 IK 的更新往往滞后几个版本,这是长期维护的隐性成本。
方案 C 是把分词放到应用层做:用 jieba 之类的库切好词,写入时以 analyzer: keyword 加空格分隔的形态存进去。好处是分词逻辑完全可控、可以随业务随时调整词表;坏处是索引重建要跑一遍应用代码,而且要自己处理同义词和停用词。
不管选哪条路,有一条铁律:索引时的分词器和检索时的分词器必须一致。用 IK 建索引、查询时忘了指定 analyzer 走了 standard,结果就是明明有这篇文章却搜不出来,而且日志里没有任何报错,非常难排查。
三、索引设计:别把整篇文章一次性塞进去
新手最常见的错误是把数据库整行数据复制进索引,包括正文 HTML。这会导致两个问题:一是索引体积膨胀十倍以上;二是高亮结果里带着一堆 <p> <div> 标签,还得再做一次 HTML 清理。
更合理的做法是把「可检索字段」和「展示字段」分开:
PUT /articles
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "30s"
},
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "my_cjk", "boost": 3 },
"tags": { "type": "text", "analyzer": "my_cjk", "boost": 2 },
"excerpt": { "type": "text", "analyzer": "my_cjk" },
"body_text": { "type": "text", "analyzer": "my_cjk" },
"cid": { "type": "integer" },
"category": { "type": "keyword" },
"created": { "type": "date" }
}
}
}几个关键点。第一,number_of_shards 在单机小站上填 1 就够了,分片数建好之后不能改(只能 reindex),很多人照着教程填 5 分片,结果每个分片都有一份固定开销,白白吃掉几百 MB 堆内存。第二,refresh_interval 从默认的 1s 调成 30s,批量导入时再临时调到 -1,导入完再恢复,写入吞吐能提升好几倍。第三,body_text 存的应该是剥掉标签后的纯文本,用 PHP 的 strip_tags 加 html_entity_decode 处理即可,千万别把整段 HTML 塞进去。
字段上的 boost 只在 multi_match 里不显式指定字段权重时生效,更稳的写法是在查询里写 title^3 tags^2 excerpt。两种都行,但不要同时依赖——排错时你会发现不知道到底哪一层的权重起了作用。
四、把老数据导进去:批量接口与背压
一次性用循环单条写入几十万文档,是最容易把集群写崩的方式。正确姿势是用 _bulk 接口,每批 500 到 1000 条,批与批之间检查响应里的 errors 字段:
# 每行一条 action + 一条 source,注意最后必须有换行
{"index":{"_index":"articles","_id":"1726"}}
{"title":"...","body_text":"..."}
{"index":{"_index":"articles","_id":"1727"}}
{"title":"...","body_text":"..."}三种典型失败要分开处理。429 Too Many Requests 表示写入队列满了,说明你推得太快,应该指数退避重试(1s、2s、4s……上限 30s),而不是加大并发。400 mapper_parsing_exception 表示字段类型不匹配,比如把空字符串写进日期字段,这是数据问题,重试一百次也不会成功,要修数据。413 是单批太大,把批大小降到 200 再试。
批量导入期间还有个容易忽略的细节:先把副本数设成 0,导入完再设回 1。单机部署本来就没有副本,但如果有多节点,边写边复制会让整个导入慢一倍以上。
如果站点数据在 MySQL 里,可以用 Logstash 的 JDBC input 做增量同步,靠 last_value 记录上次的最大 cid 或更新时间。但要注意 Typecho 这类系统的文章修改不会更新一个专门的 modified 时间戳,靠时间字段做增量会漏掉「老文章被改标题」的情况——比较省心的做法是夜间跑一次全量重建到新索引,再用 alias 原子切换,这样既不用处理增量逻辑,也不会出现「改过的文章搜不到新词」。
五、2 核 4G 机器上的内存怎么分
单机部署 ES 时,最大的坑是 JVM 堆给得太大或太小。堆超过物理内存的 50% 会导致 Lucene 没法用操作系统的文件缓存,反而更慢;堆太小则频繁 GC,查询延迟抖动。
一条经验公式:堆大小 = min(物理内存的一半, 31GB),并且 Xms 和 Xmx 必须相等,避免运行时反复申请内存。
# /etc/elasticsearch/jvm.options.d/heap.options
-Xms1g
-Xmx1g
# 同时把内存锁住,避免被换出
# /etc/elasticsearch/elasticsearch.yml
bootstrap.memory_lock: true开启 memory_lock 之后必须在 systemd 里放开 LimitMEMLOCK=infinity,否则 ES 启动会直接失败并提示「Unable to lock JVM Memory」。这一点在 Debian 上尤其容易踩,因为默认的 unit 文件里没有这一行。
4G 内存的机器上,1G 堆给 ES 之后,剩下 3G 留给文件缓存、MySQL 和 PHP-FPM。这时候要顺手确认 MySQL 的 innodb_buffer_pool_size 没有被之前的调优撑得太大,否则两边抢内存,最后被 OOM killer 挑一个杀掉——而 OOM killer 通常会选占用最大的那个,往往就是 ES。看到 ES 无缘无故挂掉,先查 dmesg | grep -i oom,别急着怀疑磁盘。
六、查询侧:把耗时和结果条数都管起来
站内搜索框是公开接口,任何一个爬虫都能在一分钟里发几千次请求。如果每次查询都允许返回 100 条、允许深度分页,集群很快就会被拖垮。上线前至少做四件事:
- 限制
size上限(比如 20),并且禁用from + size深分页,超过 200 条的结果改用search_after。深度分页会让每个分片都取出from+size条再合并,成本随页码线性上升。 - 查询前对输入做长度截断(比如最多 40 个字符),超长输入会生成巨大的布尔查询。
- 加
timeout(如500ms),保证单个慢查询不会拖住整个线程池。 - 在应用层加限流,按 IP 每分钟 N 次,并且对返回空结果的请求单独计一份额度——这类请求往往来自扫描器。
还有一个小技巧:如果站内搜索只需要「标题 + 摘要」的匹配,用 _source 过滤只取需要的字段,响应体积能小一半,对带宽紧张的 VPS 很有意义。查询里写 "_source": ["title","cid","created"] 即可,高亮需要的字段要单独列进 highlight.fields,否则会拿不到片段。
七、上线顺序与回滚
换搜索引擎是有风险的操作,别一步到位。建议按这个顺序:先把 ES 装好、索引建好、数据导进去,用一个小脚本对比「同一条关键词在 MySQL 和 ES 下的前 10 条结果」,差异大就调权重;然后在应用里加一个开关,让 10% 的搜索走 ES,观察响应时间和错误率;稳定一两天后全量切换;MySQL 的旧搜索代码先留着,别删,出问题时能一键切回去。
回滚的关键是「别删数据」。旧索引不要立刻删除,先 POST /articles/_close,确认新链路稳定一周后再清理。同理,应用层的开关要能通过配置文件热切换,而不是改代码重新部署——真正出故障的时候,能少一步就少一步。
八、小结
站内搜索从 SQL 换到 Elasticsearch,难点从来不是「装不上」,而是分词一致性、索引体积、内存分配和公开接口的防护这四件事。把这四条想清楚,一台 2 核 4G 的小机器跑几万篇文档的搜索完全够用;想不清楚,给 16G 内存也一样会出现「搜不到」和「时快时慢」。对个人站长来说,能自己看得懂、改得动、出事能回滚的架构,比参数调得多漂亮更重要。