性能优化:索引,表分区与分表

背景

最近,生产上又暴露了几起较严重的性能问题(该说不说,咱这生产环境是真的不堪一击)。原因是最初研发系统时,设计师没有考虑到用户使用频率,数据增长速度,对部分表在结构上进行设计优化,结果导致有多张表的数据在一年的时间内冲破了千万大关,对大表进行操作时,响应速度慢到令人发指,这一来影响了局部功能的体验,二来因为长时间操作数据占用连接,使系统的整体性能也受到了影响。技术总监找上了我,希望我能尽快给出一个改动小,见效快的解决方案。

在了解了情况之后,我经过深思熟虑,最终想到了三个可行的办法:索引,表分区,物理分表。今天咱们就以这三个方法为例,以真实的项目日志数据进行测试,看看三者对系统的提升效果。

环境准备

我选择了系统中有代表性的一张表,sys_operation_log,有1400多万条数据,使用频率也比较高,在系统中常需要以用户id为条件,查询用户在某个业务模块里的操作日志。

其表结构如下:

sql 复制代码
--建初始表
CREATE TABLE sys_operation_log_local(
    id VARCHAR2(32) NOT NULL,
    operatorId VARCHAR2(50),
    operatorName VARCHAR2(100),
    status INTEGER,
    operationCode VARCHAR2(50),
    operationName VARCHAR2(100),
    operationType VARCHAR2(50),
    dataId VARCHAR2(127),
    changedItems VARCHAR2(2047),
    params VARCHAR2(4095),
    tableName VARCHAR2(127),
    createTime TIMESTAMP,
    requestAddr VARCHAR2(255),
    serviceAddr VARCHAR2(255),
    endTime TIMESTAMP,
    operateNt VARCHAR2(1023),
    orgCode VARCHAR2(32) DEFAULT  (''),
    PRIMARY KEY (id)
);

COMMENT ON TABLE sys_operation_log_local IS '日志信息表';
COMMENT ON COLUMN sys_operation_log_local.id IS '主键ID';
COMMENT ON COLUMN sys_operation_log_local.operatorId IS '操作人ID';
COMMENT ON COLUMN sys_operation_log_local.operatorName IS '操作人';
COMMENT ON COLUMN sys_operation_log_local.status IS '操作状态';
COMMENT ON COLUMN sys_operation_log_local.operationCode IS '操作的业务模块编码';
COMMENT ON COLUMN sys_operation_log_local.operationName IS '操作的业务模块名称';
COMMENT ON COLUMN sys_operation_log_local.operationType IS '业务操作类型';
COMMENT ON COLUMN sys_operation_log_local.dataId IS '操作数据Id';
COMMENT ON COLUMN sys_operation_log_local.changedItems IS '变更数据';
COMMENT ON COLUMN sys_operation_log_local.params IS '传入参数';
COMMENT ON COLUMN sys_operation_log_local.tableName IS '操作表Id';
COMMENT ON COLUMN sys_operation_log_local.createTime IS '创建时间';
COMMENT ON COLUMN sys_operation_log_local.requestAddr IS '请求地址';
COMMENT ON COLUMN sys_operation_log_local.serviceAddr IS '服务地址';
COMMENT ON COLUMN sys_operation_log_local.endTime IS '结束时间';
COMMENT ON COLUMN sys_operation_log_local.operateNt IS '操作备注';
COMMENT ON COLUMN sys_operation_log_local.orgCode IS '组织机构编码';

为了测试出在索引,复合索引,表分区,分表情况下的查询效率,我根据该表创建了一张分区表,因为上千万的数据都是在今年生成的,所以根据时间来分区显然不太合适,我决定以日志所属的业务模块编号(operationCode)作为分区键,如下:

sql 复制代码
CREATE TABLE sys_operation_log_part(
                                  id VARCHAR2(32) NOT NULL,
                                  operatorId VARCHAR2(50),
                                  operatorName VARCHAR2(100),
                                  status INTEGER,
                                  orgCode VARCHAR2(50),
                                  operationCode VARCHAR2(50),
                                  operationName VARCHAR2(100),
                                  operationType VARCHAR2(50),
                                  dataId VARCHAR2(127),
                                  changedItems VARCHAR2(2047),
                                  params VARCHAR2(4095),
                                  tableName VARCHAR2(127),
                                  createTime Timestamp,
                                  requestAddr VARCHAR2(255),
                                  serviceAddr VARCHAR2(255),
                                  endTime Timestamp,
                                  operateNt VARCHAR2(1023),
                                  PRIMARY KEY (id)
)PARTITION BY LIST (operationCode) (
    PARTITION p_nkcspz VALUES ('NKCSPZ'),
    PARTITION p_nkjhgl VALUES ('NKJHGL'),
    PARTITION p_nkrwcl VALUES ('NKRWCL'),
    PARTITION p_default VALUES (DEFAULT)
);

CREATE INDEX idx_operation_log_part_operId ON sys_operation_log_part(operatorId);

--初始化数据
insert into sys_operation_log_part
( id,operatorId,operatorName,status ,orgCode,operationCode,operationName,operationType,dataId,changedItems,
params,tableName,createTime ,requestAddr,serviceAddr,endTime,operateNt)
 select id,operatorId,operatorName,status ,orgCode,operationCode,operationName,operationType,dataId,changedItems,
params,tableName,createTime ,requestAddr,serviceAddr,endTime,operateNt from sys_operation_log_local

又根据operationCode建了多张表,其中以NKRWCL模块的数据量最大,就以它来进行测试,建表语句如下:

sql 复制代码
CREATE TABLE sys_operation_log_NKRWCL(
    id VARCHAR2(32) NOT NULL,
    operatorId VARCHAR2(50),
    operatorName VARCHAR2(100),
    status INTEGER,
    operationCode VARCHAR2(50),
    operationName VARCHAR2(100),
    operationType VARCHAR2(50),
    dataId VARCHAR2(127),
    changedItems VARCHAR2(2047),
    params VARCHAR2(4095),
    tableName VARCHAR2(127),
    createTime TIMESTAMP,
    requestAddr VARCHAR2(255),
    serviceAddr VARCHAR2(255),
    endTime TIMESTAMP,
    operateNt VARCHAR2(1023),
    orgCode VARCHAR2(32) DEFAULT  (''),
    PRIMARY KEY (id)
);

CREATE INDEX idx_operation_log_NKRWCL_operId ON sys_operation_log_NKRWCL(operatorId);

insert into sys_operation_log_NKRWCL
( id,operatorId,operatorName,status ,orgCode,operationCode,operationName,operationType,dataId,changedItems,
params,tableName,createTime ,requestAddr,serviceAddr,endTime,operateNt)
 select id,operatorId,operatorName,status ,orgCode,operationCode,operationName,operationType,dataId,changedItems,
params,tableName,createTime ,requestAddr,serviceAddr,endTime,operateNt from sys_operation_log where operationCode = 'NKRWCL'

完成了相关表和索引的创建,数据的初始化之后,接下来就可以开始程序调整和测试了。

程序调整

创建索引和表分区都不需要更改程序代码,充其量修改一下程序中查询条件的顺序,而物理分表则注定要修改表名,会影响程序。在评估各种实现时,实现方案的难易程度、修改的工作量作为评估的要素之一同样十分重要。你们可能想象不到,为了做到根据查询条件改表名,我做了多少尝试,MyBytia拦截器,过滤器,TableNameHandler,最后发现还是TableNameHandler的实现方式最容易,代码如下:

pom.xml

sql 复制代码
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.0</version>
        </dependency>

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.4.0</version>
        </dependency>

        <dependency>
            <groupId>com.jdbc.dm</groupId>
            <artifactId>DmJdbcDriver18</artifactId>
            <version>1.0</version>
        </dependency>

Java代码:

sql 复制代码
package com.leixi.hub.config;

import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
/**
 *
 * @author 雷袭月启
 * @since 2024/11/25 19:39
 */
public class SysOperationLogNameParser implements TableNameHandler {

    //使用ThreadLocal防止多线程相互影响
    private static ThreadLocal<String> operationCode = new ThreadLocal<String>();

    public static void setOperationCode(String code) {
        operationCode.set(code);
    }

    @Override
    public String dynamicTableName(String sql, String tableName) {
        String code = operationCode.get();
        return tableName + "_" + code;
    }
}


package com.leixi.hub.config;

import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceBuilder;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TableNameHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
import java.util.HashMap;

/**
 *
 * @author 雷袭月启
 * @since 2024/11/25 19:39
 */
@Configuration
public class MainDb {
    @Bean(name = "mainDataSource")
    @ConfigurationProperties(prefix = "dbconfig.maindb")
    public DataSource druidDataSource() {
        return DruidDataSourceBuilder.create().build();
    }



    @Bean(name = "mainSqlSessionFactory")
    @ConfigurationPropertiesBinding()
    public SqlSessionFactory sqlSessionFactory(@Qualifier(value = "mainDataSource") DataSource dataSource, ApplicationContext applicationContext) throws Exception {
        MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean();
        factoryBean.setDataSource(dataSource);
        //加载插件
        factoryBean.setPlugins(mybatisPlusInterceptor());
        factoryBean.setConfiguration(mybatisPlusConfiguration());
        factoryBean.setMapperLocations(applicationContext.getResources("classpath:mapper/*Mapper.xml"));
        return factoryBean.getObject();
    }

    @Bean
    public MybatisConfiguration mybatisPlusConfiguration() {
        MybatisConfiguration configuration = new MybatisConfiguration();

        // 配置其他 MyBatis-Plus 的属性
        configuration.setMapUnderscoreToCamelCase(false);
        //configuration.setCacheEnabled(true);
        configuration.setLogImpl(org.apache.ibatis.logging.stdout.StdOutImpl.class);
        // 其他配置...

        return configuration;
    }

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
        HashMap<String, TableNameHandler> map = new HashMap<String, TableNameHandler>();

        //这里为不同的表设置对应表名处理器
        map.put("sys_operation_log", new SysOperationLogNameParser());

        dynamicTableNameInnerInterceptor.setTableNameHandlerMap(map);
        interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
        return interceptor;
    }
}


@RestController
public class DemoController {

    @Autowired
    private CommonMapper commonMapper;

    // 定义operationCode的正则表达式
    private static final String regex = "operationCode\\s*=\\s*'([^']*)'";

    @GetMapping("/demo")
    public Object demo(String sql) {
        if (sql.contains("sys_operation_log ")) {
            SysOperationLogNameParser.setOperationCode(getOperationCodeFromSql(sql));
        }
        return commonMapper.getDataBySql(sql);
    }

    private String getOperationCodeFromSql(String sql) {
        // 编译正则表达式
        Pattern pattern = Pattern.compile(regex);
        // 创建匹配器
        Matcher matcher = pattern.matcher(sql);
        // 查找匹配项
        if (matcher.find()) {
            // 提取操作码的值
            String operationCode = matcher.group(1);
            System.out.println("operationCode: " + operationCode);
            return operationCode;
        } else {
            System.out.println("未找到 operationCode");
        }
        return "DEFAULT";
    }
}

如果仅是为了适配本测试用例,其实大可以不必这么麻烦,直接替换下sql中的表名即可。但是在实际的项目中,sql都是通过LambdaQueryWrapper转换生成的,或者BaseMapper自动生成的方法,又或者是写在Mapper.xml里的SQL,这时候就不方便修改表名了,上述的实现就能发挥出它最好的作用,改动小,侵入性低。唯一要做的是在操作之前添加一行: SysOperationLogNameParser.setOperationCode("从条件中获取的operationCode");

如下:

但是,这仍然不是侵入性最低的方法,更好的一种实现是使用Shardingshpere。

测试

咱们还是选用上一篇博客里的那套代码进行测试,使用统一的sql,查询某用户在NKRWCL中的操作记录,测试过程如下:

1、当没有任何索引时,执行以下SQL(select * from sys_operation_log_local where operationCode = 'NKRWCL' and operatorId='leixi'),用时4.37s测试结果如下:

2、添加operatorId作为索引时,用时约20ms, 测试结果如下:

create index idx_operation_log_operId ON sys_operation_log_local(operatorId);

3、添加operationCode, operatorId为复合索引时,用时也在20ms左右,测试结果如下:

create index idx_operation_log_code_operId ON sys_operation_log_local(operationCode, operatorId);

4、用同样的sql,查询分区表,根据查询条件的不同,用时在20-100ms左右,结果如下:

5、同样的sql,使用物理分表,用时16-100ms左右,查询结果如下:

结论

如果单从这个结果上看,这次的实践属实有点脱裤子放屁了,对于千万级的表来说,貌似创建索引,复合索引,表分区,物理分表的结果相差不大。但是,当我在测试环境中进行同样过程的测试时,受到网络连接,并发,事务,数据库服务器配置等多种因素的影响, 测试结果产生了巨大的差别。因测试场景中涉及敏感数据,这里不提供截图,仅公布测试结论,测试环境同等数据量,不加任何索引的情况下,查询数据需要8分钟,添加复合索引后用时150ms左右, 使用表分区平均用时在100ms左右,使用物理分表查询效率在60ms左右。从查询效率来看:物理分表 > 表分区 > 复合索引 > 单值索引

由此可知,在系统设计时做好数据库表的规划和性能方面的设计是非常必要的,也是代价最小的工作。千万不要等到数据量起来之后,再想着去分析和优化。

另:本实实现物理分表的逻辑仍然有些粗糙了,对程序有改动,就意味着工作量大,可能会出现漏改的地方,后面雷袭会考虑使用ShardingSphere来优化这个实现方式,具体请见下回分解。

相关推荐
口_天_光健4 小时前
两款轻量级数据库SQLite 和 TinyDB,简单!实用!
数据库·python·sqlite·非关系型数据库
notfindjob4 小时前
sqlite加密-QtCipherSqlitePlugin 下
数据库·算法·sqlite
凡人的AI工具箱4 小时前
每天40分玩转Django:Django部署
数据库·后端·python·算法·django
装不满的克莱因瓶4 小时前
【Redis经典面试题一】如何解决Redis和数据库一致性的问题?
数据库·redis·缓存·一致性·延迟双删·双写一致性
woshilys4 小时前
sql server msdb数据库备份恢复
数据库·sqlserver
play_big_knife4 小时前
鸿蒙项目云捐助第十六讲云捐助使用云数据库实现登录注册
数据库·华为云·harmonyos·鸿蒙·云开发·云数据库·鸿蒙开发
火鸟25 小时前
Java 初学者的第一个 SpringBoot3.4.0 登录系统
数据库·通用代码生成器·编程初学者·第一个系统·电音之王·springboot3.4.0·java初学者
总是学不会.5 小时前
【Mysql面试】MyISAM 与 InnoDB相关问题
数据库·mysql·面试
qq_2518364575 小时前
基于asp.net游乐园管理系统设计与实现
开发语言·前端·数据库·后端·asp.net
Navicat中国5 小时前
Navicat 17 功能简介 | SQL 美化
数据库·sql·mysql·dba·mariadb·navicat