目录
引言-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 类型的基础知识。
VARCHAR
是 MySQL
中用于存储数字字符串、英文、中文等短文本,如:用户名、邮箱、UUID 等可变长度的数据类型。相比于 CHAR
类型,VARCHAR
类型仅存储实际字符串内容,而 CHAR
类型在字符串不足字段长度时会用空格向右填充到指定的长度。相比于 TEXT
类型,VARCHAR
类型则在排序、查询场景具有更强的性能。
类型 | 容纳长度(字节) | 查询性能 | 适用场景 |
---|---|---|---|
CHAR | 0~255 | 快 | 身份证号、手机号等定长数据,索引效率高,但存在空间浪费风险 |
VARCHAR | 0~65,535 | 适中 | 用户名、标题等理想选择,索引效率较高 |
TEXT | 0~∞(无限) | 慢 | 适合存储大文本内容,存在索引受限问题,不适合频繁查询 |
存储相同的数据,CHAR
和 VARCHAR
在空间占用上可以参考 MySQL
官网给到的内容:
下表 通过显示将各种字符串值存储到 CHAR
和 列(假设该列使用单字节字符集,例如)的结果来说明和之间的差异。
内容 | CHAR(4) | 需要存储 | VARCHAR(4) | 需要存储 |
---|---|---|---|---|
'' | ' ' | 4 字节 | '' | 1 字节 |
'ab' | 'ab ' | 4 字节 | 'ab' | 3 个字节 |
'abcd' | 'abcd' | 4 字节 | 'abcd' | 5 个字节 |
'abcdefgh' | 'abcd' | 4 字节 | 'abcd' | 5 个字节 |
参考自:
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
值逐字符比较。ai
:Accent Insensitive
,不区分重音符号。例如,é 和 e 被视为相同。ci
:Case 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 排序的实际示例
依然使用上文的挂号信息表,将诊号: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"比较为例:
- 从左到右"99"和"98"的第一个字符都是"9","9"对应的
Unicode
值为(U+0039) - 两者字符相同,继续比较下一个字符,'99'的第二个字符是'9',对应的
Unicode
值为(U+0039),'98'的第二个字符是'8',对应的 Unicode 值为(U+0038) - U+0038 值相对于 U+009 要小,因此字符'98'排在'99'之前
以'98'、'99'来看排序规则与数字类型无异,那么接下来通过'98'与'121'的比较可以提现出问题所在:
以'98'与'121'比较为例:
- '98'与'121'的第一个字符分别是 '9'(U+0039)和 '1'(U+0031)。
- 字符小的值在前,'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
类型的字符串进行排序时,一般地:
标点符号 < 数字 < 大写英文字母 < 小写英文字母 < 中文汉字 < 表情符号。
总结
不同的业务场景,大到技术选型,小到字段类型的使用,需要根据实际需求进行方案推演。一个好的设计是完成一个系统的开始也是必要条件,开始时遵循"以终为始"的思维方式可以避免后续很多不必要的问题,它的核心思想是从最终目标出发,反向推导出实现目标的步骤和方法,从而确保行动始终围绕目标展开,不会偏离方向。
不同的数据库如MySQL
、Redis
、MongoDB
,不同的字段类型如:VARCHAR
、TINYINT
、TEXT
、CHAR
等,适用的业务场景不同,在使用之前需要拨开问题的面纱,找到最适合当前业务的解决方案,可以大大降低后续"踩坑"的风险,确保系统的稳定性和健壮性。