MySQL关于varchar排序你不知道的秘密

目录

引言-VARCHAR 类型字段排序

你是否遇到过在 MySQL​ ​中对 VARCHAR​ ​类型进行排序的需求?比如在医疗系统的医师接诊业务中,由于病人可能是通过线下就诊、或者线上预约的形式进行挂号,由于号源不同,诊号的生成逻辑则会不同,如线上预约的诊号可能会在诊号前加入"预约"二字的标识符,所以诊号的字段存储会用到 VARCHAR​ ​类型,在医师的接诊列表中则需要使用诊号进行排序,所以如果在 MySQL​ ​中基于下表的表设计实现该需求,那么实现方式如下:

sql 复制代码
CREATE TABLE registration_record (
        id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
        patient_id BIGINT NOT NULL COMMENT '患者ID',
        doctor_id BIGINT NOT NULL COMMENT '医生ID',
        department_id BIGINT NOT NULL COMMENT '科室ID',
        reg_num VARCHAR(50) NOT NULL COMMENT '诊号',
        reg_date DATE NOT NULL COMMENT '挂号日期',
        reg_time TIME NOT NULL COMMENT '挂号时间',
        reg_type int(1) NOT NULL COMMENT '挂号类型('1:普通号', '2:专家号', '3:急诊号')',
        visit_status int(1) NOT NULL COMMENT '就诊状态('1:待诊', '2:诊中', '3:已完成', '4:取消')',
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
        INDEX idx_doctor_date (doctor_id, reg_date)
    ) COMMENT '挂号记录表';

    create index idx_reg_num
        on registration_record (reg_num);

    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (1, 1, 101, 201, '1', '2024-11-19', '08:00:00', 1, 1, '2024-11-19 12:06:17', '2024-11-19 12:06:17');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (2, 2, 102, 202, '2', '2024-11-19', '08:30:00', 2, 1, '2024-11-19 12:06:17', '2024-11-19 12:06:17');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (3, 3, 103, 203, '3', '2024-11-19', '09:00:00', 3, 2, '2024-11-19 12:06:17', '2024-11-19 12:06:17');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (4, 4, 104, 204, 'b', '2024-11-19', '09:30:00', 1, 1, '2024-11-19 12:06:17', '2024-12-16 13:03:57');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (5, 5, 105, 205, '预约1', '2024-11-19', '10:00:00', 2, 1, '2024-11-19 12:06:17', '2024-11-19 12:18:10');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (6, 6, 106, 206, '预约2', '2024-11-19', '10:30:00', 3, 1, '2024-11-19 12:06:17', '2024-11-19 12:18:10');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (7, 7, 107, 207, '7', '2024-11-19', '11:00:00', 1, 3, '2024-11-19 12:06:17', '2024-11-19 12:06:17');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (8, 8, 108, 208, 'A', '2024-11-19', '11:30:00', 2, 4, '2024-11-19 12:06:17', '2024-12-16 13:03:57');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (9, 9, 109, 209, '98', '2024-11-20', '12:00:00', 3, 2, '2024-11-19 12:06:17', '2024-12-17 12:36:27');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (10, 10, 110, 210, '99', '2024-11-20', '08:00:00', 1, 1, '2024-11-19 12:06:17', '2024-11-19 12:18:10');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (11, 11, 111, 211, '120', '2024-11-20', '08:30:00', 2, 2, '2024-11-19 12:06:17', '2024-12-17 12:36:27');
    INSERT INTO registration_record (id, patient_id, doctor_id, department_id, reg_num, reg_date, reg_time, reg_type, visit_status, created_at, updated_at) VALUES (12, 12, 112, 212, '121', '2024-11-20', '09:00:00', 3, 3, '2024-11-19 12:06:17', '2024-12-17 12:36:27');

执行 SQL 如下:

sql 复制代码
 select * from registration_record where reg_date = '2024-11-19' order by reg_num desc;

执行结果:

看到结果,你可能会感到疑惑:121 明明比 98 更大,为什么 121 排在了 98 的后面?我将在下文解释问题出现的原因。

VARCHAR 数据类型的特点

在本文开始之前先来了解一下关于 VARCHAR​ ​类型的基础知识。

VARCHARMySQL 中用于存储数字字符串、英文、中文等短文本,如:用户名、邮箱、UUID​ ​等可变长度的数据类型。相比于 CHAR​ ​类型,VARCHAR​ ​类型仅存储实际字符串内容,而 CHAR​ ​类型在字符串不足字段长度时会用空格向右填充到指定的长度。相比于 TEXT​ ​类型,VARCHAR​ ​类型则在排序、查询场景具有更强的性能。

类型 容纳长度(字节) 查询性能 适用场景
CHAR 0~255 身份证号、手机号等定长数据,索引效率高,但存在空间浪费风险
VARCHAR 0~65,535 适中 用户名、标题等理想选择,索引效率较高
TEXT 0~∞(无限) 适合存储大文本内容,存在索引受限问题,不适合频繁查询

存储相同的数据,CHARVARCHAR 在空间占用上可以参考 MySQL 官网给到的内容:

下表 通过显示将各种字符串值存储到 CHAR​ ​和 列(假设该列使用单字节字符集,例如)的结果来说明和之间的差异。

内容 ​CHAR(4)​ 需要存储 ​VARCHAR(4)​ 需要存储
​''​ ​' '​ 4 字节 ​''​ 1 字节
​'ab'​ ​'ab '​ 4 字节 ​'ab'​ 3 个字节
​'abcd'​ ​'abcd'​ 4 字节 ​'abcd'​ 5 个字节
​'abcdefgh'​ ​'abcd'​ 4 字节 ​'abcd'​ 5 个字节

参考自:

11.3.2 CHAR 和 VARCHAR 类型

MySQL 8.0 VARCHAR 类型排序的基本原理

MySQL 数据表中的字段排序受字符集 (Character Set)​ ​和排序规则 (Collation)​ ​约束,字符集是一组可用字符(可以理解为字典中记录的文字范围)和编码方式,其主要作用是定义可以使用的字符范围和编码方式。 排序规则是一组用于比较字符集中字符的规则。可以在 MySQL 中使用 SHOW CHARACTER SET;​ ​命令来查看当前版本支持的字符集。

一个给定的字符集存在一个或多个排序规则,并有且只有一个默认的排序规则,但是两个不同的字符集不能具有相同的排序规则。使用 SHOW COLLATION WHERE Charset = 'xxxx';​ ​命令可以插卡字符集的排序规则。以 utf8mb4​ ​为例:utf-8​ ​是一个 Unicode​ 字符集,支持几乎所有常用语言的字符(如英文、中文、阿拉伯文等),但是 utf8​ 使用 1 到 3 个字节来编码字符(不支持 4 字节字符,因此不支持部分 Emoji​ 表情等)。utf8mb4​ 是 UTF-8​ ​的扩展版本,支持完整的 Unicode​ ​字符集,包括 4 字节字符(如 Emoji​ 表情)。

所以,使用不同的字符集,对同一组字符串进行排序,得到的排序顺序不一致,且与排序规则密切相关。这也就是文首 VARCHAR​ ​类型排序排序时,98 排在 121 之前的原因。在下文我将使用 MySQL8.0 默认的字符集 utf8mb4​ ​和排序规则 utf8mb4_0900_ai_ci​ ​为例来说明字符集对排序规则的影响。registration_record​ ​挂号记录表的字符集是 utf8mb4​,reg_num​ ​是 VARCHAR​ ​类型的诊号,排序规则是 utf8mb4_0900_ai_ci​。

utf8mb4_0900_ai_ci​ ​排序规则的组成解读

  • utf8mb4:字符集,支持完整的 Unicode 字符集,包括 4 字节字符(如 Emoji)。
  • 0900:表示基于 Unicode 9.0 标准定义的排序规则,逐字符按 Unicode 值逐字符比较。
  • aiAccent Insensitive,不区分重音符号。例如,é 和 e 被视为相同。
  • ciCase Insensitive,不区分大小写。例如,A 和 a 被视为相同。

基于以上,我们可以明确的知道:VARCHAR​ ​类型的排序仍然按照 VARCHAR​ ​字典序规则进行排序,因此下文将围绕 Unicode 9.0​ 标准的排序规则来探究引言中出现 VARCHAR​ ​类型排序的问题。

探秘 Unicode​ ​规则

什么是 Unicode​?

an international encoding standard for use with different languages and scripts, by which each letter, digit, or symbol is assigned a unique numeric value that applies across different platforms and programs.

来自维基百科

翻译下来:

一种用于不同语言和文字的国际编码标准,根据该标准,每个字母、数字或符号都被赋予一个适用于不同平台和程序的唯一数值。

总结一句话:任意字符可以通过 Unicode​ ​标准将其转换为唯一数值。

​Unicode​ ​码值与转换

Unicode 码值(即 Unicode 码点)通常以 16 进制(hexadecimal)表示。这是因为 Unicode 标准为每个字符分配了一个唯一的标识符,通常使用一个 4 位 或 8 位 的十六进制数表示。例如:

  • 字符 'A' 的 Unicode 编码是 U+0041,其中 U+ 表示 Unicode 编码点,0041 是十六进制表示的数值。
  • 字符 '1' 的 Unicode 编码是 U+0031,其中 0031 是十六进制的表示。

Unicode 16 进制转换:

字符与 Unicode 互转工具

Unicode 排序的实际示例

依然使用上文的挂号信息表,将诊号:98、99、120、121 插入数据库,使用诊号正序进行查询:

sql 复制代码
select doctor_id,patient_id,reg_num,reg_time from registration_record where reg_date = '2024-11-20' order by reg_num desc;
sql 复制代码
select doctor_id,patient_id,reg_num,reg_time from registration_record where reg_date = '2024-11-20' order by reg_num asc;

按诊号从大到小,基于 Unicode​ ​字符集对该字段的排序结果为:99、98、121、120;按诊号从小到大,基于 Unicode 字符集对字段的排序结果为:120、121、98、99。在数学上,这种排序规则显然是不正确的,那么为了按照 Unicode ​的标准来探究如何得到的该结果,第一步需要将以上字符数据转换为 Unicode​ ​再按照 Unicode​ ​的排序规则进行排序比较。

自己在测试 Unicode 转换时,可以使用上文链接中的工具地址。

以下是 8、9、1、2、0 对应的 Unicode 转换结果:

  • 99 → '9'(U+0039),'9'(U+0039)
  • 98 → '9'(U+0039),'8'(U+0038)
  • 121 → '1'(U+0031),'2'(U+0032),'1'(U+0031)
  • 120 → '1'(U+0031),'2'(U+0032),'0'(U+0030)
Unicode 逐字符比较的基本规则
  • 从左到右逐字符比较字符串的每个字符。
  • 如果字符相同,继续比较下一个字符。
  • 字符的 Unicode 编码值越小,排在前面。

以"98"、"99"比较为例:

  1. 从左到右"99"和"98"的第一个字符都是"9","9"对应的 Unicode 值为(U+0039)
  2. 两者字符相同,继续比较下一个字符,'99'的第二个字符是'9',对应的 Unicode 值为(U+0039),'98'的第二个字符是'8',对应的 Unicode 值为(U+0038)
  3. U+0038 值相对于 U+009 要小,因此字符'98'排在'99'之前

以'98'、'99'来看排序规则与数字类型无异,那么接下来通过'98'与'121'的比较可以提现出问题所在:

以'98'与'121'比较为例:

  1. '98'与'121'的第一个字符分别是 '9'(U+0039)和 '1'(U+0031)。
  2. 字符小的值在前,'1'(U+0031)< '9'(U+0039),因此'121'排在'98'

这也便是出现引言中的问题所在。

排序问题优化

排序问题的优化可以从两个方面入手:

避免不必要的字符集转换

为解决 VARCHAR​ ​类型的数字字符串排序不正确的问题,解决思路之一就是使用 MySQL​ ​的 CAST​ ​函数将 VARCHAR​ ​类型转换为数字类型:

sql 复制代码
    select doctor_id,patient_id,reg_num,reg_time from registration_record where reg_date = '2024-11-20' order by CAST(reg_num AS UNSIGNED ) asc;

排序结果:

MySQL 的索引列使用的是原始值,由于该方案使用了 CAST​ ​函数,会导致无法命中索引的问题,当数据量较大时会导致查询变慢的问题。

使用 explain​ ​执行查看执行计划,发现扫描全表。

合理选择字段存储/排序

在前文举例的诊号相关业务中,诊号可能出现非纯数字需要使用 VARCHAR​ ​类型进行存储的情况,如有排序的需求,则可以根据具体业务场景使用其他字段进行排序,如在诊号相关业务中,诊号随时间推移逐渐增大,按诊号进行排序实则是按创建时间(create_time)进行排序,亦或者使用 int​ ​类型自增字段进行排序,如果您的 MySQL​ ​表使用的是自增主键,那么根据自增主键的 id 进行排序是一个不错的选择,以上两种方案均可弥补使用 CAST​ ​函数不走索引的情况。

Unicode 排序扩展

中文、英文、数字、符号对应 Unicode 的取值范围

数字

数字字符(0-9​)Unicode 范围:U+0030 到 U+0039

  • 例如:

    • '0' → U+0030(十六进制) = 48(十进制)
    • '1' → U+0031(十六进制) = 49(十进制)
    • '9' → U+0039(十六进制) = 57(十进制)
英文字母
大写英文字母(A-Z)
  • Unicode 范围:U+0041 到 U+005A

  • 例如:

    • 'A' → U+0041(十六进制) = 65(十进制)
    • 'B' → U+0042(十六进制) = 66(十进制)
    • 'Z' → U+005A(十六进制) = 90(十进制)
  • 大写字母的 Unicode 码点大于数字字符。

小写英文字母(a-z)
  • Unicode 范围:U+0061 到 U+007A

  • 例如:

    • 'a' → U+0061(十六进制) = 97(十进制)
    • 'b' → U+0062(十六进制) = 98(十进制)
    • 'z' → U+007A(十六进制) = 122(十进制)
  • 小写字母的 Unicode 码点比大写字母的码点大,因此小写字母排在大写字母后面。

中文字符(汉字)
  • Unicode 范围:U+4E00 到 U+9FFF(常用汉字区)

  • 例如:

    • '中' → U+4E2D(十六进制) = 20014(十进制)
    • '国' → U+56FD(十六进制) = 22379(十进制)
  • 中文字符的 Unicode 码点通常远大于数字和字母的码点。

其他符号和表情符号
标点符号
  • 标点符号(如 !​, ?​, .​, ,​)的 Unicode 范围通常位于字符表的较小范围,如:

    • '!' → U+0021(十六进制) = 33(十进制)
    • '?' → U+003F(十六进制) = 63(十进制)
  • 这些字符的 Unicode 码点一般比字母和数字的码点小。

其他符号和表情符号(Emoji)
  • 例如:

    • '🙂' → U+1F642(十六进制) = 128578(十进制)
  • 这些符号的 Unicode 码点大大高于常规字符,因此通常排在字符串的末尾。

所以 VARCHAR​ ​类型的字符串进行排序时,一般地:

标点符号 < 数字 < 大写英文字母 < 小写英文字母 < 中文汉字 < 表情符号。

总结

不同的业务场景,大到技术选型,小到字段类型的使用,需要根据实际需求进行方案推演。一个好的设计是完成一个系统的开始也是必要条件,开始时遵循"以终为始"的思维方式可以避免后续很多不必要的问题,它的核心思想是从最终目标出发,反向推导出实现目标的步骤和方法,从而确保行动始终围绕目标展开,不会偏离方向。

不同的数据库如MySQLRedisMongoDB,不同的字段类型如:VARCHAR​、TINYINT​、TEXT​、CHAR​等,适用的业务场景不同,在使用之前需要拨开问题的面纱,找到最适合当前业务的解决方案,可以大大降低后续"踩坑"的风险,确保系统的稳定性和健壮性。

相关推荐
无奈ieq28 分钟前
Scala快速入门+示例
开发语言·后端·scala
散修-小胖子2 小时前
InnoDB 事务系统(一):认识事务
数据库·mysql
闲人陈二狗2 小时前
MySQL的详细使用教程
数据库·mysql
hshpy2 小时前
To use only local configuration in your Spring Boot application
java·spring boot·后端
小码编匠2 小时前
.NET 下 RabbitMQ 队列、死信队列、延时队列及小应用
后端·c#·.net
hnmpf2 小时前
flask-admin+Flask-WTF 实现实现增删改查
后端·python·flask
阑梦清川3 小时前
基于ubuntu的mysql 8.0安装教程
linux·mysql·ubuntu
Snow_Dragon_L3 小时前
【MySQL】表操作
linux·数据库·后端·sql·mysql·ubuntu
千年死缓3 小时前
golang结构体转map
开发语言·后端·golang
AitTech4 小时前
MySQL中常用的函数
数据库·mysql