1. 问题
1.1 发现问题
最近生产环境日志中报了一个异常:
12021-07-11 20:54:41.632 ERROR 26289 --- [XNIO-1 task-11] c.e.t.s.t.mp.WxMpMessageRouterService :
2### Error updating database. Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
3### The error may exist in com/eveus/tap/account/mapper/AccountMapper.java (best guess)
4### The error may involve com.eveus.tap.account.mapper.AccountMapper.insert-Inline
5### The error occurred while setting parameters
6### SQL: INSERT INTO tap_account ( open_id, avatar, nick_name, country, province, city, gender, secret, status, IS_DISABLED, is_subscribe, create_time, update_time ) VALUES
7 ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
8### Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
9; uncategorized SQLException; SQL state [HY000]; error code [1366]; Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1; nested exception is java.sql.SQLException:
10 Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
11
12org.springframework.jdbc.UncategorizedSQLException:
13### Error updating database. Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
14### The error may exist in com/eveus/tap/account/mapper/AccountMapper.java (best guess)
15### The error may involve com.eveus.tap.account.mapper.AccountMapper.insert-Inline
16### The error occurred while setting parameters
17### SQL: INSERT INTO tap_account ( open_id, avatar, nick_name, country, province, city, gender, secret, status, IS_DISABLED, is_subscribe, create_time, update_time ) VALUES
18 ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
19### Cause: java.sql.SQLException: Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
20; uncategorized SQLException; SQL state [HY000]; error code [1366]; Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1; nested exception is java.sql.SQLException:
21 Incorrect string value: '\xF0\x9F\xA6\x84' for column 'NICK_NAME' at row 1
22 at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:89)
23 at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
24 at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:81)
25 at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:88)
26 at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:440)
27 at com.sun.proxy.$Proxy134.insert(Unknown Source)
28 at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:271)
29 at com.baomidou.mybatisplus.core.override.MybatisMapperMethod.execute(MybatisMapperMethod.java:60)
30 at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:96)
31 at com.sun.proxy.$Proxy186.insert(Unknown Source)
32 at com.baomidou.mybatisplus.extension.service.IService.save(IService.java:59)
33 at com.eveus.tap.account.service.impl.AccountServiceImpl.addAccount(AccountServiceImpl.java:151)
34 at com.eveus.tap.account.service.impl.AccountServiceImpl.ensureLoad(AccountServiceImpl.java:226)
35 at com.eveus.tap.account.service.impl.AccountServiceImpl$$FastClassBySpringCGLIB$$34fe95c8.invoke(<generated>)
36 at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
37 at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:769)
38 at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
39 at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:747)
40 at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:95)
41 at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
42 at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:747)
43 at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:689)
44 at com.eveus.tap.account.service.impl.AccountServiceImpl$$EnhancerBySpringCGLIB$$cbb1e26b.ensureLoad(<generated>)
45 at com.eveus.tap.server.taxer.mp.handler.SubscribeHandler.handle(SubscribeHandler.java:54)
46 at me.chanjar.weixin.mp.api.WxMpMessageRouterRule.service(WxMpMessageRouterRule.java:226)
47 at me.chanjar.weixin.mp.api.WxMpMessageRouter.route(WxMpMessageRouter.java:203)
48 at me.chanjar.weixin.mp.api.WxMpMessageRouter.route(WxMpMessageRouter.java:154)
49 at me.chanjar.weixin.mp.api.WxMpMessageRouter.route(WxMpMessageRouter.java:233)
看错误信息是因为 MySQL
字符编码不正确导致无法保存特殊字符导致的。把字符还原一下,发现 \xF0\x9F\xA6\x84
代表的字符内容是:🦄,是一个 Emoji
字符。
当使用对 utf8
编码的时候,和一般汉字占用3个字节不同,Emoji
字符的编码会比较特殊一点,占用4个字节。而由于历史的原因,MySQL
使用的 utf8
编码最长支持3个字节,如果要保存4个字节的 unicode
,则需要使用 utf8mb4
编码。上述错误应该是因此而出。
1.2 寻找原因
让我奇怪的是,这个问题应该不会出现才对啊,因为这个表需要存储从微信获取的用户昵称,当时已经考虑了可能存在 Emoji
字符,因此该表的编码已经被修改成了 utf8mb4
。查看一下表结构,现有的表结构定义如下:
1CREATE TABLE `tap_account` (
2 `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
3 `AVATAR` varchar(256) DEFAULT NULL COMMENT '头像,微信授权,用户头像,最后一个数值代表正方形头像大小(有0、46、64、96、132数值可选,0代表640*640正方形头像)',
4 `NICK_NAME` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
5 ...
6 PRIMARY KEY (`ID`),
7 UNIQUE KEY `tap_account_OPEN_ID_uindex` (`OPEN_ID`)
8) ENGINE=InnoDB AUTO_INCREMENT=72 DEFAULT CHARSET=utf8mb4 COMMENT='自然人账号表';
可以看到目前的字符集已经配置成了 utf8mb4
,按理说应该能够保存成功才对。
为了验证一下数据库是否修改正确,直接使用sql修改记录试试:
1update tap_account set nick_name='千面怪🦄' where id=1;
发现更新确实可以成功,没有错误发生;而且也可以正常查询到的修改后的结果。这说明表方面应该没有问题。
因为之前考虑到其他表没有存储 Emoji
表情的需要,所以没有在数据库层面整体修改编码。也就是有一部分表还是使用 utf8
编码,只有这个表 tap_account
使用了 utf8mb4
编码。看一下 MySQL
的系统变量:
1MySQL [tap_admin]> show variables like '%character%';
2+--------------------------+---------+
3| Variable_name | Value |
4+--------------------------+---------+
5| character_set_client | utf8mb4 |
6| character_set_connection | utf8mb4 |
7| character_set_database | utf8 |
8| character_set_filesystem | binary |
9| character_set_results | utf8mb4 |
10| character_set_server | utf8 |
11| character_set_system | utf8 |
12| character_sets_dir | |
13+--------------------------+---------+
148 rows in set (0.001 sec)
可以看到服务器默认的编码(character_set_server)为 utf8
。
是不是因为这个原因导致的问题呢?
2. 关于编码自动检测
我使用的 MySQL
库为 mysql-connector-java
,版本为 8.0.19
。配置的连接字符串如下:
1spring:
2 datasource:
3 url: jdbc:mysql://rm-uecp.mysql.rds.aliyuncs.com:3306/tap_admin?useUnicode=true&serverTimezone=GMT%2b8:00
连接字符串中只是使用了useUnicode
开启了unicode
支持,并没有指定具体的字符编码。这时会用到 mysql-connector-java
的自动检测机制。根据 MySQL
官方文档的说明,这个检测机制会用到 character_set_server
这个参数。
The character encoding between client and server is automatically detected upon connection (provided that the Connector/J connection properties characterEncoding and connectionCollation are not set). You specify the encoding on the server using the system variable character_set_server (for more information, see Server Character Set and Collation). The driver automatically uses the encoding specified by the server. For example, to use the 4-byte UTF-8 character set with Connector/J, configure the MySQL server with character_set_server=utf8mb4, and leave characterEncoding and connectionCollation out of the Connector/J connection string. Connector/J will then autodetect the UTF-8 setting.
当在连接字符串中没有设置 charcterEncoding
的时候,将自动使用MySQL系统变量 character_set_server
中指定的字符集。如果需要覆盖以上检测机制,可以指定 characterEncoding
变量:
To override the automatically detected encoding on the client side, use the characterEncoding property in the connection URL to the server. Use Java-style names when specifying character encodings. The following table lists MySQL character set names and their corresponding Java-style names:
特别的对于 characterEncoding=utf8
,不同版本的 mysql-connector-java
处理上是有些区别的:
简单来说:8.0.13及以上版本,会自动使用 utf8mb4
;而之前版本使用的则是 utf8mb3
(也就是通常的utf8
编码)。
3. 原因分析
现在来看一下为什么数据库能够保存 Emoji
字符,但是通过 Java
却无法保存。
3.1 utf8mb4
支持条件
首先总结一下支持 utf8mb4
需要的前置条件。
3.1.1 数据库版本
utf8mb4
需要的最低mysql版本为 5.5.3+
,若不是,则需要升级。可以使用以下 SQL
查询一下当前的版本号:
1select version();
3.1.2 修改数据库、表或字段的字符集
可以使用以下SQL修改数据库、表或字段支持 utf8mb4
编码:
1-- 修改数据库编码
2ALTER DATABASE database_name CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
3-- 修改表编码
4ALTER TABLE table_name CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
5-- 修改字段编码
6ALTER TABLE table_name CHANGE column_name VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
经过实验,确认可以只修改表支持 utf8mb4
即可,数据库层面可以保持 utf8
编码不用修改。
3.1.3 MySQL客户端
mysql connector
需要至少大于5.1.13,否则无法支持 utf8mb4
。另外还要注意8.0.13前后支持形式有所区别。
3.1.4 链接字符串
需要在 mysql-connector-java
链接字符串中增加 useUnicode=true&characterEncoding=utf8mb4
来开启 utf8mb4
支持。
3.2 结论
我用的mysql-connector-java
版本是 8.0.19
,大于8.0.13
,所以使用 characterEncoding=utf8
就相当于支持了 utf8mb4
编码。
但是因为我没有将整个数据库的编码改为 utf8mb4
,所以不指定该参数的时候会被自动检测为 utf8
编码,导致保存失败。
最后将连接字符串改成这样问题就解决了:
1spring:
2 datasource:
3 url: jdbc:mysql://rm-uecp.mysql.rds.aliyuncs.com:3306/tap_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
4. 总结
总结一下,这个问题出现的原因是因为没有更改数据库的字符编码,导致 mysql-connector-java
的自动检测机制做出了错误的假设,没有设置为需要的 utf8mb4
编码。
另外从网上查到的资料一般说的都是同时修改数据库、表的字符集,没有我这种只修改表的字符集,而数据库字符集没更改的情况。遇到这种特殊情况还是要自己多做实验,实践出真知。