Mycat的sql解析源码剖析

半腔热情 提交于 2020-01-21 01:32:22

 Mycat 的 SQL 解析分为浅解析和深解析两部分,下面就分别讲解这两部分。

本文分析的 Mycat 版本号是 1.6.7.4-test-20191226161308,具体的版本号信息详见源码的 io.mycat.config.Versions#SERVER_VERSION 属性。

一、浅解析

 不同类型的 sql 语句需要不同的逻辑处理,而浅解析则是用于判定出 sql 语句所属的类型,比如 INSERT 类型、DELETE 类型、UPDATE 类型、SELECT 类型。在当前版本中 Mycat 就将所有 sql 归纳为下述共计 29 种类型。

package io.mycat.server.parser;

public final class ServerParse {

    public static final int OTHER = -1;
    public static final int BEGIN = 1;
    public static final int COMMIT = 2;
    public static final int DELETE = 3;
    public static final int INSERT = 4;
    public static final int REPLACE = 5;
    public static final int ROLLBACK = 6;
    public static final int SELECT = 7;
    public static final int SET = 8;
    public static final int SHOW = 9;
    public static final int START = 10;
    public static final int UPDATE = 11;
    public static final int KILL = 12;
    public static final int SAVEPOINT = 13;
    public static final int USE = 14;
    public static final int EXPLAIN = 15;
    public static final int EXPLAIN2 = 151;
    public static final int KILL_QUERY = 16;
    public static final int HELP = 17;
    public static final int MYSQL_CMD_COMMENT = 18;
    public static final int MYSQL_COMMENT = 19;
    public static final int CALL = 20;
    public static final int DESCRIBE = 21;
    public static final int LOCK = 22;
    public static final int UNLOCK = 23;
    public static final int LOAD_DATA_INFILE_SQL = 99;
    public static final int DDL = 100;
    public static final int COMMAND = 101;
    public static final int MIGRATE = 203;
}

 浅解析功能的实现主要是在 io.mycat.server.parser.ServerParse 类中,当收到客户端的请求时,会先把从信道中获取到的数据包进行解码成字符串,然后就是判断该字符串属于哪种 sql 类型,如下伪代码展示了进入浅解析功能的入口。

package io.mycat.server;

public class ServerQueryHandler implements FrontendQueryHandler {

    @Override
    public void query(String sql) {
        ...
        // 判定sql类型
        int rs = ServerParse.parse(sql);
        ...
    }
}

 进入到浅解析内部,可见其通过判断字符串的首个有效字符,再次进入不同的逻辑处理,进一步缩小判断范围。

package io.mycat.server.parser;

public final class ServerParse {

    public static int parse(String stmt) {
        int length = stmt.length();
        int rt = -1;
        for (int i = 0; i < length; ++i) { // 为了截取到有效起始字符才使用循环
            switch (stmt.charAt(i)) {  // 通过sql语句首个字符决定流程分支
                case ' ':
                case '\t':
                case '\r':
                case '\n':
                   continue; // 无效起始符,直接进入下次轮询
                case '/':
                    if (i == 0 && stmt.charAt(1) == '*' && stmt.charAt(2) == '!' && 
                            stmt.charAt(length - 2) == '*' && stmt.charAt(length - 1) == '/') {
                        return MYSQL_CMD_COMMENT; // /*! ... */类型的注释 
                    }
                case '#':
                    i = ParseUtil.comment(stmt, i);
                    if (i + 1 == length) {
                        return MYSQL_COMMENT;
                    }
                    continue;
                case 'A':
                case 'a':
                    rt = aCheck(stmt, i); // 首词是否为 alter
                    if (rt != OTHER) {
                        return rt;
                    }
                    continue;
                case 'B':
                case 'b':
                    rt = beginCheck(stmt, i); // 首词是否为 begin
                    if (rt != OTHER) {
                        return rt;
                    }
                    continue;
                case 'C':
                case 'c':
                    rt = commitOrCallCheckOrCreate(stmt, i); // 首词是否为 commit、call 和 create 之一
                    if (rt != OTHER) {
                        return rt;
                    }
                    continue;
                case 'D':
                case 'd':
                    rt = deleteOrdCheck(stmt, i); // 首词是否为 delete 或 drop
                    if (rt != OTHER) {
                        return rt;
                    }
                    continue;
                // 其他分支
            }
        }
        return OTHER;
    }
}

 如果 sql 语句是以字母 A(不区分大小写) 作为可见起始符,则将进入相应的流程分支,即下述方法判断该语句是否以 alter 关键词作为可见起始词。值得注意的是,即使该语句满足上述条件,还需要判断 alter 关键词后是否紧跟至少一个不可见字符(空格、制表符、换行符之一)。

package io.mycat.server.parser;

public final class ServerParse {    

    private static int aCheck(String stmt, int offset) {
        if (stmt.length() > offset + 4) {
            char c1 = stmt.charAt(++offset);
            char c2 = stmt.charAt(++offset);
            char c3 = stmt.charAt(++offset);
            char c4 = stmt.charAt(++offset);
            char c5 = stmt.charAt(++offset);
            if ((c1 == 'L' || c1 == 'l') && 
                    (c2 == 'T' || c2 == 't') && 
                    (c3 == 'E' || c3 == 'e') && 
                    (c4 == 'R' || c4 == 'r') && 
                    (c5 == ' ' || c5 == '\t' || c5 == '\r' || c5 == '\n')) {
                return DDL; // DDL 类型
            }
        }
        return OTHER; // 其他未知类型
    }
}

 满足 DDL 类型的语句条件如下:

  • 语句的起始可见词为 alter(不区分大小写)
  • alter 关键词后应紧跟至少一个不可见符(只能是空格、制表符和换行符之一)

其他类型的判断逻辑与此基本相似:都是判断首个可见词是否为预期的关键词关键词后是否紧跟合法的非可见符,只有当此两者都满足时,方返回对应的语句类型,否则返回OTHER类型,此处就不再赘述解析出其他 SQL 类型的具体实现方式。

二、深解析

 深解析指的是:通过 SQL 解析器将 SQL 语句转换成 AST 语法树。Mycat 通过该解析功能达到如下两个目的:

  • 判定 sql 语法的合法性
  • 为路由计算和结果集处理提供依据

2.1 SQL 解析器

 Mycat 1.3 之前使用的是 fdb parser(FoundationDB SQL Server) 解析器,从 1.3 开始引入 druid 解析器,从 1.4 开始移除了 fdbparser,只保留 druidparser 方式。

2.1.1 fdbparser 存在的问题

  1. 修改解析器源码的门槛太高。fdbparser 使用了 JavaCC 解析器,如果要修改解析器的源码必须搞清楚 JavaCC 的原理(修改解析器源码是有时碰到不支持的语法,要修改解析器来支持)。
  2. 没有好的 API 接口获取 AST 语法树中的表名、拆分字段条件等,所以路由解析时的代码很难有好的结构,就是写的很让人看不懂。
  3. 支持的语法太少。如 insert into … on duplicate key update …,带注释的 create table 语句不支持,此处不再穷举。
  4. 解析性能很差。对于一些较长的查询类语句,会耗费数秒的时间。

2.1.2 druidparser 简介

 在 Druid 的 SQL 解析器中,有三个重要组成部分,他们分别是:

  • Parser
    • 词法分析:拆解出每个独立的单词
    • 语法分析:解析出语句的唯一含义
  • AST(Abstract Syntax Tree,抽象语法树)
     以树状结构存储解析后的信息。
  • Visitor
     获取 AST 中的信息。对于用 Visitor 无法解析到的信息,可以直接访问 AST 去获取。

2.2 解析功能的封装

 在 Mycat 中的解析动作主要是解析 AST,使其能够获取到预期的所有信息,故定义了一个 io.mycat.route.parser.druid.DruidParser 接口。方法介绍:

序号 方法名 描述
1 parser 解析的入口方法
2 visitorParse 通过 visitor 解析,可以很方便的获取到表名、条件、字段列表、值列表等
3 visitorParse statement 方式解析。子类覆盖该方法一般是将 SQLStatement 转型后再解析
如转型为 MySqlInsertStatement
4 changeSql 该方法用来改写sql。如 select 语句加 limit、insert 语句加自增长值等
5 getCtx 获取解析结果,返回 DruidShardingParseInfo 对象。
该对象包含解析到的表名列表、条件列表等信息,用于后续路由计算

 DruidParser 接口有一个默认的 io.mycat.route.parser.druid.impl.DefaultDruidParser 实现类,该类相当于一个模板类,parser 模板方法公开定义了执行解析操作的步骤,如下述伪代码所示。

package io.mycat.route.parser.druid.impl;

public class DefaultDruidParser implements DruidParser {

    public void parser(SchemaConfig schema, RouteResultset rrs, SQLStatement stmt, String originSql,
                       LayerCachePool cachePool, MycatSchemaStatVisitor schemaStatVisitor)
            throws SQLNonTransientException {
        
        ctx = new DruidShardingParseInfo();
        ctx.setSql(originSql);

        //通过visitor解析
        visitorParse(rrs, stmt, schemaStatVisitor);

        //通过Statement解析
        statementParse(schema, rrs, stmt);
    }
}

模板模式:定义一个操作中的算法骨架,而将一些步骤延迟到子类中。模版方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。

 Mycat 对 SQLStatement 解析时,大多数类型的 statement 通过 visitorParse(RouteResultset, SQLStatement, MycatSchemaStatVisitor) 方法解析完就得到了路由计算的所需的所有信息(表名、条件字段等),如果还有信息没解析出来,就通过 statementParse(SchemaConfig, RouteResultset, SQLStatement) 方法再次解析。这两种方式解析后,会得到路由计算所需的所有信息。
 解析算法的总体骨架勾勒出来了,剩下的就需要为其填充血肉。下为 sql 深解析的类图。

在这里插入图片描述
DruidMysqlRouteStratgy 会根据解析出来的 Statement (AST 语法树)来调用相应的解析器进行解析,解析后的结果会存放到 DruidShardingParseInfo 类中(解析结果信息:表名、条件等),用于后续的计算路由。

2.2.1 默认解析器

 对于 AST 的解析,默认的解析器中仅实现了 visitorParse 方法,其他实现自 DruidParser 接口的方法均用空方法体重写,表示此解析器不做任何处理。它在 visitorParse 方法内的具体解析的内容如下:

package io.mycat.route.parser.druid.impl;

public class DefaultDruidParser implements DruidParser {

    public void visitorParse(RouteResultset rrs, SQLStatement stmt, MycatSchemaStatVisitor visitor)
            throws SQLNonTransientException {
        stmt.accept(visitor);
        ctx.setVisitor(visitor);

        if (stmt instanceof SQLSelectStatement) {
            ... //在rrs中标记此sql是否为select...for update...类型语句
        }

       List<List<Condition>> mergedConditionList = new ArrayList<>();
        if (visitor.hasOrCondition()) { //此语句包含or关键字
            mergedConditionList = visitor.splitConditions(); //根据or拆分
        } else { //此语句不含or关键字
            mergedConditionList.add(visitor.getConditions());
        }

        if (visitor.isHasChange()) { //是否改写过sql
            ctx.setSql(stmt.toString());
            rrs.setStatement(ctx.getSql());
        }

        if (visitor.getAliasMap() != null) { // sql中是否存在别名
            ... //去除字段名的引号和schema

            ctx.addTables(visitor.getTables());
            visitor.getAliasMap().putAll(tableAliasMap);
            ctx.setTableAliasMap(tableAliasMap);
        }
        //设置用于路由计算的分片键/值
        ctx.setRouteCalculateUnits(this.buildRouteCalculateUnits(visitor, mergedConditionList));
    }
}

 对于一般的 statement,使用 visitorParse 方式解析就能得到路由计算的所有信息,visitorParse 在模板类 DefaultDruidParser 中已经有了统一的实现。如果没有特定子类去重写该方法,则默认会使用此处的 visitorParse 方式解析。

当前不使用此 visitorParse 解析的分别有:DruidAlterTableParserDruidCreateTableParserDruidInsertParserDruidLockTableParser 四个解析器,因为使用该解析方法无法获取到预期的信息(DruidLockTableParser 例外),所以此四者重写了该方法,转而使用 statementPparse 解析。

2.2.2 INSERT 解析器

 因为 INSERT 类型的 SQL 语句通过默认解析器内的 visitorParse 方法得不到表名、字段等信息,且为了节省性能开销,所以直接用空方法体重写了该方法,使用 statementParse 方法去解析。

package io.mycat.route.parser.druid.impl;

public class DruidInsertParser extends DefaultDruidParser {

    /**
     * 考虑因素:是否分片、isChildTable、批量
     *
     * @param schema 数据库名
     * @param rrs    路由结果
     * @param stmt   执行语句
     * @throws SQLNonTransientException 非持久化SQL异常
     */
    @Override
    public void statementParse(SchemaConfig schema, RouteResultset rrs, SQLStatement stmt)
            throws SQLNonTransientException {

        MySqlInsertStatement insert = (MySqlInsertStatement) stmt;
        // 获取移除 ` 符号后的表名称
        String tableName = StringUtil.removeBackquote(insert.getTableName().getSimpleName()).toUpperCase();
        ctx.addTable(tableName);
        /* 1.是否为非分片表 */
        if (RouterUtil.isNoSharding(schema, tableName)) {
            RouterUtil.routeForTableMeta(rrs, schema, tableName, rrs.getStatement());
            rrs.setFinishedRoute(true); // 路由已完成
            return;
        }
        // 处理分片表
        TableConfig tc = schema.getTables().get(tableName);
        if (tc == null) {
            String msg = "can't find table define in schema " + tableName + " schema:" + schema.getName();
            LOGGER.warn(msg);
            throw new SQLNonTransientException(msg);
        } else {
            /* 2.是否为childTable */
            if (tc.isChildTable()) {
                // childTable 的 insert 直接在解析过程中完成路由
                parserChildTable(schema, rrs, tableName, insert);
                return;
            }

            String partitionColumn = tc.getPartitionColumn(); // 分片键
            if (partitionColumn != null) {//分片表
                //拆分表必须给出column list,否则无法寻找分片字段的值
                if (CollectionUtil.isEmpty(insert.getColumns())) {
                    throw new SQLSyntaxErrorException("partition table, insert must provide ColumnList");
                }
                /* 3.是否为批量insert */
                if (isMultiInsert(insert)) {
                    // 批量insert。
                    parserBatchInsert(schema, rrs, partitionColumn, tableName, insert);
                } else {
                    // 单条insert。将表名称、分片键名称、分片键值缓存到解析结果ctx中
                    parserSingleInsert(schema, rrs, partitionColumn, tableName, insert);
                }
            }
        }
    }
}

 Mycat 支持的批量插入类型仅 insert into table() values (),(),… 一种,具体实现方式如下:

package io.mycat.route.parser.druid.impl;

public class DruidInsertParser extends DefaultDruidParser {

    /**
     * insert into...select...或insert into table() values (),(),...
     *
     * @param schema          数据库名
     * @param rrs             路由结果
     * @param partitionColumn 分片键
     * @param tableName       表名称
     * @param insertStmt      insert类型的AST
     * @throws SQLNonTransientException 非持久化SQL异常
     */
    private void parserBatchInsert(SchemaConfig schema, RouteResultset rrs, String partitionColumn, String tableName,
                                   MySqlInsertStatement insertStmt) throws SQLNonTransientException {
        //insert into table() values (),(),....
        if (insertStmt.getValuesList().size() > 1) {
            ...
        } else if (insertStmt.getQuery() != null) { // insert into...select...
            // 不支持 insert into .... select ....
            String msg = "TODO:insert into .... select .... not supported!";
            LOGGER.warn(msg);
            throw new SQLNonTransientException(msg);
        }
    }
}

###2.2.3 UPDATE 解析器
 update 类型的 sql 语句限定了被变更表的个数;修改了变更全局表时返回的受影响行数;不支持变更分片键值;以及清空该表的主键缓存。

package io.mycat.route.parser.druid.impl;

public class DruidUpdateParser extends DefaultDruidParser {

    public void statementParse(SchemaConfig schema, RouteResultset rrs, SQLStatement stmt)
            throws SQLNonTransientException {
        //这里限制了update分片表的个数只能有一个
        if (ctx.getTables() != null && getUpdateTableCount() > 1 && !schema.isNoSharding()) { // 不支持多表更新
            String msg = "multi table related update not supported,tables:" + ctx.getTables();
            LOGGER.warn(msg);
            throw new SQLNonTransientException(msg);
        }
        MySqlUpdateStatement update = (MySqlUpdateStatement) stmt;
        String tableName = StringUtil.removeBackquote(update.getTableName().getSimpleName().toUpperCase()); // 移除`符号

        TableConfig tc = schema.getTables().get(tableName);

        if (RouterUtil.isNoSharding(schema, tableName)) {//整个schema都不分库或者该表不拆分
            RouterUtil.routeForTableMeta(rrs, schema, tableName, rrs.getStatement());
            rrs.setFinishedRoute(true); // 已完成路由
            return;
        }

        String partitionColumn = tc.getPartitionColumn();
        String joinKey = tc.getJoinKey();
        if (tc.isGlobalTable() || (partitionColumn == null && joinKey == null)) {
            //修改全局表 update 受影响的行数
            RouterUtil.routeToMultiNode(false, rrs, tc.getDataNodes(), rrs.getStatement(), tc.isGlobalTable());
            rrs.setFinishedRoute(true); // 已完成路由
            return;
        }
        // 确认分片键不作变更
        confirmShardColumnNotUpdated(update, schema, tableName, partitionColumn, joinKey, rrs);

        if (schema.getTables().get(tableName).isGlobalTable() && ctx.getRouteCalculateUnit().getTablesAndConditions().size() > 1) {
            // 不支持多表关联时更新全局表
            throw new SQLNonTransientException("global table is not supported in multi table related update " + tableName);
        }

        //在解析SQL时清空该表的主键缓存
        TableConfig tableConfig = schema.getTables().get(tableName);
        if (tableConfig != null && !tableConfig.primaryKeyIsPartionKey()) {
            String cacheName = schema.getName() + "_" + tableName;
            cacheName = cacheName.toUpperCase();
            for (CachePool value : MycatServer.getInstance().getCacheService().getAllCachePools().values()) {
                value.clearCache(cacheName);
                value.getCacheStatic().reset();
            }
        }
    }
}

 其他类型的解析就不再一一列举了,请参见源码的 io.mycat.route.parser.druid.impl 包内。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!