1. 为什么MySQL单独使用会遇到瓶颈?
我做了十年数据库架构,见过太多团队在MySQL单表数据量突破千万级后陷入性能泥潭。这就像让一辆家用轿车去跑拉力赛——不是不能跑,但迟早会散架。
1.1 数据量天花板问题
MySQL单表数据量建议控制在千万级以内,这不是MySQL的缺陷,而是B+树索引结构的特性决定的。当单表数据超过2000万行时,索引高度会增加到4层,随机IO次数暴增。我去年优化过一个电商平台的商品表,单表1.2亿数据时查询延迟从20ms飙升到800ms。
解决方案通常有三种:
- 水平分表:按ID范围或哈希值拆分
- 垂直分表:将大字段分离到扩展表
- 归档历史数据(但业务查询会变复杂)
1.2 全文检索的先天不足
MySQL的ngram全文索引就像用瑞士军刀砍树——它能工作,但你会怀念电锯。我测试过在500万文章的表中:
- LIKE '%关键词%' 需要12秒
- ngram全文索引需要1.8秒
- 相同数据在ES中仅需23毫秒
更致命的是,ngram不支持:
- 同义词扩展(搜索"手机"找不到"智能手机")
- 词干提取(搜索"running"找不到"run")
- 相关性评分(无法按匹配度排序)
1.3 组合索引的排列组合灾难
假设有个商品表需要按"品牌+价格+配置"查询,DBA通常会创建(brand, price, config)的联合索引。但遇到以下查询就失效了:
sql复制SELECT * FROM products
WHERE config='高配' AND price BETWEEN 1000 AND 2000
因为不符合"最左前缀原则"。要覆盖所有组合查询,理论上需要创建6种索引排列,这会导致:
- 索引占用的空间超过数据本身
- 写入性能下降50%以上
- 索引维护成本呈指数增长
实战经验:在汽车之家这类需要多维度筛选的场景,我们曾为单个表创建了17个索引,最终导致每秒只能处理30次写入。
2. ElasticSearch的强项与致命伤
2.1 搜索性能的降维打击
ES的倒排索引+分片架构,让它在大数据搜索场景就像装了喷气引擎。我们做过对比测试:
- 在2亿商品数据中模糊搜索"黑色真皮沙发"
- MySQL:12秒(即使有全文索引)
- ES:89毫秒
ES的杀手锏功能包括:
- 近实时搜索(1秒内可检索新数据)
- 聚合分析(秒级统计百万级数据)
- 自定义评分(boost重要字段)
- 拼音/同义词/纠错搜索
2.2 事务一致性的缺失
ES不适合作为唯一数据源的根本原因,是它缺乏真正的ACID事务。我遇到过最惨痛的案例:
- 用户支付订单后系统崩溃
- MySQL已记录支付成功
- ES还未更新库存
- 其他用户看到错误库存继续下单
最终导致超卖200多单。解决方案只能是:
- 所有写操作必须走MySQL
- 通过binlog或CDC同步到ES
- 接受秒级延迟(最终一致性)
2.3 分页查询的深坑
ES的from+size分页在深度分页时性能急剧下降。查询第10000页(每页10条)时:
- 需要排序100010条记录
- 占用大量堆内存
- 可能直接OOM崩溃
替代方案:
json复制{
"query": {...},
"search_after": [上次最后一条的排序值],
"size": 10
}
这种游标分页可以避免性能悬崖,但无法随机跳页。
3. 黄金组合:MySQL+ES混合架构
3.1 经典的双写模式
这是我们团队在电商系统使用的架构:
mermaid复制graph TD
A[业务系统] -->|写入| B[MySQL]
A -->|同步写入| C[ES]
D[查询服务] --> C
优点:
- 实现简单
- ES数据延迟极低
致命缺陷:
- 双写不一致(网络超时导致数据差异)
- 需要自己处理重试补偿
3.2 基于binlog的同步方案
更可靠的方案是通过Canal或Debezium监听MySQL binlog:
python复制# 伪代码示例
def process_binlog(event):
if event.table == 'products':
es.update(
index='products',
id=event.row['id'],
body={'doc': event.row}
)
注意事项:
- 需要处理DDL变更(ALTER TABLE)
- 小心循环同步(ES更新触发MySQL更新)
- 批量处理提升性能(但会增加延迟)
3.3 数据同步的容错设计
我们总结的容错模式:
- 记录同步位置(binlog offset)
- 失败时重试3次
- 仍失败则写入死信队列
- 定时任务补偿死信数据
- 监控延迟报警(超过10秒触发)
4. 实战避坑指南
4.1 字段类型映射陷阱
曾有个项目把MySQL的datetime直接映射为ES的date类型,结果:
- MySQL存储'2023-01-01 12:00:00'
- ES解析为UTC时间'2023-01-01 04:00:00'
正确做法是明确指定时区:
json复制{
"mappings": {
"properties": {
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||epoch_millis",
"time_zone": "+08:00"
}
}
}
}
4.2 索引别名的重要性
直接使用索引名(如products_2023)会导致:
- 应用代码频繁修改
- 重建索引时服务中断
应该始终使用别名:
bash复制# 新建索引
PUT /products_2023_v2
# 原子切换别名
POST /_aliases
{
"actions": [
{"remove": {"index": "products_2023", "alias": "products"}},
{"add": {"index": "products_2023_v2", "alias": "products"}}
]
}
4.3 冷热数据分离
将历史数据迁移到冷节点可以节省70%成本:
json复制PUT _ilm/policy/hot_cold_policy
{
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50GB"
}
}
},
"warm": {
"min_age": "7d",
"actions": {
"allocate": {
"require": {
"data": "warm"
}
}
}
}
}
}
5. 性能调优实战参数
5.1 JVM堆内存设置
ES的JVM堆不能超过32GB,否则指针压缩失效。建议:
- 机器内存64GB:设置31GB堆
- 预留一半内存给文件系统缓存
yaml复制# config/jvm.options
-Xms31g
-Xmx31g
5.2 分片数量公式
理想分片数 = 数据总量 / (单个分片建议30GB)
例如:
- 预计数据量1.2TB
- 分片数 = 1200 / 30 = 40个
bash复制PUT /my_index
{
"settings": {
"number_of_shards": 40,
"number_of_replicas": 1
}
}
5.3 刷新间隔优化
默认1秒刷新会导致大量小段(segment),调整为30秒可提升写入吞吐量3倍:
json复制PUT /my_index/_settings
{
"index.refresh_interval": "30s"
}
查询时可以通过?refresh=true强制刷新。
最后分享一个血泪教训:永远不要在ES里存储业务关键数据。我们曾因ES集群故障导致搜索服务不可用,但得益于所有数据都在MySQL有完整备份,最终只花了2小时就重建了整个ES集群。这种架构设计才是真正的工程智慧——既享受ES的查询性能,又保持MySQL的数据可靠性。