mysql-connector-java 插入 utf8mb4 字符失敗問題處理分析

更改MySQL數據庫的編碼爲utf8mb4

原文:http://blog.csdn.net/woslx/article/details/49685111

utf-8編碼可能2個字節、3個字節、4個字節的字符,但是MySQL的utf8編碼只支持3字節的數據,而移動端的表情數據是4個字節的字符。如果直接往採用utf-8編碼的數據庫中插入表情數據,Java程序中將報SQL異常:

java.sql.SQLException: Incorrect string value: ‘\xF0\x9F\x92\x94’ for column ‘name’ at row 1
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:1073) 
at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3593) 
at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3525) 
at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:1986) 
at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2140) 
at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2620) 
at com.mysql.jdbc.StatementImpl.executeUpdate(StatementImpl.java:1662) 
at com.mysql.jdbc.StatementImpl.executeUpdate(StatementImpl.java:1581)

可以對4字節的字符進行編碼存儲,然後取出來的時候,再進行解碼。但是這樣做會使得任何使用該字符的地方都要進行編碼與解碼。

utf8mb4編碼是utf8編碼的超集,兼容utf8,並且能存儲4字節的表情字符。 
採用utf8mb4編碼的好處是:存儲與獲取數據的時候,不用再考慮表情字符的編碼與解碼問題。

更改數據庫的編碼爲utf8mb4:

1. MySQL的版本

utf8mb4的最低mysql版本支持版本爲5.5.3+,若不是,請升級到較新版本。

2. MySQL驅動

5.1.34可用,最低不能低於5.1.13

3.修改MySQL配置文件(非必須)

修改mysql配置文件my.cnf(windows爲my.ini) 
my.cnf一般在etc/mysql/my.cnf位置。找到後請在以下三部分裏添加如下內容: 
[client] 
default-character-set = utf8mb4 
[mysql] 
default-character-set = utf8mb4 
[mysqld] 
character-set-client-handshake = FALSE 
character-set-server = utf8mb4 
collation-server = utf8mb4_unicode_ci 
init_connect='SET NAMES utf8mb4'

4. 重啓數據庫,檢查變量

SHOW VARIABLES WHERE Variable_name LIKE 'character_set_%' OR Variable_name LIKE 'collation%';

Variable_name Value
character_set_client utf8mb4
character_set_connection utf8mb4
character_set_database utf8mb4
character_set_filesystem binary
character_set_results utf8mb4
character_set_server utf8mb4
character_set_system utf8
collation_connection utf8mb4_unicode_ci
collation_database utf8mb4_unicode_ci
collation_server utf8mb4_unicode_ci

collation_connection 、collation_database 、collation_server是什麼沒關係。

但必須保證

系統變量 描述
character_set_client (客戶端來源數據使用的字符集)
character_set_connection (連接層字符集)
character_set_database (當前選中數據庫的默認字符集)
character_set_results (查詢結果字符集)
character_set_server (默認的內部操作字符集)

這幾個變量必須是utf8mb4。

5. 數據庫連接的配置

數據庫連接參數中: 
characterEncoding=utf8會被自動識別爲utf8mb4,也可以不加這個參數,會自動檢測。 
而autoReconnect=true是必須加上的。

6. 將數據庫和已經建好的表也轉換成utf8mb4

更改數據庫編碼:ALTER DATABASE caitu99 CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

或 set names utf8mb4

更改表編碼:ALTER TABLE TABLE_NAME CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
如有必要,還可以更改列的編碼

查看錶的編碼: show full columns from TABLE_NAME

 

mysql-connector-java 插入 utf8mb4 字符失敗問題處理分析

原文:https://blog.csdn.net/FHGFHFYUUGY/article/details/90292139

問題說明

業務數據庫實例的編碼由 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 版本, 變更的並不多, 沒有做大的更新, 升級的話不會對已有的功能產生影響. 不過線上升級建議分批操作, 以免存在問題影響所有的業務.

jdbc下載地址:https://repo1.maven.org/maven2/mysql/mysql-connector-java/

 

注:採用第三種方案解決了問題。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章