尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

数据库设计实战:从ER图到建表,避开那些常见坑

数据库设计实战:从ER图到建表,避开那些常见坑 直接进入正题。做后端、做数据分析、甚至做产品的人迟早都要面对数据库设计这关。我见过太多项目前期接口写得飞快上线两周开始补数据补到第40天发现当初设计表的时候少加了一个状态字段结果要同时改表结构、改接口、改缓存逻辑最后只能灰度迁数据。说实话很多系统从第一张表开始就已经注定后面要返工这不是编码能力问题而是数据库设计的基本功没夯实。这篇就把“第七章 数据库的设计”这个主题展开结合我这些年做过的实际项目从流程、理论到落地案例过一遍重点讲讲那些教科书上不会明说、但实战里特别容易踩的坑。1. 先搞清楚数据库设计到底在解决什么问题1.1 数据库设计不只是“建表”很多刚入行的同学觉得数据库设计就是根据需求写几个CREATE TABLE字段类型选对、主键加上、完事。这个理解不能说错但远远不够。数据库设计的本质是把现实世界的业务规则、实体关系、数据流转方式翻译成一组结构合理、扩展空间足够、查询效率可接受的表结构。翻译得好不好直接决定了后面的开发效率、查询性能、数据一致性甚至团队的协作成本。举个最直白的例子用户表该怎么设计新手可能只想到“用户名、密码、手机号”三个字段。但你仔细想想一个用户有昵称、头像、性别、生日、注册时间、最后登录时间、账号状态、来源渠道这些是不是都应该放一张表里如果后期要做多端登录第三方openid存哪如果用户有多个收货地址是塞进用户表还是单独拆分每一个决策背后都有取舍没有绝对的对错但每一步都必须有清晰依据。所以我更愿意把数据库设计理解为一种“预判的艺术”——在业务还没爆发式增长的时候就要预判未来的查询模式、数据量级、扩展方向在当下做出不至于推倒重来的决定。1.2 设计失误的代价是延迟爆发的数据库设计的失误不像代码bug那样当场报错它更像埋在地基里的隐患平时看不出问题等业务数据累积到一定量级、产品迭代开始加速的时候问题才集中爆发。我参与过一个校园二手交易平台的项目。前期为了图省事把商品分类做成了一级字段也就是直接在商品表里写一个“分类ID”分类表只有父子两层。结果运营团队后来想加三级分类改动范围直接波及商品表、搜索逻辑、首页聚合接口、后台管理端前后修了一个星期中间还出现过用户搜不到商品的线上事故。这就是典型的前期设计没有预留层次扩展空间。1.3 谁需要认真学数据库设计这个话题覆盖的人群比想象中广。后台开发工程师是最直接的日常就是跟表结构打交道数据分析师也需要懂因为取数时要理解指标背后的事实表和维度表是怎么组织的才能写出正确的关联查询产品经理和项目负责人同样建议了解否则在评审技术方案的时候缺乏判断力很容易在需求层面埋下数据设计隐患。数据库设计不是某个岗位的专属技能而是做数据相关工作的通用底层能力。2. 数据库设计的五个阶段从业务到建表2.1 需求分析先把业务流程聊透数据库设计的第一步不是画ER图而是做需求分析。这个阶段的核心任务是把业务的“是什么”和“怎么做”搞清楚。你需要和业务方或产品经理对齐几个关键问题系统核心要支撑哪些业务动作比如下单、退款、发货每个动作会产生哪些数据这些数据之间有什么关系未来半年到一年内业务规模大概是什么量级有没有已知的扩展计划实操中我喜欢用一个清单来兜底需求调研时逐项确认核心实体清单用户、订单、商品、支付记录等每个实体的关键属性实体之间的关系一对一、一对多、多对多高频查询场景和预期响应时间数据量级预估日增多少行、总行数规模数据合规要求哪些是敏感字段、是否需要脱敏需求分析如果偷懒后面所有阶段都会遭殃。我见过因为需求没明确“订单取消后库存要回滚”这个规则导致数据库里库存数量和订单明细对不上最后只能用定时任务做对账修正的案例。2.2 概念设计用ER图建立业务全景需求清楚之后进入概念结构设计。这个阶段的产物是ER图也就是实体-关系图它用“实体”“属性”“关系”三个核心元素描述业务数据全貌不涉及具体表结构和技术细节。画ER图时实体对应业务中的名词比如用户、商品、订单属性对应实体的特征比如商品的单价、库存量关系体现实体之间的连接比如一个用户可以有多个订单。常见的关系类型有三种一对一、一对多、多对多。一个用户拥有一个钱包账户属于一对一一个作者可以写多篇文章属于一对多一个学生可以选修多门课程一个课程可以有多名学生属于多对多。多对多关系在数据库落地时不能直接翻译成两张表需要引入中间表拆成两个一对多。2.3 逻辑设计从ER图到表结构的专业转换逻辑结构设计阶段要把ER图转化成关系模型也就是具体的表、字段、主外键、约束。这一阶段必须同时考虑范式理论和实际查询需求。分解ER图转关系模型的基本规则可以这样理解每个实体通常变成一张表实体的属性成为表的字段一对多关系通过在多的一方增加外键来实现多对多关系则需要新建中间表两张外键分别指向两端的实体表。这里有个关键点不要机械地照搬ER图。画ER图时可以理想化但转表结构时必须考虑查询路径。比如ER图上“用户表”和“订单表”是一对多关系逻辑设计时就要决定“用户ID”是否冗余到订单表。如果只从理论出发很容易设计出一堆只满足范式、却让查询变得无比复杂的结构。2.4 物理设计选存储引擎、定字段类型、建索引物理设计阶段要把逻辑模型落到具体的数据库产品上。需要做的决策包括选数据库类型MySQL、PostgreSQL、MongoDB等、定存储引擎如InnoDB、设置字符集和排序规则、设计索引、估算表分区方案等。先说数据库选型。业务数据强一致、事务要求高、结构化程度高优先选关系型数据库MySQL或PostgreSQL基本都能胜任数据量大、字段结构不固定、读写并发要求极高可以考虑引入NoSQL搭配使用但要注意一致性问题。中小团队建议默认选关系型数据库等确实遇到瓶颈再按场景引入其他存储。字段类型选择也很有讲究。一个Integer能解决的问题不要用VARCHAR存日期字段就用DATE或DATETIME不要用字符串存储日期。字符串存储日期至少带来三个问题无法利用日期函数做区间计算、排序结果不符合时间预期、占用空间更大。很多老系统里这种设计比比皆是迁移时苦不堪言。2.5 实施与运维上线只是项目中途数据库设计不是交付几张建表SQL就结束的后续的版本迁移、索引优化、数据归档都是设计的一部分。实施阶段要准备的东西很琐碎建表脚本、初始化数据脚本、索引脚本、测试环境的种子数据。建议从一开始就引入版本化管理用Flyway或Liquibase这类工具管理每次表结构变更而不是手动在生产库执行ALTER TABLE。没有版本控制的表结构三个月后没人知道哪张表被改过、为什么改。上线之后还要持续监控和优化。记录慢查询日志、分析执行计划、观察索引使用情况这些应成为长期习惯。3. 核心细节范式、主键、命名一个都不能糊弄3.1 范式理论该遵守到什么程度教科书把范式讲成一整套标准第一范式要求字段原子性第二范式要求消除部分依赖第三范式要求消除传递依赖。理论本身不复杂难的是拿捏“遵守到什么程度”。第一范式1NF基本必须遵守每个字段只存一个值。把多个手机号塞进一个字段用逗号分隔的做法会让后续查询变成噩梦。第二范式和第三范式在实际业务中就要开始权衡了。典型的例子是订单表冗余商品名称。按第三范式订单明细里只应该存商品ID商品名称要去商品表关联查询。但考虑到商品名称可能会变而订单快照必须保留下单时的名称几乎所有电商系统都会在订单明细里冗余一份商品名称和快照价格这是合理的反范式设计。核心原则增删改操作多的核心交易数据尽量满足第三范式复杂查询场景可以适度冗余但要对冗余字段的一致性负责。3.2 逻辑删除物理删除怎么选很多系统一张表里既有逻辑删除通过is_deleted字段标记又有物理删除直接DELETE这也是需要提前明确的。对于核心业务数据例如订单、支付流水、用户资产基本都建议逻辑删除保留完整历史痕迹方便审计回溯。对于那些可以重建的临时数据、日志类数据才考虑物理清理。逻辑删除需要注意的一个隐患如果大量查询忘记带上del_flag 0的条件会把已删除的数据查出来而且唯一索引也会受影响。处理方式通常是把唯一索引的字段和del_flag联合起来做唯一约束或者在必要时用生成列把删除标记编码进唯一键。这点在后面的常见问题里还会提到。3.3 主键选型自增ID、UUID还是雪花ID主键是每张表最重要的字段选型直接影响写入性能、存储占用和分布式扩展。三种主流方案各有适用场景自增ID性能最好、索引占用最小、对MySQL InnoDB聚簇索引非常友好适合单体应用、数据量可控的项目。缺点是在分布式环境下生成有瓶颈且可被外部猜测。UUID跨系统生成唯一标识非常方便在分布式环境下无需中心化发号器。但随机字符串作为主键会让InnoDB聚簇索引频繁页分裂写入性能下降明显存储占用也更大。雪花IDSnowflake兼顾趋势递增和全局唯一适合分布式架构。需要注意时钟回拨问题的处理实现时会比前两者多一点点复杂度。我的建议是中小项目直接用自增ID简单省事确定要做分库分表或微服务化就提前用雪花ID。UUID做业务标识可以但主流推荐把UUID放到别的字段上不要直接拿它当主键否则后期索引性能和空间都是问题。3.4 命名规范程序员要对自己写的字段名负责命名规范在教科书里几乎没人强调但实际协作中非常重要。团队里如果没有统一的命名规范时间一长新老成员写的字段风格完全对不上联调时三天两头因为“这字段是userName还是username”来回确认。我自己常用的规范是表名用业务域加下划线比如user_account、order_info、product_sku字段名统一小写加下划线主键统一叫id创建时间、更新时间分别叫created_at、updated_at业务外键取名格式为业务含义加_id比如user_id、order_id状态字段统一叫status并且制定清晰的状态值字典1、2、3各自代表什么不能同一个语义在不同表里数值含义还不一样。另外所有表都必须包含created_at和updated_at两个时间字段这是调试和数据排查的基础设施没有它们出了问题连数据是什么时候写入的都不知道。4. 完整实战从零设计一个电商订单系统的核心表4.1 需求场景与业务规则整理为了让上面的方法论更容易落地这里用一个精简的电商订单系统作为完整案例。核心业务规则先梳理清楚平台上有用户、商品、SKU库存单位用户下单后会生成订单头和订单明细订单有状态流转待支付、已支付、待发货、已发货、已完成、已取消支付成功后要记录支付流水商品名称和历史价格要冗余到订单明细里做快照订单创建、支付、发货等关键动作要记录操作日志数据量预估日订单量初期约10万单订单明细约20万条一年订单总量在3000万级别属于需要认真考虑索引策略、且后期可能需要分表的量级。4.2 从需求到ER图的拆解从上述需求中识别出核心实体至少包括用户user、商品product、商品SKUproduct_sku、订单order_info、订单明细order_item、支付流水payment_record、订单操作日志order_log。实体关系定义为一个用户有多张订单一张订单属于一个用户一对多一张订单包含多个明细一个SKU对应多个明细一对多一张订单以正常状态对应一条支付流水特殊场景下可能出现多条比如部分退款所以按一对多设计更稳妥一个订单有多条日志记录一对多。这里特别强调订单模型的一个经典设计订单头和订单明细要分开。如果不拆直接把商品信息塞在订单表里一张表要承载订单级属性和明细商品属性会导致数据大量冗余订单状态判断逻辑也会变得非常混乱。4.3 物理表结构落地关键建表语句参考逻辑模型确定后给出几张核心表的实际建表SQL。这里以MySQL 8.0为例字符集使用utf8mb4所有表都带created_at和updated_at。用户表CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(64) NOT NULL COMMENT 用户名, password_hash VARCHAR(128) NOT NULL COMMENT 密码哈希值, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 2禁用, 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_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;订单头表CREATE TABLE order_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL COMMENT 下单用户ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, pay_amount DECIMAL(10,2) NOT NULL COMMENT 实付金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2待发货 3已发货 4已完成 5已取消, receiver_name VARCHAR(64) NOT NULL COMMENT 收货人姓名, receiver_phone VARCHAR(20) NOT NULL COMMENT 收货人电话, receiver_address VARCHAR(255) NOT NULL COMMENT 收货地址, remark VARCHAR(255) DEFAULT NULL COMMENT 用户备注, paid_at DATETIME DEFAULT NULL COMMENT 支付时间, shipped_at DATETIME DEFAULT NULL COMMENT 发货时间, finished_at DATETIME 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_order_no (order_no), KEY idx_user_id (user_id), KEY idx_status (status), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单头表;金额为什么不建议用FLOAT或DOUBLE现场给个反例就明白了0.1加0.2用二进制浮点存储会出现误差虽然看起来只差零点几但财务对账时对不上就是事故。用DECIMAL(10,2)能精确表示到分满足绝大多数交易场景。订单明细表CREATE TABLE order_item ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, order_id BIGINT NOT NULL COMMENT 订单ID, sku_id BIGINT NOT NULL COMMENT SKU ID, product_name VARCHAR(128) NOT NULL COMMENT 商品名称快照, sku_attrs VARCHAR(255) DEFAULT NULL COMMENT SKU规格快照如黑色;128G, product_price DECIMAL(10,2) NOT NULL COMMENT 下单时单价, quantity INT NOT NULL DEFAULT 1 COMMENT 购买数量, subtotal_amount DECIMAL(10,2) NOT 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), KEY idx_order_id (order_id), KEY idx_sku_id (sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;订单明细表里的product_name、sku_attrs、product_price就是典型的有意冗余。商品表和SKU表的数据后续可能调整但订单必须保留用户下单那一刻看到的商品信息和价格这是交易快照的基本要求。4.4 索引设计不是越多越好索引设计是这个案例的核心技术点之一直接说结论索引要跟查询走不是跟字段走。高频查询路径有哪些索引就建在哪些路径上。比如订单表高频查询包括“用户查自己的订单列表”那idx_user_id必须建“运营后台按订单状态筛选订单”那idx_status建上按时间范围统计数据idx_created_at建上。同时尽量考虑联合索引比如“查询某用户在某个时间段的订单”(user_id, created_at)联合索引会比两个独立索引更高效。联合索引的设计有几个常见误区。最典型的是顺序问题如果索引是(user_id, created_at)查询条件只带created_at是走不了这个索引的。所以设计联合索引时要遵守最左前缀原则把最常用的条件字段放最前面。但是不要给每列都建索引。索引占用额外的存储空间而且在写入时要同步更新索引结构索引越多写入越慢。这个订单系统初期有5到6个索引足够了等真正出现慢查询再做增量优化都来得及。4.5 订单号唯一约束的坑订单号order_no在设计时用UNIQUE KEY约束这是必须的但要注意一个分布式环境下的细节如果用时间戳加随机数生成订单号并发量上来后有一定概率生成重复订单号一旦插入时撞了唯一索引接口就会报错用户感觉就是下单失败。更稳的做法是用雪花ID直接作为order_no或者用专门的发号器生成不重复的序列。这个坑在压测时很容易暴露出来提前设计好生成方式省得改到一半再来修。4.6 订单状态和时间字段的扩展预留订单表里设计了paid_at、shipped_at、finished_at三个可空时间字段。有些项目为了省事只放一个status字段真到运营需要分析“从下单到支付平均花多久”却发现时间全在日志表里查询复杂度直接上升几个量级。关键业务状态切换的时间点在业务表里留字段记录成本很低但价值非常大。5. 高频问题排查与长期维护经验5.1 常见问题速查表把实际运维中遇到的高频问题整理成一张表方便快速定位现象常见原因处理思路查询越来越慢一张表几百万行就扛不住索引缺失或索引设计不合理用EXPLAIN看执行计划找到全表扫描的查询针对性建索引数据对不上账汇总金额与明细不符并发写入缺少锁或事务隔离级别设置不当核对事务边界明确悲观锁/乐观锁策略删除数据后ID却继续大幅跳号逻辑删除/唯一索引冲突导致插入失败消耗了自增ID属于正常现象无需处理如果是业务上不允许改用业务主键走索引还是很慢扫描行数异常大索引区分度差比如status字段大量集中在同一个值评估是否有必要单独建索引必要时改为联合索引多个服务同时插入同一张表出现锁等待热点行更新冲突考虑异步化、拆分热点表或调整事务范围5.2 慢查询和死锁的排查思路慢查询排查是最常见的日常任务。拿到一条慢SQL第一步用EXPLAIN看执行计划重点关注三个指标type从高到低依次是system、const、eq_ref、ref、range、index、ALL如果达到ALL说明是全表扫描key有没有命中索引rows字段预估扫描行数。这三个指标能定位80%的问题。死锁则更隐蔽。曾经遇到一个场景两个服务分别以不同顺序更新同一组资源导致互相持有对方需要的锁。排查时打开死锁日志能看到两条事务获取锁的顺序完全相反。解决办法是统一约定所有代码里对资源的加锁顺序。对于电商库存、账户余额这种热点更新单纯加行锁在高并发下性能其实很差实际项目里通常会改为预扣减库存加异步对账或者用Redis做前置扣减再异步落库但引入这些方案同时要承担一定的数据一致性风险必须结合业务场景取舍。5.3 表结构版本管理别再手动ALTER了很多团队对表结构的管理方式仍然是“生产环境手动执行SQL”这是高风险行为。正确做法是引入数据库迁移工具每一次结构变更都走自动化脚本流程。以Flyway为例把V1__create_user_table.sql、V2__add_order_index.sql这类带版本号的脚本提交到代码仓库构建部署阶段自动执行。这样带来几个好处环境之间结构一致不会出现测试环境改了字段、生产环境忘改的情况变更历史可追溯出了问题知道是哪次变更引起的新同事拉下代码能快速恢复整套表结构。这个习惯建议从一开始就养成团队再小也适用。5.4 数据量增长后的分库分表与归档任何系统做到后面都会面临数据量增长的问题。订单表三五年后累积几个亿的数据正常查询都会明显变慢这时候一般有两种处理路线归档和分片。归档策略是把历史数据比如三年前的已结束订单迁移到归档表或冷存储在线表只保留热数据查询性能能得到显著改善。方案更彻底但运维和开发复杂度会明显上升比如跨分片查询、全局ID生成都从“可选”变成“必选”。我的建议是除非当前单表数据量已经明确达到性能瓶颈否则不要提前引入分库分表。可以先把归档做好绝大多数场景下能解决90%的问题。6. 最后分享一个长期受益的反思习惯数据库设计这块我自己的体会是与其急着追求一次设计得绝对完美不如把每一次设计都当成对业务理解的复盘。项目上线半年后再回头看你设计的表哪些字段一开始就预料到了哪些字段是中途补的当初为什么没想到把这些问题记录下来下次你的预判能力会明显提升。还有一个具体的小技巧设计表结构时每张表都顺手写清楚COMMENT字段注释也尽可能完整。当时可能觉得浪费时间但三个月后你自己翻代码都会感谢当时的注释。顺手加字段注释的成本几乎是零收益却是长期的反正我做了十年项目从来没有因为注释太多而后悔过。另外提醒一句数据库设计文档不要写完就丢。可以把核心表结构、关键索引方案、特殊设计取舍以简短的Markdown文档形式放在代码仓库里随代码一起维护。文档不追求大而全把那些“当时为什么这么设计”的判断记清楚就足够了。团队里换人、交接、复盘这份文档比任何口头说明都管用。
返回列表