业务数据库实例的编码由 utf8 修改为 utf8mb4 后, java 业务插入表情符等宽字符(4 字节)的时候一直报错以下相关的错误:
### Cause:java.sql.SQLException:Incorrect string value:\xF0\x9F\x98\x8E for column nick_name at row 1 ;uncategorized SQLException for SQL[]; SQL state [HY000]; error code[1366];Incorrect string value: \xF0\x9F\x98\x8E for column nick_name at row 1
程序及数据库运行的版本及环境如下所示:
Centos 7.6 kernel-3.10.0-957.1.3.el7.x86_64 mysql-connector-java-5.1.46 Percona-Server-5.6.38-rel83.0-Linux
测试环境中使用同样的 mysql-connector-java
版本, 程序可以正常插入. 所不同的是测试环境修改完编码后重启了 MySQL 服务, 线上环境仅做以下修改, 重启程序而不重启 MySQL 服务:
set global character_set_client = utf8mb4; set global character_set_connection = utf8mb4; set global character_set_database = utf8mb4; set global character_set_results = utf8mb4; set global character_set_server = utf8mb4; set global collation_server = utf8mb4_general_ci; set global collation_database = utf8mb4_general_ci; set global collation_connection = utf8mb4_general_ci;
参考 connector-j-reference-charset 可以看到如果程序要插入 utf8mb4 字符, 需要满足以下条件:
Connector/J 5.1.47 及以上版本: 1. 指定 characterEncoding 参数为 UTF8/UTF-8 即可, 新版本直接映射到 utf8mb4 编码; 2. 如果 connectionCollation 指定的排序规则不是 utf8mb4 相关的, 则 characterEncoding 参数会重写为排序规则对应的编码; Connector/J 5.1.47 以下版本: 1. 设置 MySQL 参数变量 character_set_server=utf8mb4; 2. 指定 characterEncoding 参数为 UTF8/UTF-8, jdbc 程序会进行探测是否使用 utf8mb4;
所以对于 mysql-connector-java
版本来讲, 我们的条件已经满足, 不过还是插入失败. 另外 characterEncoding
参数的值只可以指定 connector-j-reference-charset 链接中 Table 5.3
提到的编码名, 指定其余的编码名, jdbc 在建立连接的时候就是失败报错.
mysql-connect-java 如何处理编码
满足了官方文档中的条件还是插入失败, 而使用 python, perl 等脚本程序却可以正常插入 utf8mb4 字符, 这点很让人迷惑. 我们参考 mysql-connector-java-5.1.46
的源程序可以看到以下代码:
//src/com/mysql/jdbc/ConnectionImpl.java 1616 private boolean configureClientCharacterSet(boolean dontCheckServerMatch) throws SQLException { 1617 String realJavaEncoding = getEncoding(); ...... 1689 if (realJavaEncoding != null) { 1690 1691 // 1692 // Now, inform the server what character set we will be using from now-on... 1693 // 1694 if (realJavaEncoding.equalsIgnoreCase("UTF-8") || realJavaEncoding.equalsIgnoreCase("UTF8")) { 1695 // charset names are case-sensitive 1696 1697 boolean utf8mb4Supported = versionMeetsMinimum(5, 5, 2); 1698 boolean useutf8mb4 = utf8mb4Supported && (CharsetMapping.UTF8MB4_INDEXES.contains(this.io.serverCharsetIndex)); 1699 1700 if (!getUseOldUTF8Behavior()) { 1701 if (dontCheckServerMatch || !characterSetNamesMatches("utf8") || (utf8mb4Supported && !characterSetNamesMatches("utf8mb4"))) { 1702 execSQL(null, "SET NAMES " + (useutf8mb4 ? "utf8mb4" : "utf8"), -1, null, DEFAULT_RESULT_SET_TYPE, 1703 DEFAULT_RESULT_SET_CONCURRENCY, false, this.database, null, false); 1704 this.serverVariables.put("character_set_client", useutf8mb4 ? "utf8mb4" : "utf8"); 1705 this.serverVariables.put("character_set_connection", useutf8mb4 ? "utf8mb4" : "utf8"); 1706 } 1707 } else { 1708 execSQL(null, "SET NAMES latin1", -1, null, DEFAULT_RESULT_SET_TYPE, DEFAULT_RESULT_SET_CONCURRENCY, false, this.database, null, 1709 false); 1710 this.serverVariables.put("character_set_client", "latin1"); 1711 this.serverVariables.put("character_set_connection", "latin1"); 1712 } 1713 1714 setEncoding(realJavaEncoding);
可以看到 1694 行代码即我们制定的 characterEncoding
参数, 后续的代码则为编码的自动探测. 1697 行代码为判断当前 MySQL 版本是否支持 utf8mb4 编码(mysql-5.5.2版本开始支持 utf8mb4 编码), 1698 行中 useutf8mb4
由两个条件来决定:
utf8mb4Supported CharsetMapping.UTF8MB4_INDEXES.contains(this.io.serverCharsetIndex)
我们的数据库版本是 5.6.38
, 所以第一个条件是满足的, 第二个条件中的 this.io.serverCharsetIndex
来源于以下代码, 可以看到这段代码是程序与数据库连接的时候所做的握手协议处理, serverCharsetIndex
为 MySQL Server 返回给当前会话的编码号(对应 information_schema.COLLATIONS
表的 ID 字段), 所以第二个条件即为判断当前会话接收到的编码号是否存在于 CharsetMapping.UTF8MB4_INDEXES
的集合中.
//src/com/mysql/jdbc/MysqlIO.java 998 /** 999 * Initialize communications with the MySQL server. Handles logging on, and 1000 * handling initial connection errors. 1001 * 1002 * @param user 1003 * @param password 1004 * @param database 1005 * 1006 * @throws SQLException 1007 * @throws CommunicationsException 1008 */ 1009 void doHandshake(String user, String password, String database) throws SQLException { 1010 // Read the first packet ...... 1118 if ((versionMeetsMinimum(4, 1, 1) || ((this.protocolVersion > 9) && (this.serverCapabilities & CLIENT_PROTOCOL_41) != 0))) { 1119 1120 /* New protocol with 16 bytes to describe server characteristics */ 1121 // read character set (1 byte) 1122 this.serverCharsetIndex = buf.readByte() & 0xff; 1123 // read status flags (2 bytes) 1124 this.serverStatus = buf.readInt();
参考 mysql-connector-java-4.1.47
版本的 changelog:
See Using Character Sets and Unicode for details, including how to use the utf8mb3 character set now for connection. (Bug #23227334, Bug #81196)
bug #81196 与我们碰到的问题相同. 如果 serverCharsetIndex
的值不是上述的集合中, jdbc 就会在会话建立后一直执行 SET NAMES utf8
操作.
协议分析
我们通过 tcpdump 来查看握手协议的报文信息:
0000 fe ee 16 93 fe 2d 52 54 00 48 bd 50 08 00 45 08 .....-RT.H.P..E. 0010 00 8b 4a b8 40 00 40 06 cc 78 0a 94 07 09 0a 94 ..J.@[email protected]...... 0020 07 04 0c e7 c5 0c b7 f4 a5 5a 5a 53 2f f9 80 18 .........ZZS/... 0030 00 e3 23 b2 00 00 01 01 08 0a a9 c6 ef e2 a9 b5 ..#............. 0040 7a 4b 53 00 00 00 0a 35 2e 36 2e 33 38 2d 38 33 zKS....5.6.38-83 0050 2e 30 2d 6c 6f 67 00 78 15 10 01 74 2d 7d 51 5e .0-log.x...t-}Q^ 0060 64 5b 79 00 ff f7 21 02 00 7f 80 15 00 00 00 00 d[y...!......... 0070 00 00 00 00 00 00 70 48 56 56 30 29 7c 58 24 48 ......pHVV0)|X$H 0080 7e 64 00 6d 79 73 71 6c 5f 6e 61 74 69 76 65 5f ~d.mysql_native_ 0090 70 61 73 73 77 6f 72 64 00 password.
参考 MySQL 的 通信协议格式 :
1 [0a] protocol version string[NUL] server version 4 connection id string[8] auth-plugin-data-part-1 1 [00] filler 2 capability flags (lower 2 bytes) if more data in the packet: 1 character set 2 status flags 2 capability flags (upper 2 bytes) if capabilities & CLIENT_PLUGIN_AUTH { 1 length of auth-plugin-data } else { 1 [00] } string[10] reserved (all [00]) if capabilities & CLIENT_SECURE_CONNECTION { string[$len] auth-plugin-data-part-2 ($len=MAX(13, length of auth-plugin-data - 8)) if capabilities & CLIENT_PLUGIN_AUTH { string[NUL] auth-plugin name }
从上述的协议格式来查找 tcpdump 报文中的各字段信息如下:
protocol version: 0a server version: 35 2e 36 2e 33 38 2d 38 33 2e 30 2d 6c 6f 67 00 connection id: 78 15 10 01 auth-plugin-date: 74 2d 7d 51 5e 64 5b 79 [00] filler: 00 capability flags: ff f7 character set: 21 status: 02 00
可以看到 MySQL Server 返回的 character set
为 0x21(十进制 33), 33 对应 information_schema.COLLATIONS
表中的 utf8 编码, 这意味着我们改了 MySQL Server
编码相关的参数后并没有将新的 utf8mb4 编码返回给客户端, 而是返回以前的编码.
MySQL 如何返回编码给客户端
我们以同样 MySQL 版本的 debug 版本进行测试, 如下所示为 debug 版本的 trace 信息:
...... T@29 : | | | | | |server_mpvio_read_packet T@29 : | | | | | >vio_read
这里的函数 send_server_handshake_packet
即实现了返回给客户端的握手协议, 10496 行即为 MySQL Server
返回的编码信息:
//src/sql/sql_acl.cc 10419 static bool send_server_handshake_packet(MPVIO_EXT *mpvio, 10420 const char *data, uint data_len) 10421 { ...... 10494 int2store(end, mpvio->client_capabilities); 10495 /* write server characteristics: up to 16 bytes allowed */ 10496 end[2]= (char) default_charset_info->number; 10497 int2store(end + 3, mpvio->server_status[0]);
对于代码 default_charset_info->number
, 其为 CHARSET_INFO
结构体的类型, 如下:
typedef struct charset_info_st { uint number; uint primary_number; uint binary_number; .... MY_CHARSET_HANDLER *cset; MY_COLLATION_HANDLER *coll; } CHARSET_INFO;
从 sql/mysqld.cc
中的代码来看, default_charset_info
仅在 MySQL Server
启动的时候进行初始化使用, 可以看到其值为 character-set-server
的参数值:
4020 int init_common_variables() 4021 { 4022 umask(((~my_umask) & 0666)); 4023 connection_errors_select= 0; ...... 4302 if (item_create_init()) 4303 return 1; 4304 item_init(); ...... 4322 if (!(default_charset_info= 4323 get_charset_by_csname(default_character_set_name, 4324 MY_CS_PRIMARY, MYF(MY_WME)))) 4325 { 4326 if (next_character_set_name) 4327 { 4328 default_character_set_name= next_character_set_name; 4329 default_collation_name= 0; // Ignore collation 4330 } 4331 else 4332 return 1; // Eof of the list 4333 } 4334 else 4335 break; 4336 } 7537 {"character-set-server", 'C', "Set the default character set.", 7538 &default_character_set_name, &default_character_set_name, 7539 0, GET_STR, REQUIRED_ARG, 0, 0, 0, 0, 0, 0 },
而启动后更改编码相关的参数并不会触发 default_charset_info
的更新, 从 debug 版本的 trace 日志中即可看到, 上述相关的操作仅在连接建立的时候初始化:
....... T@1 :get_charset_by_csname T@1 : | enter: name: 'utf8'
从这方面来看, 修改正在运行的数据库的编码并不会触发 default_charset_info
的更新, 返回给客户端协议包中的编码就还是以前的编码.
从上述的分析来看, mysql-connect-java-5.1.46
依赖数据库返回的编码, 不过由于数据库返回给客户端的编码还是以前的编码(同参数 character-set-server
的值一致), 所以要解决程序插入表情符的方式可以使用下面的方式:
1. 重启 MySQL Server
修改数据库的配置文件, 将原先 utf8 相关的编码都修改为 utf8mb4, 重启 MySQL Server
, 新的default_charset_info
继承 character-set-server
参数的值, 返回给客户端的编码即为 utf8mb4 编码. 这种方式适合新创建的或者测试环境的数据库, 线上的已运行数据库一般不做重启操作.
2. 打补丁
参考 bugs 81196 提供的方式, 这种方式适用于 5.1.38 ~ 5.1.46
版本, 其额外获取当前会话的 collation 参数是否包含 utf8mb4 来决定 useutf8mb4
是否为真, 如下所示:
diff --git a/src/com/mysql/jdbc/ConnectionImpl.java b/src/com/mysql/jdbc/ConnectionImpl.java index 9da30ea..854ae59 100644 --- a/src/com/mysql/jdbc/ConnectionImpl.java +++ b/src/com/mysql/jdbc/ConnectionImpl.java @@ -1762,7 +1762,8 @@ // charset names are case-sensitive boolean utf8mb4Supported = versionMeetsMinimum(5, 5, 2); - boolean useutf8mb4 = utf8mb4Supported && (CharsetMapping.UTF8MB4_INDEXES.contains(this.io.serverCharsetIndex)); + boolean useutf8mb4 = utf8mb4Supported && (CharsetMapping.UTF8MB4_INDEXES.contains(this.io.serverCharsetIndex) + || (getConnectionCollation() != null && StringUtils.startsWithIgnoreCase(getConnectionCollation(), "utf8mb4"))); if (!getUseOldUTF8Behavior()) { if (dontCheckServerMatch || !characterSetNamesMatches("utf8") || (utf8mb4Supported && !characterSetNamesMatches("utf8mb4"))) {
从 tcpdump -A -r ....
的报文来看:
12:08:22.994813 IP 10.0.21.17.50444 > 10.0.21.5.3303: Flags [P.], seq 261:1189, ack 110, win 115, options [nop,nop,TS val 2847242832 ecr 2848387046], length 928 ..zP........./* mysql-connector-java-5.1.46 ( Revision: 9cc87a48e75c2d2e87c1a293b2862ce651cb256e ) */SELECT @@session.auto_increment_increment AS auto_increment_increment, @@character_set_client AS character_set_client, @@character_set_connection AS character_set_connection, @@character_set_results AS character_set_results, @@character_set_server AS character_set_server, @@collation_server AS collation_server,...... 12:08:22.994939 IP 10.0.21.5.3303 > 10.0.21.17.50444: Flags [P.], seq 110:1137, ack 1189, win 250, options [nop,nop,TS val 2848387046 ecr 2847242832], length 1027 ......zP..........def....auto_increment_increment..?...........*....def....character_set_client..!................def....character_set_connection..!...........+....def....character_set_results..!...........*....def....character_set_server..!...........&....def....collation_server..!.6........." .........................2.utf8.utf8.utf8.utf8mb4.utf8mb4_general_ci..28800.GPL.1.....
jdbc 初始化的时候会获取一些变量参数的信息, 如上所示, collation 相关的参数均为 utf8mb4 相关的信息, 所以这种补丁的方式也可以解决碰到的问题, 这种方式需要开发者修改并编译对应的 mysql-connector-java
版本.
3. 升级 Connector/J 版本
上述有提到 5.1.47
版本的 characterEncoding
参数设置为 UTF8/UTF-8
的时候, 会直接映射到 utf8mb4, 不像低版本那样还需要依赖数据库返回的编码, 也不用重启数据库即可生效, 详见5.1.47-changelog . 从 changelog 可以看到比起 5.1.46
版本, 变更的并不多, 没有做大的更新, 升级的话不会对已有的功能产生影响. 不过线上升级建议分批操作, 以免存在问题影响所有的业务.