mysql-connector-java 插入 utf8mb4 字符失败问题处理分析
source link: https://www.tuicool.com/articles/muEJRj3
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
问题说明
业务数据库实例的编码由 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;
jdbc 配置说明
参考 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 : | | | | | | <net_flush 224 T@29 : | | | | | <send_server_handshake_packet 10513 T@29 : | | | | <server_mpvio_write_packet 11619 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 : <item_create_init 5792 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
版本, 变更的并不多, 没有做大的更新, 升级的话不会对已有的功能产生影响. 不过线上升级建议分批操作, 以免存在问题影响所有的业务.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK