记住,永远不要在 MySQL 中使用 “utf8” 编码

file

今天发现一个 bug:我尝试着将一个 UTf-8 编码的字符串存储到 MariaDB 的 UTF-8 编码中,然后 Rails 报了一个奇怪的错误:

Incorrect string value: ‘\xF0\x9F\x98\x83 <…’ for column ‘summary’ at row 1

这是一个 UTF-8 的客户端服务端,在一个 UTF-8 编码的数据库里面。这个字符串 「?< …」也是合法的。

但是这里却有点问题: MySQL的 utf8 不是 UTF-8

这个 「utf8」编码只支持每个字符占三个字节。真正的 UTF-8 编码是-每个人都使用,包括你自己-每个字符需要四个字节。

MySQL 的开发人员并没有修复这个 bug,但是他们在 2010 年发布了一个解决方案:一种叫做「utf8mb4」的新编码。

当然,他们并没有对外宣传这件事 (大概是因为这个 bug 太尴尬了),现在网上仍然有很多教程让我们使用「utf8」编码,但都是错的。

简而言之:

  • MySQL 的「utf8mb4」才是真正意义上的「UTF-8」。
  • MySQL 的「utf8」指的是「一种专有的编码」,这种编码有很多 Unicode 字符不能编码。

我在此做一个明确的声明:所有还在使用「utf8」编码的 MySQL 和 MariaDB 的用户实际上应当使用「utf8mb4」编码。而不应该继续使用「utf8」。

什么是编码?什么是 UTF-8?

Joel on Software 博客中有 我最喜欢的介绍。我概括一下它。

计算机以 1 和 0 的形式存储文本。这个段落中的第一个字母被存储为「01000011」,而你的计算机输出「C」。计算机分为两个步骤选择输出「C」:

  • 你的计算机读取「01000011」并确定这个是数字 67。这是因为 67 被编码为「01000011」。
  • 你的计算机在 Unicode 字符集中查找了 67 号字符,发现 67 代表「C」。

当我键入「C」时,同样的事情发生在结尾。

  • 我的计算机在 Unicode 字符集中将「C」映射为 67。
  • 我的计算机将 67 编码,并将「01000011」发送给 Web 服务器。

编码是一个已经被解决的问题,几乎网上所有的程序都使用了 Unicode 编码,因为很多人都不建议使用其他编码。

编码需要考虑的东西更多,Unicode 能编码超过一百万个字符(「C」和「?」是其中的两个字符)。其中有一种简单的编码叫 UTF-32 编码,它将所有的字符都保存为 32 个 bit,这个编码方式很简单,因为计算机处理 32 个 bit 的数据如整数(年龄)时,这是一种很好的编码方式。但是这种编码方式很不实用:太浪费空间了。

UTF-8 保存空格的方式是,在 UTF-8 编码中,常见的字符如「C」存储 8 个 bit,比较生僻的字符如「?」需要 32 个 bit,而其他编码则需要 16 或 24 个 bit。一篇相关的博文提到,UTF-8 编码比 UTF-32 编码减少了四倍的空间消耗,所以 UTF-8 加载速度会更快。

你可能没有意识到这个问题,但是我们的电脑在私底下一直使用 UTF-8 编码。如果电脑不使用 UTF-8 编码,那当我输入「?」时,你将看到一个随机的很混乱的数据。

MySQL 的「utf8」编码不能和其他程序兼容,当需要保存「?」时会出错。

MySQL 的一点历史故事

为什么 MySQL 的开发者使用这种不合理的「utf8」?我们可以通过 MySQL 的 commit 日志猜测一下。

MySQL 从 version 4.1 开始支持 UTF-8,大约是在 2003 的时候,比现在的 UTF-8 标准,RFC 3629 还要早。

而以前使用的是 RFC 2279 这套 UTF-8 标准,这套标准中每个字符 6 个字节。MySQL 开发者在 first pre-pre-release version of MySQL 4.1,也就是 2002 年 3 月 28 号实现 RFC 2279 标准。

然后又发生了一件神奇的事情,MySQL 的源代码在 9 月的版本中有一个字节的调整:「UTF8 最大仅支持 3 个字节序列」。

是谁发起了这个 commit?为什么发布这个 commit?我并不知晓。MySQL 的代码仓库似乎在采用 Git 作为版本控制后丢失了一些曾经的贡献者的名字(MySQL 过去的使用的是 BitKeeper 版本控制,像 Linux 内核一样)。MySQL 官方也没有在 2003 年 9 月的正式版发布时的邮件清单中解释这个变更。

但是我可以猜想。

我们说回 2002 年,MySQL 告诉用户,如果用户需要保证每条记录在表中有相同的字节长度的话,有一套 加速优化方案 可以使用。这套方案要求用户在定义字符字段的时候使用「CHAR」类型。一个「CHAR」类型的字段不管存储的数据内容是什么,存储的字符数量始终相同。如果存储的字符比字段规定的要少,会使用空格在结尾填充,直到数量匹配为止;如果存储的字符比字段规定的要多时,会截断多出来的字符。

当 MySQL 开发人员第一次尝试 UTF-8 时,就是还在使用每个字符 6 个字节这种方案时,他们似乎有些犹豫:一个 CHAR(1) 的列会使用 6 个字节长度;一个 CHAR(2) 的列会使用 12 个字节长度等等。

明确的说:他们最初的没有正式发布的做法是正确的,这种解决方案是有据可查且广泛适用的,任何一个只要是理解 UTF-8 编码的人都会认同这个方案。

但是很明显,MySQL 开发人员(或者是发行商)考虑到可能有一些用户会做这两件事情:

  • 使用 CHAR 类型字段。(CHAR 字段现在基本没人使用了。但是在当时,MySQL 中使用 CHAR 字段会有更优的速度,但是在 2005 年之后却并非如此)
  • CHAR 字段列的编码设置为「utf8」。

我的猜测是 MySQL 开发人员为了方便那些既想优化空间和时间性能,又没能成功优化空间和时间性能的用户,废弃了原有的「utf8」编码。

然而没有人能得到自己想要的。想要优化时间和空间性能的用户仍旧在错误的使用「utf8」CHAR 字段,但是这些字段往往会很大,导致 MySQL 的速度比不使用 CHAR 的时候还要慢。而那些想要使用真正的 UTF-8 的用户错误的使用了「utf8」,导致他们不能存储「?」这类字符。

但是,一旦 MySQL 发布这个无用的字符编码,就再也不能修复它了:因为这会令所有的用户都要重新构建每一个数据库。所以 MySQL 最终在 2010 年发布了真正的 UTF-8,不过使用了另一个名字「utf8mb4」。

为什么这么糟糕

我这个星期很心情很糟糕,由于我被「utf8」这个名字给愚弄了,令我找 bug 很艰难,并且我并不是唯一一个被愚弄的,几乎我在网上找的所有文章都在吹捧「utf8」是 UTF-8。

命名为「utf8」本就是一个错误,这是一套专有的字符编码。这套编码导致了很多新的问题,并且这套编码并不能解决它所说的能解决的问题。

这是虚假宣传。

我的课程总结

  • 数据库系统有微妙的错误和异常,你可以通过数据库的选择避免许多错误。
  • 如果你需要一个数据库, 不要使用 MySQL 或者 MariaDB。 使用 PostgreSQL
  • 如果你需要使用 MySQL或 MariaDB,千万不要使用「UTF8」。当你想要UTF-8时,总是使用「UTF8Mb4」。现在去 转换你的数据库吧避免发生头痛的事。
本作品采用《CC 协议》,转载必须注明作者和本文链接
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 16

没有评论?言之凿凿.抛砖引玉吧.

5年前 评论
北冥

我就遇到这个问题了,utf8存一个字符报错,折腾了很长时间,最后改utf8mb4解决的

5年前 评论

微信存储用户发来的信息的时候,表情就会报错。。现在的做法是没有体会编码格式,而是检测到有表情直接替换为空了。

5年前 评论

我有个问题,如何将utf8的数据库改为utf8mb4?我试过直接修改mysql的字符集好像原来的旧数据并不会生效?

5年前 评论
月光

为什么 MariaDB 也不行

5年前 评论

使用utf8mb4也会有很多坑…… 数据库连接的编码一定也要改

@Ali
utf8直接存表情肯定会遇到一些问题,直接替换为空不太好吧,可以编码后再存储:

    function nickname_encode($s){
        $chars = preg_split('//u', $s, null, PREG_SPLIT_NO_EMPTY);
        foreach ($chars as &$c) {
            if (strlen($c) > 3 || $c === "%"){
                $c = urlencode($c);
            }
        }

        return implode($chars);
    }

解码直接用urldecode就行了。

5年前 评论

@Clarencep 领导要求不存,说用不到。只有听领导的喽

5年前 评论

@Ali ? 现在好多人都喜欢用emoji表情的…… 看来你们领导是不喜欢用emoji表情的

5年前 评论

@Clarencep 我们领导说。就为了记录一下用户问的那种问题比较多。而表情不重要。?

5年前 评论
你看我吊吗啊

file

我这到底选啥。。

5年前 评论
Jennie

我一直用的是utf8mb4,还没报过错。:laughing:

5年前 评论

我就是一直被坑的其中之一

5年前 评论

受教了,战略性mark

5年前 评论

之前存表情就被坑过

5年前 评论

前几年都是用utf8,这几年都是微信相关的开发,微信昵称.....,已经把习惯改成了utf8mb4了....

5年前 评论

Mysql 的 UTF8 是当年支持双字节编码的方式,因此最大每个字只有3字节。像GBK、Big5、Shift-JIS等。后来,Unicod为了统一世界编码,使用了4字节的编码方式。因此原本的UTF8是无法支持的,需要使用最大6字节的方式来支持。为此,mysql也增加了新的编码格式utf8mb4

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!