数据库设计优化:表结构设计原则、索引策略、分区表与分库分表
数据库设计,说白了就是给数据找个好房子住。房子盖得不好,住的人多了就得出问题。我在回扣交易系统里踩过不少坑,今天把这些经验掰开揉碎了讲给你听。
一、表结构设计原则
先说说表结构。我见过太多人一上来就建表,结果上线后天天改字段。嗯,这里有几个原则你得记住。
1. 字段类型选择要抠门
能用 INT 就别用 BIGINT,能用 VARCHAR(50) 就别用 VARCHAR(255)。为什么?
- 存储空间:一个
BIGINT占 8 字节,INT只占 4 字节。你想想看,一亿条记录就差 400MB。 - 查询速度:数据页能装下的行数更多,IO 次数自然就少了。
- 索引效率:索引树更矮,查询更快。
我个人习惯:主键用 BIGINT UNSIGNED,状态字段用 TINYINT,金额用 DECIMAL(18,2)。别问为什么,问就是吃过亏。
2. 范式与反范式要平衡
第三范式是理论上的好学生,但实际业务中往往需要反范式。我在项目中遇到过这样一个场景:
回扣交易系统里,订单表需要频繁查询用户姓名和商品名称。如果严格遵循第三范式,每次都要 JOIN 用户表和商品表。结果呢?一个简单的列表页,查询耗时 3 秒多。
我的做法是:在订单表里冗余了 user_name 和 product_name 字段。虽然违反了第三范式,但查询速度从 3 秒降到了 0.1 秒。
注意:反范式要谨慎。我曾经在一个项目里冗余了 5 个字段,结果更新用户昵称时要同步更新 3 张表,维护成本直线上升。反范式只适用于读多写少的场景。
3. 预留字段是个坑
很多人喜欢在表里加 reserve1、reserve2 这样的预留字段。我劝你千万别这么干。
- 预留字段的类型和长度是固定的,业务需求来了往往不匹配。
- 代码里到处都是
reserve1,可读性极差。 - MySQL 对 NULL 值的处理有额外开销。
更好的做法是:用 JSON 类型存储扩展字段。MySQL 5.7 之后对 JSON 的支持已经很好了,可以建虚拟列加索引。
二、索引策略
索引这东西,用好了是神器,用不好就是累赘。我见过有人一张表建了 20 个索引,结果写入速度比蜗牛还慢。
1. 索引不是越多越好
每个索引都是一棵 B+ 树。写入数据时,所有索引都要更新。你想想看,10 个索引就是 10 棵树要维护。
| 索引数量 | 查询提升 | 写入损耗 |
|---|---|---|
| 0-3 个 | 明显 | 可忽略 |
| 4-6 个 | 边际递减 | 开始明显 |
| 7 个以上 | 几乎无提升 | 严重影响写入 |
我建议:单表索引控制在 5 个以内。超过这个数,你得想想是不是表结构设计有问题。
2. 联合索引的字段顺序
联合索引遵循最左前缀原则。举个例子:
-- 创建联合索引
ALTER TABLE order ADD INDEX idx_user_status_time (user_id, status, create_time);
-- 能用到索引的查询
SELECT * FROM order WHERE user_id = 1001;
SELECT * FROM order WHERE user_id = 1001 AND status = 1;
SELECT * FROM order WHERE user_id = 1001 AND status = 1 AND create_time > '2024-01-01';
-- 用不到索引的查询
SELECT * FROM order WHERE status = 1;
SELECT * FROM order WHERE create_time > '2024-01-01';
字段顺序怎么排?记住一句话:区分度高的放前面。比如 user_id 的区分度远高于 status,所以放第一个。
小技巧:用 SELECT COUNT(DISTINCT column) / COUNT(*) 算算区分度。值越接近 1,区分度越高。
3. 覆盖索引是个好东西
覆盖索引的意思是:索引里已经包含了查询需要的所有字段,不需要回表查数据行。这能省掉一次 IO 操作。
-- 假设有索引 idx_user_id (user_id)
-- 这个查询需要回表
SELECT user_id, user_name FROM order WHERE user_id = 1001;
-- 改成覆盖索引
ALTER TABLE order ADD INDEX idx_user_id_name (user_id, user_name);
-- 现在这个查询直接从索引里拿数据,不用回表
SELECT user_id, user_name FROM order WHERE user_id = 1001;
我在回扣交易系统里,把高频查询的字段都塞进了索引里。查询性能提升了 30% 以上。
三、分区表
分区表不是银弹,但用对场景效果很好。说白了就是把一张大表拆成多个小表,查询时只扫描相关分区。
1. 什么时候用分区表
- 数据量大:单表超过 500 万行,或者超过 10GB。
- 有明显的时间维度:比如按月份分区。
- 需要定期清理历史数据:直接 TRUNCATE 分区比 DELETE 快得多。
2. 分区类型选择
| 分区类型 | 适用场景 | 示例 |
|---|---|---|
| RANGE | 按时间、ID 范围 | PARTITION BY RANGE (YEAR(create_time)) |
| LIST | 按枚举值 | PARTITION BY LIST (status) |
| HASH | 均匀分布数据 | PARTITION BY HASH (user_id) PARTITIONS 8 |
| KEY | 类似 HASH,但用 MySQL 内置函数 | PARTITION BY KEY (id) PARTITIONS 8 |
我个人习惯:回扣交易系统里,订单表按 create_time 做 RANGE 分区,每个月一个分区。查询最近三个月的订单,只扫描三个分区,速度杠杠的。
3. 分区表的坑
我曾经踩过的坑:分区表的分区键必须包含在主键或唯一键里。如果你把 create_time 作为分区键,但主键是 id,那就不行。解决办法是用复合主键 (id, create_time)。
四、分库分表
分区表解决不了所有问题。当单库的写入压力太大,或者单表数据量超过 1 亿行时,就得考虑分库分表了。
1. 什么时候需要分库分表
- 单库连接数不够:MySQL 默认最大连接数 151,虽然可以调大,但 CPU 和内存扛不住。
- 单表数据量过大:超过 1 亿行,索引深度增加,查询变慢。
- 写入瓶颈:单库的写入 TPS 达到上限。
2. 分片策略
分片键的选择是关键。选错了,后面全是坑。
-- 按 user_id 取模分片
-- 分 16 个库,每个库 16 张表
-- 分片规则:user_id % 16 = 库编号,user_id / 16 % 16 = 表编号
-- 查询时带上 user_id
SELECT * FROM order_${dbIndex}.order_${tableIndex}
WHERE user_id = 1001 AND order_id = 20240101001;
我建议:分片键要满足两个条件:
- 查询频率高:90% 以上的查询都带这个字段。
- 分布均匀:不能出现热点数据。
3. 分库分表的挑战
分库分表不是银弹,它带来了新的问题:
- 跨库查询:不能 JOIN 了,得在应用层做聚合。
- 分布式事务:需要引入 Seata 或 TCC 方案。
- 全局主键:不能用自增 ID 了,得用雪花算法或 Leaf。
- 数据迁移:扩缩容时数据要重新分布,非常麻烦。
我的经验:能不拆就不拆。先试试分区表、读写分离、缓存优化。实在扛不住了再考虑分库分表。而且一开始就要设计好分片规则,不然后面改起来想死的心都有。
五、知识体系总览
下面这张图是我自己整理的数据库设计优化知识体系,你可以照着这个思路来排查问题。
好了,数据库设计优化这块就聊这么多。记住一句话:没有最好的方案,只有最合适的方案。根据你的业务场景和数据量,选择合适的优化手段,别一上来就搞分库分表,那玩意儿维护成本真不低。
最后送大家一句话:数据库优化是个持续的过程,不是一锤子买卖。上线后要持续监控慢查询,根据实际情况调整索引和表结构。我在回扣交易系统里,每个月都会 review 一次慢查询日志,该加索引加索引,该重构重构。