大家想象一下,你正美滋滋地喝着咖啡,想着这个月的KPI稳了——MySQL跑得比老黄牛还稳,订单、用户、商品三张表恩爱如初,JOIN起来比老夫妻还默契。
突然,产品经理从工位弹起来,像被椅子烫了屁股:
“我们要做智能搜索!要像淘宝那样,搜‘男士薄款T恤’出来全是帅小伙穿的那种!”
后端Leader扶了扶眼镜,云淡风轻地补刀:
“那就把搜索迁到ES上吧。”
你一口咖啡差点喷在显示器上。
啥?MySQL不是用得好好的吗?这玩意儿不是能用到我退休吗?
难道ES是技术圈的顶流网红,大家都跟风?还是说我太久没刷掘金,已经变成技术化石了?
别急,今天咱们不聊焦虑,只聊干货。我用我那头日渐稀疏的头发担保——
不是MySQL不香了,而是有些活儿,它真的干不动了。
一、先吹一波MySQL
你楼下那家便利店,24小时亮着灯,老板永远在你忘带钥匙时借你一把。MySQL就是数据库界的这家店。
1.1 事务?它从不出轨
做电商扣库存、扣余额、生订单,三步必须“全成”或“全撤”。MySQL的ACID特性,翻译成人话就是:
要么一起结账,要么全给我放回购物车。
我之前见过有人用非事务数据库做订单,宕机后冒出来几百个“幽灵订单”——库存扣了,订单没了,用户骂娘,技术员熬了三个通宵改BUG,头发从三七分改成地中海。
MySQL要是会说话,肯定会说:早用我,至于吗?
1.2 SQL跟普通话一样普及
你刚入行时,第一句SQL是不是这个:
SELECT * FROM user WHERE id = 1;
就像学编程先学Hello World。MySQL的SQL语法,你从阿里跳到腾讯,从电商跳到教育,基本不用重新学。
实习生来了,丢给他两张表,JOIN一下,半天就能出活。
这种低门槛的友好,是MySQL刻在骨子里的温柔。
1.3 出问题了?全网都是你师傅
主从复制、MGR、mysqldump、xtrabackup、Prometheus + Grafana……MySQL的生态,比你老家亲戚还齐全。
上次我遇到主从延迟,发了个朋友圈,还没等我查文档,评论区已经甩过来三篇博客、两个视频、一个付费咨询名片。
这种“全村帮你修数据库”的安全感,除了MySQL,没谁了。
二、MySQL的三块软肋
可社区便利店再好,它也卖不了波音737啊。业务一复杂,MySQL就开始喘了。
2.1 全文检索:LIKE慢成狗,匹配蠢如猪
用户搜
“薄款 纯棉 男士 T恤”,你用MySQL怎么写?
// Java + MySQL:噩梦版全文检索
String sql = "SELECT * FROM goods " +
"WHERE name LIKE '%薄款%' " +
"AND name LIKE '%纯棉%' " +
"AND name LIKE '%男士T恤%'";
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql);
这玩意儿有两个死穴:
第一,慢到用户切App。
%薄款% —— 百分号打头,索引直接下班,全表扫描上班。100万商品?恭喜,3秒后见。用户等3秒,隔壁拼多多已经下单成功了。
第二,匹配逻辑比直男还直。
用户搜“薄款T恤男士”和“男士薄款T恤”,MySQL认为是两个世界的人。而且它只会“包含匹配”,不会“按相关性排序”。
有的商品标题是“2024新款 薄款纯棉男士T恤”,完美命中;有的商品是“男士外套 送薄款T恤”,蹭流量的。MySQL一视同仁,全甩你脸上,用户翻三页都找不到想要的。
有人抬杠:“我用MySQL全文索引了啊!”
兄弟,MySQL全文索引就是个“我能跑,但别让我跑马拉松”的水平:
我见过一个团队,为了用MySQL做搜索,自己写分词脚本、加冗余字段、搞定时任务,维护成本比直接上ES还高,最后全员跑路,新来的接手直接提桶。
2.2 复杂聚合:查一次数据,够泡一杯手冲咖啡
产品经理又来了:
“我要看过去7天销售数据,按省份分组,显示下单用户数、金额、客单价、退款率,按客单价排序,还能钻取到城市。”
你咽了咽口水,写了个SQL:
// Java + MySQL:聚合查询,咖啡凉透版
String sql = "SELECT province, " +
"COUNT(DISTINCT user_id) AS user_count, " +
"SUM(order_amount) AS total_amount, " " +
"SUM(order_amount)/COUNT(DISTINCT user_id) AS avg_price " +
"FROM order " +
"WHERE create_time BETWEEN ? AND ? " +
"GROUP BY province ORDER BY avg_price DESC";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setDate(1, startDate);
ps.setDate(2, endDate);
ResultSet rs = ps.executeQuery(); // 此处可去泡杯咖啡
10万订单:秒出。1亿订单:8分钟。
产品经理端着咖啡回来,屏幕还在转圈。他看了你一眼,那眼神仿佛在说:“你管这叫‘实时报表’?”
为什么慢?
COUNT(DISTINCT user_id):去重计数,MySQL要把所有user_id读出来塞进哈希表,1亿条就炸- JOIN + GROUP BY:临时表、文件排序,内存不够就写磁盘,速度掉到机械硬盘水平
- 不支持预计算:每次都是现场算,把CPU和IO干到100%,订单写入都受影响
有人用分库分表,8分钟变2分钟——产品经理还是不满意。
有人用缓存,每分钟重算——缓存key比微博热搜还多,还老不一致。
记得以前有个小伙伴待过一家公司,专门写定时任务凌晨跑统计,结果有一天任务卡死,第二天老板看着错误数据以为业绩崩了,差点开全员危机大会。
全公司陪MySQL加班,就为了一个GROUP BY。
2.3 海量数据:MySQL存储贵、查询慢、扩容难
日志场景,每天几亿条接口调用、用户行为、异常堆栈。用MySQL存?
第一,存储成本高到财务找你喝茶。
每条日志几KB,几亿条就是几十TB。我前客户用MySQL存日志,3个月180亿条,分了100多张表,占了100TB,每年存储费够买一辆Model 3。
第二,查询慢到运维泡面都坨了。
查“昨天15:00-17:00,/api/pay耗时>1秒的日志”:
String sql = "SELECT * FROM api_log " +
"WHERE api_path = '/api/pay' " +
"AND response_time > 1000 " +
"AND create_time BETWEEN ? AND ?";
MySQL扫描上千万行,索引选择性差,15分钟后出结果。
运维小哥每次查日志前,先泡杯咖啡,然后去上个厕所,回来再刷会儿抖音——结果还没出来。
第三,扩容比离婚分财产还麻烦。
分库分表一旦写死,加节点要改配置、迁数据、停服务。业务涨一波,DDL跑一夜,心惊胆战生怕主键冲突。
三、ES来救场:你累了,换我扛一会儿
这时候Elasticsearch(ES)登场了。
它不是啥黑魔法,就是个为查询而生的偏科战神。跟MySQL比,它像图书馆和文件柜的区别:
- ES:图书馆,不仅目录全,还把每本书拆成关键词,你输入“Java并发编程”,它马上告诉你哪本书第几页提到过
3.1 全文检索:ES把搜索做成了“即开即热”
还是搜 “薄款 纯棉 男士 T恤”。
第一步,智能分词。
ES + IK分词器,能把句子拆成“薄款”“纯棉”“男士”“T恤”,还能配同义词(T恤=汗衫=短袖)、纠错(纯绵→纯棉)。
用户搜“男士 汗衫 薄款”,照样精准命中。
第二步,倒排索引,快到像作弊。
ES会建一张“关键词→商品ID”的映射表。搜关键词时,它直接取交集,然后算相关性得分。
商品A标题全命中,得分1.0;商品B只命中三个词,得分0.8;商品C蹭流量,得分0.1。
用户第一眼看到的,永远是他最想买的。
第三步,Java操作ES,优雅得像写诗。
// Java + ES High Level REST Client:全文检索真香版
import org.elasticsearch.index.query.QueryBuilders;
import org.elasticsearch.search.builder.SearchSourceBuilder;
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("goods_name", "薄款 纯棉 男士 T恤"))
.filter(QueryBuilders.rangeQuery("price").gte(100).lte(200))
);
sourceBuilder.from(0);
sourceBuilder.size(20);
sourceBuilder.fetchSource(new String[]{"id", "goods_name", "price"},
null);
SearchRequest searchRequest = new SearchRequest("goods_index");
searchRequest.source(sourceBuilder);
SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
这段代码:
fetchSource
:只返回需要的字段,省带宽
响应时间:0.3秒。
我帮朋友把电商搜索从MySQL迁到ES后,**搜索转化率涨了20%**,产品经理再也不拍桌子了,改拍我肩膀。
3.2 复杂聚合:ES用分布式算力碾压
同样那个“7天销售数据,按省份分组,还要客单价排序、钻取城市”的需求,ES怎么写?
// Java + ES:聚合查询,咖啡还没冒完气就出结果了
import org.elasticsearch.search.aggregations.AggregationBuilders;
import org.elasticsearch.search.aggregations.bucket.terms.TermsAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.CardinalityAggregationBuilder;
import org.elasticsearch.search.aggregations.metrics.SumAggregationBuilder;
import org.elasticsearch.search.aggregations.pipeline.BucketScriptPipelineAggregationBuilder;
import org.elasticsearch.search.aggregations.pipeline.BucketSortPipelineAggregationBuilder;
import org.elasticsearch.search.sort.SortOrder;
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.rangeQuery("create_time")
.gte("2024-01-01").lte("2024-01-07"));
sourceBuilder.size(0); // 不返回文档,只返回聚合结果
// 省份分组
TermsAggregationBuilder provinceAgg = AggregationBuilders.terms("province_agg")
.field("province.keyword")
.size(20);
// 用户数去重
CardinalityAggregationBuilder userCountAgg = AggregationBuilders
.cardinality("user_count").field("user_id");
provinceAgg.subAggregation(userCountAgg);
// 下单金额求和
SumAggregationBuilder totalAmountAgg = AggregationBuilders
.sum("total_amount").field("order_amount");
provinceAgg.subAggregation(totalAmountAgg);
// 客单价:金额/用户数(bucket script)
Map bucketsPath = new HashMap<>();
bucketsPath.put("total", "total_amount");
bucketsPath.put("users", "user_count");
BucketScriptPipelineAggregationBuilder avgPriceAgg =
AggregationBuilders.bucketScript("avg_price", bucketsPath, "params.total / params.users");
provinceAgg.subAggregation(avgPriceAgg);
// 按客单价排序
BucketSortPipelineAggregationBuilder sortAgg =
AggregationBuilders.bucketSort("sort_agg",
List.of(new FieldSortBuilder("avg_price").order(SortOrder.DESC)));
provinceAgg.subAggregation(sortAgg);
sourceBuilder.aggregation(provinceAgg);
SearchResponse response = client.search(
new SearchRequest("order_index").source(sourceBuilder),
RequestOptions.DEFAULT
);
这个查询在1亿条订单的ES集群上,跑完只要300毫秒。
比MySQL快1600倍。
为什么?
- 列存储+聚合索引:ES把数值字段存成适合计算的格式,读聚合直接读索引,不用扫原始数据
- 分布式计算:数据分30个分片,每个分片算自己的,主节点汇总,并行效率拉满
- 实时写入实时查:用户刚下单,报表就+1,不用等定时任务
自从换了ES,产品经理自己用Kibana拖dashboard,每天早上发数据日报到老板群,团队还拿了个“数据驱动奖”。
3.3 海量日志:ES存得起、查得快、扩得动
还是那个每天2亿条日志的客户,从MySQL迁到ES后:
存储:100TB → 55TB,ES压缩比惊人,相同字段值只存一次
查询:15分钟 → 10秒,分布式并行+分片剪枝
扩容:从10节点到20节点,改个配置,ES自动rebalance,业务零感知
而且ES支持冷热分离:
-
冷数据(30天+)可自动归档到对象存储,查的时候再加载
运维小哥现在查日志前,咖啡还是那杯咖啡,但结果秒出,他终于有时间看完一部电影了。
四、说清楚:ES不是来取代MySQL的,是来帮它的
看到这儿,你可能会问:
“那我以后是不是所有数据都扔ES?”
千万别。 ES也有短板——它不!支!持!事!务!
你用ES存订单,宕机后订单表少了一半,另一半扣了库存没生成订单,用户投诉电话能把客服打哭。
正确姿势是:让MySQL和ES搞“开放式关系”。
那数据怎么同步?
Java + Canal监听binlog,实时推ES:
// Canal Client伪代码:监听MySQL binlog -> 实时同步ES
public void processCanalEntry(CanalEntry.Entry entry) {
if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
// 将MySQL的行数据转换成ES文档
Map esDoc = new HashMap<>();
for (CanalEntry.Column column : rowData.getAfterColumnsList()) {
esDoc.put(column.getName(), column.getValue());
}
// 写入ES
IndexRequest request = new IndexRequest("goods_index")
.id(esDoc.get("id").toString())
.source(esDoc);
restHighLevelClient.index(request, RequestOptions.DEFAULT);
}
}
}
这套组合拳,我见过的电商公司、游戏公司、SaaS平台,几乎都是这么用的。
五、实战三连:从MySQL到ES,怎么少踩坑?
光知道“为啥换”不够,还得知道“怎么换”。这三点你收好,都是真金白银换来的教训。
5.1 索引设计:这三个坑,每个我都掉过
坑1:把所有字段都设成text
text类型会分词,你按“province”分组,结果出来“北京”“京”“市”三个bucket。
正确姿势:
// ES索引映射 - Java客户端构建
import org.elasticsearch.xcontent.XContentBuilder;
import
org.elasticsearch.xcontent.XContentFactory;
XContentBuilder mapping = XContentFactory.jsonBuilder()
.startObject()
.startObject("properties")
.startObject("goods_name")
.field("type", "text")
.field("analyzer", "ik_max_word")
.endObject()
.startObject("province")
.field("type", "keyword")
// 分组用keyword
.endObject()
.startObject("price")
.field("type", "float") // 数值计算用float/double
.endObject()
.startObject("create_time")
.field("type", "date")
.field("format", "yyyy-MM-dd HH:mm:ss")
.endObject()
.endObject()
.endObject();
CreateIndexRequest request = new CreateIndexRequest("goods_index");
request.mapping(mapping);
client.indices().create(request, RequestOptions.DEFAULT);
坑2:分片数拍脑袋
分片不是越多越好。每个分片20-50GB是黄金尺寸。
100GB数据,设3-5个分片就够了。不确定就先按 分片数 = 节点数 × 2 设,后续可调,但调分片要重建索引,所以一次想好。
坑3:直接查索引名,不用别名
别写死goods_202401。用别名goods_current,查询时指向别名。
// Java:切换索引别名,业务无感
AliasActions actions = new AliasActions()
.add(new AliasActions.Action(
AliasActions.Type.REMOVE,
"goods_202401",
"goods_current"))
.add(new AliasActions.Action(
AliasActions.Type.ADD,
"goods_202402",
"goods_current"));
IndicesAliasesRequest request = new IndicesAliasesRequest();
request.addAliasAction(actions.toArray(new AliasActions.Action[0]));
client.indices().updateAliases(request, RequestOptions.DEFAULT);
这样,你夜里偷偷建好新索引,第二天上班前把别名切过去,产品经理还在睡觉,你已经完成了一次无缝迁移。
2. 查询优化:让ES再快一倍
技巧1:过滤用filter,别用must
filter不计算相关性,结果可缓存,速度翻倍。
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("goods_name", "薄款 T恤"
))
.filter(QueryBuilders.termQuery("province", "北京市")) // 能缓存的过滤条件
);
技巧2:别用wildcard前缀模糊,用prefix
*T恤这种查询,ES要扫全表。改成前缀查询:
// 不要这样
QueryBuilders.wildcardQuery("goods_name", "*T恤");
// 要这样
QueryBuilders.prefixQuery("goods_name", "T恤");
如果必须后缀匹配(查abc*易,查*abc难),建一个倒序字段存恤T,然后前缀查。
技巧3:只捞你要的字段
不要用"_source": true,指定字段:
sourceBuilder.fetchSource(
new String[]{"id", "goods_name", "price"}, // 包含
null
);
搜索结果里少传几十个字段,网络开销立减。
六、总结
回到开头那个问题:
MySQL用着挺省心,为啥突然都要换成ES?
因为业务长大了。
MySQL就像你刚毕业买的那辆五菱宏光,能拉货、能载人、皮实耐造、修车便宜。你开着它创业、送货、跑业务,风里来雨里去,从没把你扔路上。
可有一天,你业务做大了,客户要冷链运输,要跨国配送,要实时追踪。你看着那辆五菱,它还是那么可靠,可它真的干不了这些。
你不是要把它卖掉,你只是再添一辆冷链车。
让MySQL继续守着它最擅长的订单、用户、事务。让ES去扛搜索、日志、报表。
各司其职,才是架构师的浪漫。
最后送你一句我这几年悟出来的话:
不要对技术动感情,要对问题动脑筋。
MySQL很好,ES也很香。但最好的技术,永远是那个能让今晚的你按时下班的技术。
咖啡喝完了,头发也掉了几根,该去写代码了。希望下次技术评审会,你也能自信地说:“这个需求,MySQL扛不住,但我们有ES。