十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL数据类型选型指南:从存储原理到索引性能的深度解析

MySQL数据类型选型指南:从存储原理到索引性能的深度解析 说真的干了这么多年开发和运维踩过最多的坑不是复杂的SQL写不出来而是表结构设计的时候对数据类型不够上心。一次建表后面几十年都得围着它转。MySQL的数据类型看着简单翻来覆去就那几类但选错一个字段类型可能让你的索引直接失效、存储空间白白翻倍、甚至在高并发场景下拖垮整个库。不少人拿着varchar(255)一把梭或者金额明明该用decimal却整成了float最后查出来的数据差那么零点几对不上账。这篇我就把MySQL数据类型的那些事好好盘一盘从底层存储逻辑到面试常考的点再到实际上线遇到的坑一次讲透。1. 为什么数据类型值得认真学好多新手觉得数据类型不就是建表的时候随便选一下吗能存进去不就行了。真不是这样。数据类型决定了三件大事存储空间怎么算、比较运算怎么做、索引能不能生效。先算一笔空间账。一张一千万行的表如果用户ID用bigint8字节而实际上最多不会超过十万那就白白多出了大约76MB的存储空间这还不算二级索引的额外开销。反过来如果某个状态字段本来只有0和1两个值你用varchar(10)去存那浪费更吓人因为varchar要额外用1到2个字节记录字符串长度每一行都比tinyint多出9个字节以上。一千万行就是接近100MB的差距还全是无效数据。再说比较运算。数据库在查询的时候对同一个字段的类型有明确要求。如果你把订单金额设计成varchar然后WHERE条件里用amount 100去筛MySQL会在每一行都做隐式类型转换把varchar转成数字再比较。这一下索引废了查询变成全表扫描而且还有可能导致转换出错。我见过一个用户表10万行数据就因为手机号字段存成了varchar查询的时候忘了加引号直接走全表扫描响应时间从毫秒级变成了几秒。这种问题不查个半天根本看不出来。最后是索引。索引越短越好这是MySQL优化的一条铁律。因为InnoDB的索引页大小是固定的16KB页里能放下多少条索引记录取决于索引字段的长度。如果字段用int一页能放下几千个key用varchar(100)一页可能就几百个。索引字段越短同样的内存能缓存更多的索引页磁盘IO自然就更少查询越快。所以学数据类型不是背几个能存什么值的问题而是要建立起一个意识每个字段都应该是最小够用的类型够用就好不要贪大。这个思路贯穿整篇。2. 数值型整数、小数与位类型怎么选2.1 整数类型别再纠结int(11)了先看一组基本信息tinyint1字节有符号范围-128到127无符号0到255smallint2字节有符号范围-32768到32767mediumint3字节有符号范围约-838万到838万int4字节有符号范围约-21亿到21亿bigint8字节范围极大一般用来存雪花算法生成的ID或超大数值一个常见的误区是int(11)里的11到底表示什么它不是什么存储长度限制而是显示宽度。在配合zerofill属性时才有效果比如int(4)加上zerofill存1会显示为0001。问题是zerofill本身会影响存储方式现代MySQL版本里并不推荐日常使用很多人看到表结构里写int(11)就觉得有什么特殊含义实际上啥也没有。选型建议很简单状态值、开关、枚举数字比如订单状态、是否删除用tinyint。统计次数、点赞数、评论数这种一般不会爆表的用int。分布式ID、雪花ID、复杂系统里的主键直接bigint。无业务含义的自增主键能用int别用bigint尤其是中间表、关联表节省空间的效果非常明显。还要注意unsigned的使用。它能把正数范围扩大一倍。但说实话我实际使用中很少加unsigned因为加了它之后一旦后续需要存负数比如积分变化这种可能有正有负的场景ALTER TABLE又得改一遍成本太高。只有在强制不允许出现负数的场景下才考虑。2.2 小数类型float和double是近似值陷阱浮点类型float4字节和double8字节存的是近似值二进制浮点数在转换成十进制的时候会出现误差。比如SELECT 0.1 0.2;在MySQL里执行结果不是0.3而是0.30000000000000004。这种误差在银行、财务、订单金额计算里是绝对不能接受的。那存金额用什么用decimal。CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, amount DECIMAL(10, 2) NOT NULL );DECIMAL(10, 2)表示总共10位数其中小数部分占2位也就是可以存最大99999999.99。基本上一张订单表的金额范围完全够了。decimal底层是以字符串形式存储的定点数所以精度不会丢失但代价是空间比浮点类型大运算速度也稍慢。不过现在服务器性能都上来了只要不是每秒几十万次的小数运算用decimal完全没问题。还有个冷知识MySQL 8.0里对float和double的语法做了收紧。以前你可以写float(10, 2)这种带精度参数的写法现在虽然兼容但官方已经不建议了新代码里就直接写float即可精度控制交给应用侧就好。2.3 位类型BIT到底有什么用BIT类型平时用得少但有一个经典场景状态标志位。比如一个用户有多种权限权限每个权限用一个bit表示1代表有0代表没有一个BIT(8)就可以存8种权限状态查的时候用位运算判断。这种设计在嵌入式、物联网、配置管理类的系统里能省不少空间。不过说实话日常业务系统里我很少建议用BIT因为它在ORM工具里映射出来的类型有点尴尬JDBC驱动读出来是byte[]很多人一不注意就转错了。真要用位图做权限不如直接用SET类型或者干脆用几个tinyint字段可读性更高。这个类型知道怎么回事就行面试的时候能讲出位类型适合做标志位已经算加分项。3. 字符串类型从char到text存储机制与性能差异3.1 CHAR与VARCHAR不只是长度区别CHAR(N)和VARCHAR(N)的区别是面试必考题但很多人只背了表面。CHAR是定长的。哪怕你只存了一个字符进去它也会占用N个字符的空间。如果存的内容不够长后面会自动补空格取出来的时候MySQL会把尾部的空格去掉除非开了PAD_CHAR_TO_FULL_LENGTH模式。VARCHAR是变长的。它需要额外用1到2个字节记录实际使用的长度比如一个VARCHAR(10)的字段存了abc实际占用的空间是3个字符加1个长度字节。性能差异在InnoDB里没有以前MyISAM时代那么悬殊了因为InnoDB本来就是按页存数据的行格式是动态的char的定长优势被削弱了。但有一个选择原则依然有效长度固定且经常被更新的字段选CHAR长度不确定的选VARCHAR。不能踩的坑是不要让VARCHAR的长度过大。VARCHAR(255)和VARCHAR(5000)在存储同样短内容时空间上没有太大区别因为变长嘛用的空间由内容决定。但一旦超过255就需要2个字节记录长度而且如果这个字段建了索引索引大小直接跟着字段最大长度走性能和空间都会受影响。更关键的是VARCHAR(N)里的N是字符数不是字节数在utf8mb4下一个汉字占3到4个字节。所以VARCHAR(255)在最坏情况下占用的字节数是255×41020字节都接近索引键的最大限制了。UTF-8准确说应该是utf8mb4是在MySQL里存储中文和表情符号的推荐字符集。注意MySQL的历史遗留问题旧版默认的utf8并不是真正的全量UTF-8编码它最多只能存3字节的UTF-8字符像emoji表情这类4字节字符就存不进去。所以新库创建没有任何理由不用utf8mb4。3.2 TEXT与BLOB大字段的无奈选择TEXT家族有TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXTBLOB家族也有对应的4种。区别很简单TEXT存的是文本字符BLOB存的是二进制数据。BLOB没有字符集的概念存进去啥就是啥。实操中建议记住三件事别对TEXT/BLOB字段建普通索引。它们默认只支持前缀索引也就是给前N个字符建索引。查询里如果你用WHERE content 很长的内容即使建了索引也只是部分优化。更麻烦的是TEXT字段在排序和分组的时候只能使用前max_sort_length个字节默认1024字节稍微长一点的内容排序就可能出问题。TEXT类型不能有默认值。在MySQL 8.0之前TEXT、BLOB字段是不允许指定DEFAULT值的直到8.0.13版本才部分放开但依然有很多限制。如果业务上需要一个文本类型的默认值通常会选择用VARCHAR或者让应用层在插入的时候给值。尽量拆表。一个字段内容特别长查询的时候即使你不SELECT这个字段InnoDB在读取行数据的时候也会把这部分数据页读出来取决于行格式和存储情况多多少少影响性能。要存大文本又不常用拆到一张单独的子表里按主键关联查询性能会好很多。3.3 ENUM与SET当心变更表结构的代价ENUM是枚举类型适合固定几个取值的字段。比如性别、订单状态、课程难度这些。它在底层是保存为整数显示为字符串所以空间很小查询也快。但ENUM有个很容易踩的大坑改枚举值需要ALTER TABLE。假如你的订单状态一开始设计成ENUM(pending, paid, canceled)后来产品说加一个refunding状态你就必须执行ALTER TABLE orders MODIFY status ENUM(pending, paid, canceled, refunding) NOT NULL;这在千万级大表上是个非常重的操作。即使Online DDL在MySQL 8.0里已经做了不少优化但表的数据量一大变更期间还是有锁等待和复制延迟的风险。另外一个坑是ENUM里的ORDER BY行为是按枚举的索引顺序排序不是字符串的字典序。如果你想按月查询月份字段这个特性恰好能用上但如果是普通状态字段排序行为没人会注意到容易出bug。实际操作中我更倾向于用TINYINT配合代码里的常量定义或者直接用VARCHAR加CHECK约束MySQL 8.0.16之后CHECK约束才真正生效这样扩展性更好。SET类型同样是这个问题一旦集合里的值要调整同样要走ALTER TABLE所以只用在小范围且极稳定的配置场景。3.4 JSON类型8.0后的利器MySQL 5.7引入了JSON类型8.0里做了大量优化。它最大的好处是可以直接在MySQL里对JSON文档做路径查询、索引、函数运算不用把整个字段捞到应用层再解析。SELECT user_id, JSON_EXTRACT(extra_info, $.age) AS age FROM users WHERE JSON_CONTAINS(extra_info-$.tags, vip);注意两点JSON字段不能有默认值而且JSON本身没有索引必须通过生成列Generated Column配合来建立索引。比如给$.age这条路建索引ALTER TABLE users ADD COLUMN age INT GENERATED ALWAYS AS (extra_info-$.age) VIRTUAL, ADD INDEX idx_age (age);不过我也说实话JSON类型用起来方便但别把它当万能筐。凡是需要关联查询、需要建索引、需要统计的字段都应该抽出来做成独立字段。JSON字段适合放那些只有展示价值不做查询条件的扩展信息。如果每天几十万条记录往里塞JSON字段解析是要消耗CPU的数据量大了之后成本不低。4. 日期与时间类型别再被时区坑了4.1 DATETIME和TIMESTAMP怎么选MySQL主要的日期时间类型有DATE、TIME、YEAR、DATETIME、TIMESTAMP实际开发中90%的场景只用到DATETIME和TIMESTAMP以及偶尔用DATE存生日这种纯日期。核心区别如下DATETIME8字节范围1000-01-01到9999-12-31不依赖数据库时区设置存进去啥样取出来就是啥样。适合存业务时间比如创建时间、支付时间。TIMESTAMP4字节范围1970-01-01到2038-01-19底层存的是UTC时间戳显示的时候会根据数据库会话的时区把时间转成对应时区的值。它能存的年份上限到了2038年也就是著名的Y2K38问题。我的选择习惯是能选DATETIME优先DATETIME。原因很简单分布式系统、云数据库的数据库实例很可能部署在不同的时区TIMESTAMP的时区转换会带来各种莫名其妙的问题。有一次我排查线上一个问题用户支付时间比实际时间多了8个小时追了一夜最后发现是DBA把数据库时区从08:00改成了SYSTEM所有TIMESTAMP字段全乱了。换成DATETIME就完全不受影响。4.2 默认值和自动更新建表的时候常看到下面两种写法CREATE TABLE orders ( created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );DEFAULT CURRENT_TIMESTAMP让插入数据的时候自动填当前时间ON UPDATE CURRENT_TIMESTAMP让更新记录的适合自动刷新为当前时间。这个在MySQL 5.6.5之后就已经支持了实属建表标配。注意一点TIMESTAMP或者DATETIME的默认值设置在MySQL 8.0里受到了explicit_defaults_for_timestamp参数的影响。如果你的实例把explicit_defaults_for_timestamp设成了ON那么TIMESTAMP在没指定默认值的时候是允许为NULL的不会像旧版本那样自动应用CURRENT_TIMESTAMP。所以建表的时候最好明确写出来别依赖隐式行为。另外日期时间字段的精度问题。如果有业务需要精确到毫秒可以定义精度created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)不带参数的CURRENT_TIMESTAMP精度是秒定义了(3)之后就是毫秒。不过除非是计费、日志流水这种场景否则建议只用秒级精度多一位精度InnoDB在存储上需要的空间会变大索引页能放下的记录数也会减少。5. 类型选错之后的代价与常见坑5.1 隐式类型转换索引说废就废这是个非常经典的坑。假设有个表CREATE TABLE users ( id INT PRIMARY KEY, phone VARCHAR(20) NOT NULL, KEY idx_phone (phone) );查询的时候很多人会写成SELECT * FROM users WHERE phone 13800138000;这里的phone字段是VARCHAR但等号右侧的13800138000在MySQL里会被当成数字因为字面量没有引号。MySQL会把phone字段从字符串转成数字来比较也就是在字段上做了函数转换索引idx_phone直接失效全表扫描。解决办法就是查询的时候给值加引号SELECT * FROM users WHERE phone 13800138000;更安全的做法是在代码层就固定所有查询参数的传递类型别让MySQL帮你做隐式转换。同理日期时间字段也容易踩这个坑字符串日期和DATE类型比较的时候尽量保持两边类型一致。5.2 ALTER TABLE改类型大表上是要出事的设计表结构的时候就要想把类型定对因为后期ALTER TABLE MODIFY COLUMN在大表上代价非常高。MySQL 8.0虽然支持了INPLACE算法但不是所有类型变更都能用INPLACE。比如把VARCHAR(10)改成VARCHAR(255)如果长度变化导致行大小超过页面限制就需要重建表COPY算法这意味着要拷贝所有数据期间还要占用大量磁盘空间对主库性能影响极大。几个从小到大变更容易踩的雷INT改成BIGINT通常需要重建表。VARCHAR从短变长但不超过255多数情况可以INPLACE但要具体看。TINYINT改成INT同样需要重建表。把一个字段从INT改成VARCHAR几乎必然重建表。所以建表的时候字段类型宁可往大一点选也不要选了刚好够用但半年后要改。比如状态字段明明现在只用0和1但产品上线的速度很可能一个月后就加个2、3那不如直接设TINYINT而不要用BIT(1)留点扩展空间给未来。5.3 字符集和排序规则的坑整表字符集和字段字符集不一致会导致联表查询的时候索引失效因为MySQL需要把两边的字符集都转成共同的字符集才能比较比如utf8mb4和latin1做连接查询性能会有明显下降。更麻烦的是排序规则collation不一致也会影响比较结果。建议做法建库的时候用utf8mb4排序规则用utf8mb4_unicode_ci或utf8mb4_0900_ai_ciMySQL 8.0默认所有表和字段都用默认字符集除非特殊字段比如存emoji才单独设置。千万不要在字段级别东一个字符集西一个排序规则后面排查乱码问题会怀疑人生。5.4 空值与NOT NULL字段要不要允许NULL我的答案是业务上保证有值的一定要NOT NULL。原因是NULL在索引和比较中有很多特殊行为NULL不会进普通索引在InnoDB中NULL值不会被记录在二级索引上但主键索引一定不会为NULL查询条件里IS NULL也走不了索引优化。使用聚合函数COUNT(column)的时候NULL值不参与计数容易得到意外结果。和NULL做任何比较运算结果都是NULL所以WHERE status ! deleted会过滤掉status为NULL的记录很多人查着查着发现某些数据莫名其妙消失了。如果实在不确定字段有没有值推荐给一个默认值比如空字符串、数字0而不是直接裸奔允许NULL。6. 数据类型与索引、存储的空间账6.1 主键类型的终极选择主键类型直接决定聚簇索引的大小。InnoDB的聚簇索引就是整张表的数据主键占了多大空间每一行的行头就要带多大空间。如果用UUID字符串作为主键一个36位的VARCHAR每行就是36字节左右的额外开销但如果是BIGINT只要8个字节。而且UUID作为主键还有个致命问题无序。InnoDB的聚簇索引是按主键排序的插入一个随机UUID会导致页分裂频繁写入性能大幅下降。所以线上系统只要不是极其特殊的场景主键用自增BIGINT或者类似的有序数字是最省心的。如果你必须用业务主键比如订单号也建议用数字型字段而不是超长字符串。6.2 前缀索引和索引长度如果要给一个VARCHAR(255)的字段建索引但实际查询里只用了字段的前20个字符做匹配可以建一个前缀索引ALTER TABLE article ADD INDEX idx_title_prefix (title(20));前缀索引能大幅缩小索引体积提升写入性能。但前缀索引有个致命限制不能用于覆盖索引优化也就是说查询结果如果要求返回整个title字段通过前缀索引找到记录之后必须回表才能拿到真实值。所以前缀索引适合那种只用来过滤无需返回的字段。索引长度的经验值INT是4字节BIGINT是8字节VARCHAR(100)在utf8mb4下最大索引字节是100×4400字节。InnoDB在DYNAMIC行格式下单个索引键最大允许3072字节MySQL 8.0之前的版本是767字节。所以创建VARCHAR(1000)字段的普通索引很可能会报Specified key was too long错误解决办法就是砍字段长度或者用前缀索引。6.3 行格式对类型的影响InnoDB的DYNAMIC行格式下变长字段如VARCHAR、TEXT如果太长会被放到溢出页Overflow Page中行中只保存20字节的指针。这意味着日常查询时如果一条SQL不选中大字段读取主表页并没有额外IO。但如果你经常SELECT *把大字段的内容也捞出来那么每次都要多读溢出页性能就会掉下来。所以设计表结构的时候主表和扩展信息表拆开是很有必要的这不光是规范问题也是在给InnoDB减负。7. 实操建议汇总新项目建表的参考模板我贴一个实际项目中比较常用的建表模板包含了上面说的几个关键点CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(64) NOT NULL COMMENT 用户名最长64个字符, gender TINYINT NOT NULL DEFAULT 0 COMMENT 性别0未知1男2女, birthday DATE DEFAULT NULL COMMENT 生日, balance DECIMAL(10, 2) NOT NULL DEFAULT 0.00 COMMENT 账户余额, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常0禁用, extra_info JSON DEFAULT NULL COMMENT 扩展信息不建议存核心业务字段, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci COMMENT用户表;这个模板里值得解释的几个点username用VARCHAR(64)而不是VARCHAR(255)因为用户名正常不会超过64个字符短一点唯一索引占的空间就更少。gender用TINYINT加注释而不是直接ENUM(male,female)因为后面加性别的取值空间不用改表结构。balance用DECIMAL(10, 2)绝不用FLOAT和DOUBLE存金额。extra_info用JSON但明确注释了不建议存核心业务字段防止后续滥用。created_at和updated_at都用DATETIME配合DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP应用层完全不用手动维护时间。8. 最后分享一点个人心得MySQL数据类型这块很多人的问题不是不知道而是知道但没当回事。我刚开始工作的头两年也觉得这是新手才学的内容结果后来实际维护一个老系统的时候看到手机上把手机号存成INT的缺勤看到金额用DOUBLE导致对账差出几万块才明白当初建表时多花五分钟想清楚每个字段的类型后面能省出几十个小时的修复时间。数据类型的知识不难但一定要结合真实的业务场景去理解。面试的时候被问到为什么用decimal而不用float不要只回答decimal更精准最好能补一句float在二进制存储中存在精度损失对金额字段会产生不符合预期的误差且这种误差在聚合计算时会被放大。能把这个道理讲到这个程度面试官基本就知道你是真的踩过坑、干过活的人。希望大家在新项目建表的时候真的多想想每个字段类型背后那笔空间账和索引账。类型选对了MySQL会替你在背后省很多力气选错了就是给自己和同事挖一个看不见的坑。
返回列表