ES宽表解决多表关联查询性能问题

一、痛点

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查询
数据同步延迟增高 优化批量处理策略/调整触发频率
数据一致性异常 建立差异数据补偿任务