一、痛点
1.复杂聚合查询性能瓶颈
在实际查询场景中,当需要执行多表关联 时(如商品表goods和区划售卖价格表goodsSales,一对多型采用父子文档,一对一型平铺字段),面临严重性能问题:
- 分页查询 count 总数很慢
- 复杂条件筛选(多字段组合过滤)导致查询响应时间线性增长
- 聚合计算(如分类统计、价格区间分布)消耗大量数据库资源
2.索引膨胀问题
为提升查询性能而不断增加索引只是缓兵之计:
- 索引过多会使写入操作(INSERT/UPDATE)性能下降
- 索引维护成本随数据量增长而急剧上升
- 存储空间占用显著增加(索引大小超过数据大小)
3.传统解决方案局限
- MySQL分库分表方案:改造复杂,跨分片查询性能差,本来就是💩山代码,不要轻易改
- 内存缓存方案:无法解决复杂聚合计算问题
- 冗余字段方案:数据一致性维护困难,灵活性差,这个是以往的缓兵之计
二、优化方案:ES宽表
1.方案核心思路
常规查询 复杂查询 业务系统 是否为复杂查询 MySQL Elasticsearch 特定/通用业务场景宽表 毫秒级响应
2. 方案优势
性能提升
- 复杂聚合查询响应时间从几十秒级降至毫秒级
- 支持千万级数据实时聚合
资源高效 - 复用现有ES,无需新增基础设施
- 减少MySQL索引数量,提升写入性能
扩展灵活 - 动态调整分片应对数据增长
- 支持字段动态扩展
- 改动可以做到业务代码无侵入
3.数据同步机制
3.1 双轨同步策略
高实时数据 低实时数据 定时同步 MySQL 同步双写 ES 异步写入 定时任务表
同步方式 | 延迟 | 一致性 | 适用场景 |
---|---|---|---|
同步双写 | <50ms | 强一致性 | 商品价格更新、状态变更 |
异步写入 | 1-5分钟 | 最终一致 | 商品全区划总销量 |
4.索引设计规范
因为我的场景是一个商品在多个区划售卖,且每个区划的价格都不一样,所以这里采用父子文档。父文档只存自身goods的数据,子文档只存goodsSales的数据。
4.1 父子文档结构
json
{
"mappings": {
"properties": {
//父子文档关系字段
"goodsJoin": {
"type": "join",
"relations": {
"goods": "goodsSales"
}
},
//父文档字段
"goods_id": {"type": "keyword"},
"goods_name": {
"type": "text",
"fields": {
"keyword": {"type": "keyword"}
}
},
//子文档字段
"currentPrice": {"type": "float"},
"salesRegion": {"type": "keyword"}
}
},
"settings": {
"number_of_shards": 3,
"number_of_replicas": 0,
"max_result_window": 200000
}
}
4.2 关键设计原则
数据结构优化
- 父文档(goods):存储商品基础信息
- 子文档(goodsSales):存储区划售卖价格数据
- 文档ID:父文档ID需要添加
_goods
后缀防冲突(除非你的业务数据ID不同表也不重复),ES中父子文档相当于是放在同一张表的数据,不过有特殊标识。
性能调优配置
- 关闭副本:
"number_of_replicas": 0
(可后期启用) - 扩展结果集:
"max_result_window": 200000
(看你有没有全量查询的需求,视情况配置) - 预热全局序数:
"eager_global_ordinals": true
存储优化
- 禁用不必要分词器(纯字段条件查询,用不上分词)
- 使用
ignore_above
控制keyword字段长度(字段太长也不会作为查询条件)
三、实施路线
1.索引设计
1.1 字段映射设计
- 确定宽表字段清单
- 制定类型转换规则
- 验证父子文档结构可行性
1.2 容量规划
根据你的字段数量和数据规模去预估
数据量 | 分片数 | 存储预估 |
---|---|---|
<500万 | 2 | 15GB |
500-2000万 | 3 | 50GB |
>2000万 | 5+ | 100GB+ |
2.功能开发
有条件的话可以部署canal,订阅mysql的binlog,这样就不需要关心事务回滚问题了,而且解耦。
2.1 同步双写
事务末尾ES写入,防止回滚导致数据不一致的情况,这种直接硬编码实现。我的场景没有太多同步双写的接口,硬编码简单,并且由于机器有限不能部署canal。
2.2 异步双写
SQL拦截器捕获变更(异步写入),事务回滚SQL拦截器中的插入定时表的SQL也会回滚。
可参考的代码如下:
java
package top.ikanp.demo.interceptor;
import lombok.extern.slf4j.Slf4j;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.update.Update;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
/**
* 特定表的sql拦截器
*/
@Intercepts({@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
), @Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
), @Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)})
@Slf4j
public class SpecificTableSqlInterceptor implements Interceptor {
public SpecificTableSqlInterceptor() {
log.info("初始化SpecificTableSqlInterceptor...");
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement mappedStatement = (MappedStatement) args[0];
Object parameterObject = args[1];
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
BoundSql boundSql;
try {
if (args.length == 6) {
boundSql = (BoundSql) args[5];
} else {
boundSql = mappedStatement.getBoundSql(parameterObject);
}
} catch (Throwable throwable) {
return invocation.proceed();
}
// select 语句直接跳过
if (boundSql.getSql().toLowerCase().startsWith("select")) {
return invocation.proceed();
}
Statement statement = CCJSqlParserUtil.parse(boundSql.getSql());
if (SqlCommandType.INSERT.equals(sqlCommandType)) {
System.out.println("insert sql");
Insert insertStatement = (Insert) statement;
Table table = insertStatement.getTable();
if ("goods".equalsIgnoreCase(table.getName())) {
// 将SQL字段数据插入到定时任务表
}
} else if (SqlCommandType.UPDATE.equals(sqlCommandType)) {
System.out.println("update sql");
Update updateStatement = (Update) statement;
Table table = updateStatement.getTable();
if ("goods".equalsIgnoreCase(table.getName())) {
// 将SQL字段数据插入到定时任务表
}
} else if (SqlCommandType.DELETE.equals(sqlCommandType)) {
System.out.println("delete sql");
}
return invocation.proceed();
}
}
2.3 查询适配层
编写 mapper 的包装类替换原始 mapper 的 bean 对象,实现查询代码无侵入。
(1) Mapper包装类默认实现
主要通过传入代理 mapper,调用默认方法。
java
package top.ikanp.demo.decorator;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* BaseMapper的装饰器默认实现
*
* @author HetFrame
* @date 2025/3/3 11:19
*/
public class BaseMapperDecoratorDefaultImpl<T> implements BaseMapper<T> {
protected final BaseMapper<T> delegate;
public BaseMapperDecoratorDefaultImpl(BaseMapper<T> delegate) {
this.delegate = delegate;
}
@Override
public int insert(T entity) {
return delegate.insert(entity);
}
@Override
public int deleteById(Serializable id) {
return delegate.deleteById(id);
}
@Override
public int deleteById(T entity) {
return delegate.deleteById(entity);
}
@Override
public int deleteByMap(Map<String, Object> columnMap) {
return delegate.deleteByMap(columnMap);
}
@Override
public int delete(Wrapper<T> queryWrapper) {
return delegate.delete(queryWrapper);
}
@Override
public int deleteBatchIds(Collection<?> idList) {
return delegate.deleteBatchIds(idList);
}
@Override
public int updateById(T entity) {
return delegate.updateById(entity);
}
@Override
public int update(T entity, Wrapper<T> updateWrapper) {
return delegate.update(entity, updateWrapper);
}
@Override
public T selectById(Serializable id) {
return delegate.selectById(id);
}
@Override
public List<T> selectBatchIds(Collection<? extends Serializable> idList) {
return delegate.selectBatchIds(idList);
}
@Override
public List<T> selectByMap(Map<String, Object> columnMap) {
return delegate.selectByMap(columnMap);
}
@Override
public Long selectCount(Wrapper<T> queryWrapper) {
return delegate.selectCount(queryWrapper);
}
@Override
public List<T> selectList(Wrapper<T> queryWrapper) {
return delegate.selectList(queryWrapper);
}
@Override
public List<Map<String, Object>> selectMaps(Wrapper<T> queryWrapper) {
return delegate.selectMaps(queryWrapper);
}
@Override
public List<Object> selectObjs(Wrapper<T> queryWrapper) {
return delegate.selectObjs(queryWrapper);
}
@Override
public <P extends IPage<T>> P selectPage(P page, Wrapper<T> queryWrapper) {
return delegate.selectPage(page, queryWrapper);
}
@Override
public <P extends IPage<Map<String, Object>>> P selectMapsPage(P page, Wrapper<T> queryWrapper) {
return delegate.selectMapsPage(page, queryWrapper);
}
}
(2) 具体 mapper 包装类
比如你的复杂查询在 GoodsSalesMapper 里,但是只有 selectGoodsSalesByGoodsGuid 方法需要走 ES 宽表查询,其他方法都不做修改,那么需要继承 BaseMapperDecoratorDefaultImpl,并且实现 GoodsSalesMapper 接口,再覆写对应的方法。
java
package top.ikanp.demo.decorator;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import lombok.extern.slf4j.Slf4j;
import top.ikanp.demo.bean.GoodsSalesDTO;
import top.ikanp.demo.bean.GoodsSalesEntity;
import top.ikanp.demo.config.MapperDecoratorConfig;
import top.ikanp.demo.mapper.GoodsSalesMapper;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class GoodsSalesMapperDecorator extends BaseMapperDecoratorDefaultImpl<GoodsSalesEntity> implements GoodsSalesMapper {
/**
* 原始GoodsSalesMapper对象
*/
private final GoodsSalesMapper delegate;
public GoodsSalesMapperDecorator(GoodsSalesMapper delegate) {
super(delegate);
this.delegate = delegate;
}
@Override
public GoodsSalesEntity selectGoodsSales(String goodsGuid, String saGuid) {
return delegate.selectGoodsSales(goodsGuid, saGuid);
}
@Override
public List<GoodsSalesEntity> selectGoodsSalesByGoodsGuid(String goodsGuid) {
// 如果开启了 ES 查询
if (MapperDecoratorConfig.isUseEsQuery()) {
// 这里编写 ES 查询的代码,可以获取到分页插件的信息,所以也兼容分页查询
Page page = PageHelper.getLocalPage();
if (page != null) {
log.info("当前分页为pageNum={},pageSize={}", page.getPageNum(), page.getPageSize());
page.close();
page.setTotal(123);
}
List<GoodsSalesEntity> result = new ArrayList<>();
GoodsSalesEntity goodsSalesEntity = new GoodsSalesEntity();
goodsSalesEntity.setGoodsSalesGuid("装饰类对象走es查询到的");
result.add(goodsSalesEntity);
return result;
}
return delegate.selectGoodsSalesByGoodsGuid(goodsGuid);
}
@Override
public List<GoodsSalesEntity> selectGoodsSalesByDTO(GoodsSalesDTO dto) {
return delegate.selectGoodsSalesByDTO(dto);
}
}
(3) bean 配置类
将 GoodsSalesMapper 的代理对象注册为 GoodsSalesMapper 类型的 bean,那么 spring 在 service 注入 GoodsSalesMapper 时就会注入代理对象,而不是原来的 mapper。
java
package top.ikanp.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import top.ikanp.demo.decorator.GoodsSalesMapperDecorator;
import top.ikanp.demo.mapper.GoodsSalesMapper;
/**
* mapper 装饰器配置类,用于将装饰类对象替换到 bean 对象中
*
* @author HetFrame
* @date 2025/3/3 11:34
*/
@Configuration
public class MapperDecoratorConfig {
@Autowired
private GoodsSalesMapper goodsSalesMapper;
private static boolean useEsQuery = false;
private static long lastUpdateTime = 0;
public static boolean isUseEsQuery() {
// 超过1分钟就重读配置
long currentTime = System.currentTimeMillis();
if (currentTime - lastUpdateTime > 1000 * 60) {
// 假设从 redis 读取配置为true
useEsQuery = true;
lastUpdateTime = currentTime;
}
return useEsQuery;
}
@Bean
@Primary
public GoodsSalesMapper getGoodsSalesMapperDecorator() {
return new GoodsSalesMapperDecorator(goodsSalesMapper);
}
}
3.验证测试
3.1 性能基准测试
按照你的业务情况来决定
3.2 数据一致性验证
按照你的业务情况来决定
四、风险应对策略
风险点 | 应对措施 |
---|---|
ES集群资源不足 | 动态降级回退SQL查询 |
数据同步延迟增高 | 优化批量处理策略/调整触发频率 |
数据一致性异常 | 建立差异数据补偿任务 |