数据库内核月报

数据库内核月报 - 2017 / 03

MySQL · 实现分析 · 对字符集和字符序支持的实现

Author: 元镇

前言

在使用MySQL数据库的时候,常常会发现由于charset或collation设置不正确导致的各种问题。一方面由于数据在client和server之间传输需要做转换会导致CPU使用率增加;另一方面由于charset或collation设置的不一致在查询过程中无法使用索引而导致全表扫描。比如数据库的charset是utf8,collation是utf8_general_ci,而client或connection设置的collation是utf8_unicode_ci,就会导致性能问题。所以我们在创建及使用数据库的时候一定要当心,尽可能减少由于charset或collation设置不对,而造成的不必要的麻烦。这篇文章就简单的介绍一下charset和collation在MySQL中的实现和几个关键的数据结构,以加深对MySQL中charset和collation的理解。

基础知识

字符和字符集(Character and Character set):那什么是字符呢?在计算机领域,我们把诸如文字、标点符号、图形符号、数字等统称为字符,包括各国家文字、标点符号、图形符号、数字等。而由字符组成的集合则成为字符集,是一个系统支持的所有抽象字符的集合。字符集由于包含字符的多少与异同而形成了各种不同的字符集,字符集种类较多,每个字符集包含的字符个数不同。我们知道,所有字符在计算机中都是以二进制来存储的。那么一个字符究竟由多少个二进制位来表示呢?这就涉及到字符编码的概念了。常见字符集名称:ASCII字符集、GB2312字符集、GBK字符集、GB18030字符集、Unicode字符集等。

字符编码(Character Encoding):字符编码也称字符码,是把字符集中的字符编码为指定集合中某一对象(例如:比特模式、自然数序列、8位组),以便文本在计算机中存储和通过通信网络传输。我们规定字符编码必须完成如下两件事:1)规定一个字符集中的字符由多少个字节表示;2)制定该字符集的字符编码表,即该字符集中每个字符对应的(二进制)值。

字符序(Collation):是一组在指定字符集中进行字符比较的规则,比如是否忽略大小写,是否按二进制比较字符等等。

MySQL中的字符集和字符序

MySQL服务器可以支持多种字符集,不同的库,不同的表盒不同的字段都可以使用不同的字符集。MySQL中的字符序名称遵从命名惯例:以字符序对应的字符集名称开头;以_ci(表示大小写不敏感)、_cs(表示大小写敏感)或_bin(表示按编码值比较)结尾。例如:在字符序“utf8_general_ci”下,字符“a”和“A”是等价的。MySQL可以使用SHOW CHARACTER SET; 命令查看支持哪些字符集和SHOW COLLATION则会显示出所有支持的字符序。

mysql> show character set;
+----------+-----------------------------+---------------------+--------+
| Charset  | Description                 | Default collation   | Maxlen |
+----------+-----------------------------+---------------------+--------+
| big5     | Big5 Traditional Chinese    | big5_chinese_ci     |      2 |
| dec8     | DEC West European           | dec8_swedish_ci     |      1 |
| cp850    | DOS West European           | cp850_general_ci    |      1 |
| hp8      | HP West European            | hp8_english_ci      |      1 |
| koi8r    | KOI8-R Relcom Russian       | koi8r_general_ci    |      1 |
| latin1   | cp1252 West European        | latin1_swedish_ci   |      1 |
| latin2   | ISO 8859-2 Central European | latin2_general_ci   |      1 |
| swe7     | 7bit Swedish                | swe7_swedish_ci     |      1 |
| ascii    | US ASCII                    | ascii_general_ci    |      1 |
| ujis     | EUC-JP Japanese             | ujis_japanese_ci    |      3 |
| sjis     | Shift-JIS Japanese          | sjis_japanese_ci    |      2 |
| hebrew   | ISO 8859-8 Hebrew           | hebrew_general_ci   |      1 |
| tis620   | TIS620 Thai                 | tis620_thai_ci      |      1 |
| euckr    | EUC-KR Korean               | euckr_korean_ci     |      2 |
| koi8u    | KOI8-U Ukrainian            | koi8u_general_ci    |      1 |
| gb2312   | GB2312 Simplified Chinese   | gb2312_chinese_ci   |      2 |
| greek    | ISO 8859-7 Greek            | greek_general_ci    |      1 |
| cp1250   | Windows Central European    | cp1250_general_ci   |      1 |
| gbk      | GBK Simplified Chinese      | gbk_chinese_ci      |      2 |
| latin5   | ISO 8859-9 Turkish          | latin5_turkish_ci   |      1 |
| armscii8 | ARMSCII-8 Armenian          | armscii8_general_ci |      1 |
| utf8     | UTF-8 Unicode               | utf8_general_ci     |      3 |
| ucs2     | UCS-2 Unicode               | ucs2_general_ci     |      2 |
| cp866    | DOS Russian                 | cp866_general_ci    |      1 |
| keybcs2  | DOS Kamenicky Czech-Slovak  | keybcs2_general_ci  |      1 |
| macce    | Mac Central European        | macce_general_ci    |      1 |
| macroman | Mac West European           | macroman_general_ci |      1 |
| cp852    | DOS Central European        | cp852_general_ci    |      1 |
| latin7   | ISO 8859-13 Baltic          | latin7_general_ci   |      1 |
| utf8mb4  | UTF-8 Unicode               | utf8mb4_general_ci  |      4 |
| cp1251   | Windows Cyrillic            | cp1251_general_ci   |      1 |
| utf16    | UTF-16 Unicode              | utf16_general_ci    |      4 |
| utf16le  | UTF-16LE Unicode            | utf16le_general_ci  |      4 |
| cp1256   | Windows Arabic              | cp1256_general_ci   |      1 |
| cp1257   | Windows Baltic              | cp1257_general_ci   |      1 |
| utf32    | UTF-32 Unicode              | utf32_general_ci    |      4 |
| binary   | Binary pseudo charset       | binary              |      1 |
| geostd8  | GEOSTD8 Georgian            | geostd8_general_ci  |      1 |
| cp932    | SJIS for Windows Japanese   | cp932_japanese_ci   |      2 |
| eucjpms  | UJIS for Windows Japanese   | eucjpms_japanese_ci |      3 |
+----------+-----------------------------+---------------------+--------+
40 rows in set (0.00 sec)

默认的字符集和字符序可以在实例启动时在命令行指定,也可以在启动之前在my.cnf或my.ini里配置,然后启动实例。

在[client]下添加

default-character-set=utf8

default-collation=utf8_general_ci

在[mysqld]下添加

collation-server=utf8_general_ci

character-set-server=utf8

也可以分别在创建数据库、表时指定。

CREATE TABLE `mysqlcode` (
`id` TINYINT( 255 ) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ,
`content` VARCHAR( 255 ) NOT NULL
) ENGINE = INNODB CHARACTER SET gbk COLLATE gbk_chinese_ci;

MySQL字符集源码实现的关键结构体

在MySQL中,每个字符集可以有多个字符序与之对应,而一个字符序只能对应一个字符集。根据字符序的命名规则我们也可以很直观的看出来某个字符序与哪个字符集对应。每种字符集都要对应某个字符序,才能够进行字符之间的比较和排序等处理,所以在MySQL实现中会为每个字符集和其对应的每个字符序组成一对。若是在使用中只指定了字符集而没有指定字符序,就会使用这个字符集的默认字符序。 在内部使用CHARSET_INFO结构 来表示,在5.6版本中此结构定义如下:

typedef struct charset_info_st
{
  uint      number;
  uint      primary_number;
  uint      binary_number;
  uint      state;
  const char *csname;
  const char *name;
  const char *comment;
  const char *tailoring;
  uchar    *ctype;
  uchar    *to_lower;
  uchar    *to_upper;
  uchar    *sort_order;
  MY_UCA_INFO *uca;
  uint16      *tab_to_uni;
  MY_UNI_IDX  *tab_from_uni;
  MY_UNICASE_INFO *caseinfo;
  uchar     *state_map;
  uchar     *ident_map;
  uint      strxfrm_multiply;
  uchar     caseup_multiply;
  uchar     casedn_multiply;
  uint      mbminlen;
  uint      mbmaxlen;
  my_wc_t   min_sort_char;
  my_wc_t   max_sort_char; /* For LIKE optimization */
  uchar     pad_char;
  my_bool   escape_with_backslash_is_dangerous;
  uchar     levels_for_compare;
  uchar     levels_for_order;

  MY_CHARSET_HANDLER *cset;
  MY_COLLATION_HANDLER *coll;

} CHARSET_INFO;

name字段,定义了这个字符集和字符序对的名字。

ctype字段是一个指向长度为257的一个字符数组,每个值记录了在这个字符集相对应的字符的属性掩码。比如,这个字符是否是数字、字符、分隔符等。这些值都是经过预计算的,第一个0是无效的,这也是为什么my_isalpha(s, c)定义里面ctype要先+1的原因。通过MY_U、_MY_L、_MY_NMR 、_MY_SPC、_MY_PNT 等的定义,可以知道,这些值肯定是按照相应的ASCII码的具体意义进行置位的。比如字符’A’,其ASCII码为65,其实大写字母,故必然具有MY_U,即第0位必然为1,找到ctype里面第66个(略过第一个无意义的0)元素,为129 = 10000001,显然第0位为1(右边起),说明为大写字母。

to_lowerto_uppper:分别是指向字符集小写和大写字符数组的指针。
sort_order则记录了此字符集排序比较时每个字符对应使用的编码。

其实对于以上几个字段主要是用来处理字符集中的ASCII字符的。而对于像中文、日文、韩文这样的多字节字符是没有大小写之分的。

在CHARSET_INFO结构 结构中,还有两个重要的字段是csetcoll,它们分别为这个字符集定义了处理字符和进行排序比较等所需要函数的句柄集合。字符集句柄结构MY_CHARSET_HANDLER主要提供了处理这个字符集字符串所需要的函数,一共有二十多个,比如判断一个字符串中字符的个数、查找一个字符在字符串的位置、字符串大小写的转换以及将此字符集编码的数字字符转换成数字等。在字符集句柄中有两个函数指针mb_wc和wc_mb,这里特别提一下,它们分别是将此字符集中的字符转换成unicode字符的函数和将unicode字符转换成此字符集中对应字符的函数,每一个字符集都要实现这两个函数,这样才能保证此字符集和其它字符集之间的转换。

typedef struct my_charset_handler_st
{
  // ......
  /* Unicode conversion */
  my_charset_conv_mb_wc mb_wc;
  my_charset_conv_wc_mb wc_mb;

  // ......
}

而字符序句柄主要提供了这个字符集中字符串排序、比较等操作所需要的函数。在字符集和字符序处理句柄里包含了要处理这种字符所需要的所有函数指针,我们可以理解成是虚函数,每个字符集和字符序有自己的实现。我们要实现一个新的字符集或字符序时,就要提供这个函数的实现,这样当用到指定的字符集和字符序时就会调用到具体的实现的函数了。

MySQL字符集之间的转换

在MySQL的server和client之间、server和connection之间、已经connection和result set之间、所使用的字符集可能不一致,这就需要字符集之间的转换,才能保证字符存储和显示的正确。在MySQL中字符集之间的转换,主要是通过my_convert()->my_convert_internal()。在my_convert_internal()中的实现代逻辑如下:

my_convert_internal(char *to, uint32 to_length,
                    const CHARSET_INFO *to_cs,
                    const char *from, uint32 from_length,
                    const CHARSET_INFO *from_cs, uint *errors)
{
   // ......
  my_charset_conv_mb_wc mb_wc= from_cs->cset->mb_wc;
  my_charset_conv_wc_mb wc_mb= to_cs->cset->wc_mb;
  uint error_count= 0;

  while (1)
  {
    if ((cnvres= (*mb_wc)(from_cs, &wc, (uchar*) from, from_end)) > 0)
    // ......

outp:
    if ((cnvres= (*wc_mb)(to_cs, wc, (uchar*) to, to_end)) > 0)
    // ......

  return (uint32) (to - to_start);
}

mb_wc是一个函数指针,它是要转换的源字符集句柄的mb_wc函数指针,目的是将源字符集中的字符转换成对应的unicode字符;wb_mb函数指针是要转换成目标字符集句柄的wc_mb函数,目的是将unicode字符转换成目的字符函数。 通过这段代码可以看出在MySQL中两个字符集之间的转换不是直接进行的,而是通过unicode间接转换的。

GBK字符集的实现

我们以GBK字符集和它默认的字符gbk_chinese_ci序为例,看看它的实现是怎么样的。首先它的字符集和字符序对的结构定义如下:

CHARSET_INFO my_charset_gbk_chinese_ci=
{
    28,0,0,     /* number */
    MY_CS_COMPILED|MY_CS_PRIMARY|MY_CS_STRNXFRM,    /* state      */
    "gbk",      /* cs name    */
    "gbk_chinese_ci",   /* name */
    "",         /* comment    */
    NULL,       /* tailoring */
    ctype_gbk,
    to_lower_gbk,
    to_upper_gbk,
    sort_order_gbk,
    //   ...
    &my_charset_handler,
    &my_collation_ci_handler
};

我们可以看到上面介绍过的ctypeto_lowerto_upppersort_order数组的实现,它们分别是ctype_gbkto_lower_gbkto_upper_gbk,sort_order_gbk外,还有t非常重要的句柄cset的实现,我们可以进一步去看看gbk的字符集句柄的实现:

static MY_CHARSET_HANDLER my_charset_handler=
{
  // ......
  my_mb_wc_gbk,
  my_wc_mb_gbk,
  // ......
};

其中的my_mb_wc_gbkmy_wc_mb_gbk函数的实现,就是实现gbk字符集和其它字符集转换用到的函数。就像MySQL字符集之间的转换节所讲的,任意两个字符集之间的转换在MySQL中并不是直接进行的,而是中间通过unicode编码实现的,都要先转换成unicode,然后再转换成目标编码。my_mb_wc_gbk就是用来实现讲gbk字符转换成unicode字符的函数,相反,my_wc_mb_gbk函数则是用来讲unicode字符转换成gbk字符的函数。通过这些函数的实现就可以将gbk编码的字符转换成数据、转换大小写、查找字符在字符串中的位置等常规的字符串操作了。

通过配置实现一个新字符序的例子

从MySQL的角度来讲,字符集分成简单字符集和复杂字符集。简单字符集就是排序时不需要特殊的字符串排序函数,也不包含多字节字符;否则,就是复杂字符集。对于简单字符集,MySQL提供了简单的配置接口,通过这个接口不需要改动源代码,就可以支持新的字符集和其字符序,实例在启动时会自动把配置的简单字符集装载进来,其实现核心源代码在charset.c中,把所有通过配置添加的字符集和字符序转载进实例里,其核心也是为这些字符集和字符序对创建CHARSET_INFO,MY_CHARSET_HANDLER和MY_COLLATION_HANDLER结构体。而复杂字符集就需要改动源代码,通过实现以上所介绍的主要三个接口结构(CHARSET_INFO,MY_CHARSET_HANDLER和MY_COLLATION_HANDLER)。

我们经常看到电话号码,但写法格式不统一。比如电话号码18612345678,可以有如下等多种写法: +86-18612345678,(86)18612345678,86-186-1234-5678, +8618612345678,其实都是表示一个电话号码。若电话号码用上述各种格式存储在数据库中,查找某个电话号码时会变得比较困难。为了解决这个问题,我们可以定义一个电话号码的字符序,使得这个字符序会忽略其中的+、-、()及空格等字符。这样就比较容易找的一个特定的电话号码了。下面的例子是为utf8字符集添加一个电话号码比较的字符序。

具体方法如下:

1)先查找一个空闲的字符序ID。通过查找INFORMATION_SCHEMA.COLLATIONS表中的ID,可以发现那些ID已经被使用了,找一个空闲未使用的即可。这个我们可以选择1029.

2)修改Index.xml文件。将要定义的字符序加入到指定的字符集中。character_sets_dir指定了Index.xml所在的目录。

mysql> SHOW VARIABLES LIKE 'character_sets_dir';
+--------------------+----------------------------------------------------------+
| Variable_name      | Value                                                    |
+--------------------+----------------------------------------------------------+
| character_sets_dir | /home/guangbao.ngb/mysql_polar/u01/mysql/share/charsets/ |
+--------------------+----------------------------------------------------------+
1 row in set (0.01 sec)

3)为新定义的字符序定义一个名字,然后把这个字符序加入到Index.xml的utf8字符集下面的一个新的字符序段落中。比如: utf8_phone_ci

<charset name="utf8">
  ...
  <collation name="utf8_phone_ci" id="1029">
    <rules>
      <reset>\u0000</reset>
      <i>\u0020</i> <!-- space -->
      <i>\u0028</i> <!-- left parenthesis -->
      <i>\u0029</i> <!-- right parenthesis -->
      <i>\u002B</i> <!-- plus -->
      <i>\u002D</i> <!-- hyphen -->
    </rules>
  </collation>
  ...
</charset>

4)重启实例,然后你就可以看到新加入的字符序了。

mysql> SHOW COLLATION WHERE Collation = 'utf8_phone_ci';
+---------------+---------+------+---------+----------+---------+
| Collation     | Charset | Id   | Default | Compiled | Sortlen |
+---------------+---------+------+---------+----------+---------+
| utf8_phone_ci | utf8    | 1029 |         |          |       8 |
+---------------+---------+------+---------+----------+---------+
1 row in set (0.02 sec)

这个字符序就可以使用了,比如:

mysql> CREATE TABLE phonebook (
         name VARCHAR(64),
         phone VARCHAR(64) CHARACTER SET utf8 COLLATE utf8_phone_ci
       );
Query OK, 0 rows affected (0.09 sec)
mysql> INSERT INTO phonebook VALUES ('ngbao','+86-18612345678');

查询字符串你可以写成任何一种,都能够查到这条记录。比如+8618612345678 、8618612345678或86-18612345678。