MySQL编码探究

前言

时位于 2002 年,MySQL4.1 版本开始支持 UTF-8 编码,由于当时采用RFC 2279规定该编码使用 1~6 字节存储单个字符,MySQL 开发者在同年 9月对源码进行了修改,将 UTF-8 编码从最大 6字节变更为 3字节,也就有了现在的UTF8MB3。 我们都知道,UTF8MB3是无法存储 SMP(Supplementary Multi-lingual Plane)字符的,而 MySQL 后来基于RFC 3629 标准新增了UTF8MB4。这时候我们只需要将表(Table)和字段(Column)的编码类型设置为UTF8MB4即可。 但在事实上,建立 MySQL 连接时通过SET NAMES指定的编码类型同样会影响到 SMP 字符的传输。 本文主要对连接 MySQL 时候编码指定的逻辑进行分析

Connector 源码分析

以下基于mysql-connector-java依赖包的5.1.46以及5.1.47版本,其他版本在实现上也许会有不同。

上述问题的原因是因为当 MySQL 实例化一个 Connector 时,会通过一个 Set 集合UTF8MB4_INDEXES是否包含变量serverCharsetIndex来得到一个 Boolean 变量useutf8mb4,后续会执行SET NAMES时会通过useutf8mb4变量判断是使用UTF8MB4还是UTF8。接下来我们跟着代码看看这些变量是如何被赋值的。

mysql-connector-java 5.1.46

  • CharsetMapping

    CharsetMapping 类被初始化时,它内部的静态代码块就会被执行

    public static final int MAP_SIZE = 2048;
    ......
    // 保存 MySQL 编码以及排序规则
    Collation[] collation = new Collation[MAP_SIZE];
    ......
    collation[33] = new Collation(33, "utf8_general_ci", 1, MYSQL_CHARSET_NAME_utf8);
    ......
    collation[45] = new Collation(45, "utf8mb4_general_ci", 1, MYSQL_CHARSET_NAME_utf8mb4);
    collation[46] = new Collation(46, "utf8mb4_bin", 0, MYSQL_CHARSET_NAME_utf8mb4);
    ......
    collation[255] = new Collation(255, "utf8mb4_0900_ai_ci", 0, "utf8mb4");
    ......
    Set<Integer> tempUTF8MB4Indexes = new HashSet<Integer>();
    Collation notUsedCollation = new Collation(0, COLLATION_NOT_DEFINED, 0, NOT_USED);
    for (int i = 1; i < MAP_SIZE; i++) {
      Collation coll = collation[i] != null ? collation[i] : notUsedCollation;
      COLLATION_INDEX_TO_COLLATION_NAME[i] = coll.collationName;
      COLLATION_INDEX_TO_CHARSET[i] = coll.mysqlCharset;
      String charsetName = coll.mysqlCharset.charsetName;
      ......
      // 找到字符集名称为'utf8mb4'并且把对应的下表保存到 tempUTF8MB4Indexes 中
      if (charsetName.equals(MYSQL_CHARSET_NAME_utf8mb4)) {
        tempUTF8MB4Indexes.add(i);
      }
    }
    CHARSET_NAME_TO_COLLATION_INDEX = Collections.unmodifiableMap(charsetNameToCollationIndexMap);
    UTF8MB4_INDEXES = Collections.unmodifiableSet(tempUTF8MB4Indexes);
    

    Collation 类

    class Collation {
      // 下标
      public final int index;
      public final String collationName;
      public final int priority;
      public final MysqlCharset mysqlCharset;
    
      public Collation(int index, String collationName, int priority, String charsetName) {
        this.index = index;
        this.collationName = collationName;
        this.priority = priority;
        this.mysqlCharset = CharsetMapping.CHARSET_NAME_TO_CHARSET.get(charsetName);
      }
    }
    

    首先,会初始化一个类型为 Collation 的数组,并且会创建多个 Collation 实例来保存 MySQL 的字符集(Charset)以及排序规则(Collation)。接下来会遍历该数组找到字符集名称为UTF8MB4的下标,最终生成一个只读的 Set 赋值给UTF8MB4_INDEXES变量。 此时UTF8MB4_INDEXES中就有了所有字符集名称为UTF8MB4的下标。

  • MysqlIO

    当一个 MySQL 连接被创建时,会通过MysqlIO类与 MySQL Server 建立 TCP 连接。 tcp 连接建立

    当 TCP 连接建立成功后,MySQL Server 会返回一个 Greeting 包。 Greeting 数据包

    此时通过调用类中的doHandshake方法:

    void doHandshake(String user, String password, String database) throws SQLException {
      // Read the first packet
      this.checkPacketSequence = false;
      this.readPacketSequence = 0;
      Buffer buf = readPacket();
      // Get the protocol version
      this.protocolVersion = buf.readByte();
      ......
      this.serverCapabilities = 0;
      // read capability flags (lower 2 bytes)
      if (buf.getPosition() < buf.getBufLength()) {
        this.serverCapabilities = buf.readInt();
      }
      // 当版本
      if ((versionMeetsMinimum(4, 1, 1) || ((this.protocolVersion > 9) && (this.serverCapabilities&CLIENT_PROTOCOL_41) != 0))) {
        // read character set (1 byte)
        this.serverCharsetIndex = buf.readByte() & 0xff;
        ......
      }
      ......
    }
    

    此时调用readPacket方法,会获取到 MySQL Server 返回的 Greeting 包数据。其中的一个字节就代表serverCharsetIndex(以上图为例serverCharsetIndex就是位于f7后面的21,转为 10 进制就是33),并且与0xff进行&操作保证它的值不会大于 255。

  • ConnectionImpl

    同样,在 MySQL 连接被创建时,会调用ConnectionImpl中的configureClientCharacterSet方法设置客户端字符编码类型。

    private boolean configureClientCharacterSet(boolean dontCheckServerMatch) throws SQLException {
      // 来自于 MySQL 属性 characterEncoding
      String realJavaEncoding = getEncoding();
      boolean characterSetAlreadyConfigured = false;
      if (versionMeetsMinimum(4, 1, 0)) {
        characterSetAlreadyConfigured = true;
        // 设置变量 useUnicode 为true, 同时来自于 MySQL 属性 useUnicode
        setUseUnicode(true);
        configureCharsetProperties();
        // we need to do this again to grab this for versions > 4.1.0
        realJavaEncoding = getEncoding();
        ......
        // 是否使用 unicode。MySQL 版本 >= 4.1.0 或者设置属性 useUnicode=true。
        // 则返回为 true
        if (getUseUnicode()) {
          // 设置属性 characterEncoding 则不为 null
          if (realJavaEncoding != null) {
            if (realJavaEncoding.equalsIgnoreCase("UTF-8") || realJavaEncoding.equalsIgnoreCase("UTF8")) {
              // MySQL 版本是否 >= 5.5.2, 该版本之后才支持 utf8mb4
              boolean utf8mb4Supported = versionMeetsMinimum(5, 5, 2);
              // UTF8MB4_INDEXES 是否包含 serverCharsetIndex
              boolean useutf8mb4 = utf8mb4Supported && f(CharsetMapping.UTF8MB4_INDEXES.contains(this.io.serverCharsetIndex));
              // 除非设置了 useOldUTF8Behavior 属性, 否则默认 false
              if (!getUseOldUTF8Behavior()) {
                // characterSetNamesMatches 方法主要判断 character_set_client、character_set_connection 这两个属性的值
                if (dontCheckServerMatch || !characterSetNamesMatches("utf8") || (utf8mb4Supported && !characterSetNamesMatches("utf8mb4"))) {
                  execSQL(null, "SET NAMES " + (useutf8mb4 ? "utf8mb4" : "utf8"), -1, null, DEFAULT_RESULT_SET_TYPE,DEFAULT_RESULT_SET_CONCURRENCY, false, this.database, null, false);
                  this.serverVariables.put("character_set_client", useutf8mb4 ? "utf8mb4" : "utf8");
                  this.serverVariables.put("character_set_connection", useutf8mb4 ? "utf8mb4" : "utf8");
                }
              }
            }
          }
        }
      }
      ......
    }
    

    我们再来看看serverCharsetIndex这个值 Greeting 数据包 根据该数据包返回结果来看,serverCharsetIndex的值应该是 33。在 Collation 数组中下标 33 对应的字符集是UTF8,所以该下标肯定是不存在于UTF8MB4_INDEXES集合中的。变量useutf8mb4的值为false,在后续调用execSQL方法时,完整的 SQL 应该是SET NAMES utf8

mysql-connector-java 5.1.47

5.1.47在源码实现上与5.1.46有些许不同,让我们看一下它对configureClientCharacterSet方法的改动。

private boolean configureClientCharacterSet(boolean dontCheckServerMatch) throws SQLException {
  ......
  // 改动 1
  // 通过 getConnectionCollation()获取 connectionCollation 属性的值,如果不为空的话则进入循环
  if (!getUseOldUTF8Behavior() && !StringUtils.isNullOrEmpty(getConnectionCollation())) {
    for (int i = 1; i < CharsetMapping.COLLATION_INDEX_TO_COLLATION_NAME.length; i++) {
      // COLLATION_INDEX_TO_COLLATION_NAME 集合保存的是 Collation 对象中的 collationName, 例如'utf8mb4_general_ci'
      if (CharsetMapping.COLLATION_INDEX_TO_COLLATION_NAME[i].equals(getConnectionCollation())) {
        connectionCollationSuffix = " COLLATE " + CharsetMapping.COLLATION_INDEX_TO_COLLATION_NAME[i];
        // 找到Collation对应的charsetName, 赋值给connectionCollationCharset
        connectionCollationCharset = CharsetMapping.COLLATION_INDEX_TO_CHARSET[i].charsetName;
        realJavaEncoding = CharsetMapping.getJavaEncodingForCollationIndex(i);
      }
    }
  }
  ......
  if (getUseUnicode()) {
    if (realJavaEncoding != null) {
      if (realJavaEncoding.equalsIgnoreCase("UTF-8") || realJavaEncoding.equalsIgnoreCase("UTF8")) {
        boolean utf8mb4Supported = versionMeetsMinimum(5, 5, 2);
        // 改动 2
        String utf8CharsetName = connectionCollationSuffix.length() > 0 ? connectionCollationCharset : (utf8mb4Supported ? "utf8mb4" : "utf8");
        if (!getUseOldUTF8Behavior()) {
          if (dontCheckServerMatch || !characterSetNamesMatches("utf8") || (utf8mb4Supported && !characterSetNamesMatches("utf8mb4")) ||(connectionCollationSuffix.length() > 0 && !getConnectionCollation().equalsIgnoreCase(this.serverVariables.get("collation_server")))) {
            execSQL(null, "SET NAMES " + utf8CharsetName + connectionCollationSuffix, -1, null, DEFAULT_RESULT_SET_TYPE,DEFAULT_RESULT_SET_CONCURRENCY, false, this.database, null, false);
            this.serverVariables.put("character_set_client", utf8CharsetName);
            this.serverVariables.put("character_set_connection", utf8CharsetName);
          }
        }
      }
    }
  }
  ......
}

通过上方代码我们可以看到,它不再依赖于UTF8MB4_INDEXES来做判断,而是基于以下两点:

  1. connectionCollationSuffix是否有值, 有则默认为编码为connectionCollationCharset
  2. utf8mb4Supported变量值是否为true(也就是说 MySQL 版本是否 >= 5.5.2),为 true 则使用编码UTF8MB4

总结

在不对其他设置进行变动的情况下,我们可以通过将mysql-connector-java包升级来完成对 UTF8MB4 的支持。 那么在不升级依赖包的情况下能做到对 UTF8MB4 的支持吗?答案是可以的!

设置属性 com.mysql.jdbc.faultInjection.serverCharsetIndex

通过上面的源码我们可以看到在configureClientCharacterSet方法中有获取com.mysql.jdbc.faultInjection.serverCharsetIndex属性并赋值给serverCharsetIndex,那么我们只需要将该属性的值设置为UTF8MB4的下标数值即可。例如 45、46、224 等...(该属性只做测试用,建议大家不要用这种方法)

将 MySQL Server 的character_set_server 设置为 utf8mb4

其实代码中的serverCharsetIndex是根据character_set_server属性的值映射而来,当我们把 MySQL Server 的character_set_server修改为UTF8MB4后再来抓包看看. Server_Greeting_utf8mb4 这时serverCharsetIndex的值应该就是ff,转换为 10 进制就是255collation[255] = new Collation(255, "utf8mb4_0900_ai_ci", 0, "utf8mb4");255对应的字符集正是 utf8mb4。

最后,这篇文章主要围绕 'MySQL 连接设置编码为 utf8 导致 SMP 字符插入失败' 问题展开解析。如有不详之处请多谅解:)