从零给一个"租房网站"设计数据库:我把整套流程和底层原理都捋明白了
前段时间做项目,我干了件特别"新手"的事:需求还没看明白,直接打开 Navicat 就开始 create table。结果呢?表建到一半发现字段漏了,关系理不清,改了一张表连带着三四张表跟着动,最后干脆推倒重来。
那次之后我才认真去啃"数据库设计"这件事。啃完最大的感受是:建表从来不是敲 SQL 那么简单,它是把一坨模糊的业务需求,一层层翻译成机器能高效存取的结构化数据的过程。 这中间每一步都有讲究,每个字段类型、每个前缀、每个冗余背后都有原因。
这篇我就用一个完整的"租房网站"项目当例子,从软件是怎么"生"出来的一路讲到最底层的 B+ 树和三大范式。我会尽量把"为什么这么做"讲透,而不只是告诉你"应该这么做"。内容有点长,建议泡杯茶慢慢看。
一、先别急着建表:一个软件是怎么"活"过来的
很多人(包括之前的我)一上来就想写代码、建表,其实这在软件工程里是很靠后的动作。一个正经的软件项目,从有想法到最后下线,要经历一条完整的"生命周期",大致分成十个阶段:
可行性研究 → 需求分析 → 概要设计 → 详细设计 → 实现 → 组装(集成)测试 → 确认测试 → 使用 → 维护 → 退役。
听着挺抽象,我特别喜欢一个"西红柿炒鸡蛋"的类比,一下就懂了。假设张三某天晚上突然想吃西红柿炒鸡蛋,决定自己动手:
| 做这道菜 | 对应软件生命周期 |
|---|---|
| 想吃,而且自己会做 | 可行性研究:需求产生,技术上能实现 |
| 定口味、分量、要不要加配菜 | 需求分析:确定功能和规模 |
| 按口味定调料多少,按分量定备多少菜 | 设计阶段(概要 + 详细) |
| 开火炒 | 实现(开发)阶段 |
| 尝一口,淡了加盐咸了加西红柿 | 测试阶段 |
| 端上桌,开吃 | 部署、使用阶段 |
| 吃完洗碗收拾 | 退役 |
你看,没人会不尝一口、不定分量就直接闷头炒一大锅。数据库设计也是同理。
那数据库设计具体落在生命周期的哪一段?主要是在需求分析 和概要设计这两个阶段。需求分析阶段产出的需求文档,会直接指导数据模型怎么建、库结构怎么规划,同时还要考虑数据的安全性、隐私保护、备份恢复策略;到了概要设计阶段,就要搭起系统总体结构、定义模块接口、设计全局的数据库或数据结构了。
为什么非要这么早、这么慎重地设计数据库? 这里有个很现实的底层原因:数据库表结构一旦上线并灌入大量数据,再想改,代价是巨大的。
改字段类型、加索引这类操作,在数据量大的时候往往是"锁表"级别的重活------你可能听说过线上执行一个 ALTER TABLE 把整张表锁住、导致业务卡死几分钟甚至更久的事故。更麻烦的是数据迁移:字段拆分、表结构重构,意味着要写脚本把几百万行老数据搬到新结构里,还得保证不丢不错。所以数据库设计的核心哲学是"一次想清楚,尽量少返工",前面多花的时间,都是在给后面省事。
二、数据库设计的四个阶段:一张总览图
把"设计数据库"这件事拆开,业界公认分成四个阶段,一环扣一环:
-
需求分析------搞清楚到底要存什么、要支持哪些业务。
-
概念设计------用 E-R 图这种"人话图形"把实体和关系画出来。
-
逻辑设计------把图翻译成具体的表结构:字段名、类型、长度、关系。
-
物理设计------考虑真正落地的存储细节:存哪、要不要建索引、要不要分区。
我用"盖房子"再打个比方,你会发现这四步特别自然:
-
需求分析 = 跟业主聊清楚要几室几厅、住几口人、要不要书房;
-
概念设计 = 画个草图,客厅在哪、卧室在哪、门朝哪开,先不管尺寸;
-
逻辑设计 = 出施工图,每面墙多厚、每根管道多粗,标注得明明白白;
-
物理设计 = 定地基怎么打、水电怎么走、用哪种建材最划算。
这四个阶段各自的产出也很明确:
需求分析产出需求文档,它指导后面所有设计,还包括数据安全、隐私、备份恢复的要求。
概念设计产出数据模型,主要是 E-R 模型(实体-关系模型)和类图。E-R 模型用"实体、属性、关系"三个东西描述数据结构,是理解数据关系的利器;类图更偏向对象之间的关系,适合面向对象的思路。
逻辑设计把概念落成表结构和视图定义。表结构包括字段名称、数据类型、长度这些属性,以及表和表之间的关系;视图则能简化复杂查询、提升安全性和易用性。
物理设计考虑存储细节:文件存哪会影响访问速度和备份策略;索引要建,但得权衡查询效率和维护成本;数据量特别大时还要考虑分区,提升管理效率。
下面我们就正式进入实战,把这套流程在一个真实的"比特租房网"项目上完整跑一遍。
三、需求分析:把"人话"翻译成"数据"
我们的项目是一个租房网站。它的核心业务其实就俩字:找房 和租房。围绕这个核心,有两个关键角色------一个是来找房租房的用户(我们叫 C 端用户,C 就是 Consumer 消费者),另一个是房源信息本身。此外还有一堆支撑角色:管后台的 B 端用户(Business 商家)、行政区域、字典配置、聊天消息等等。
需求分析最核心的动作,叫提取概念类。说白了就是:把需求文档和原型图里的名词全捞出来,然后分三类------
-
哪些名词应该当成类(实体),将来要单独建一张表;
-
哪些名词只是某个类的属性,将来是表里的一个字段;
-
哪些名词是干扰项,直接舍弃。
这一步是整个设计的地基,也是最考验功力的地方。判断一个名词到底是"实体"还是"属性",其实有底层标准,我总结成三问:
第一问:它需要被唯一标识吗? 如果一个东西需要有自己的"身份证号"(主键)来精确定位,那它多半是实体。比如"房源",每一套房子都得有个唯一 id 来区分,它是实体;而"房源的面积"不需要单独标识,它就是房源的一个属性。
第二问:它有自己独立的生命周期吗? 如果一个东西会被创建、修改、删除,且这个过程独立于别的东西,那它是实体。比如"聊天会话",它自己会被建立和关闭,是实体;而"会话里的某条消息内容"依附于会话,更偏属性(当然消息量大时我们也会把它升级成独立的表,这就看业务了)。
第三问:它会被多个地方引用吗? 如果一个信息要被很多张表反复用到,那它值得单独抽成实体,避免到处重复存。比如"行政区域",房源要用、用户筛选要用、首页展示要用,所以它是独立实体。
我们以 C 端用户为例,完整走一遍这个"牛刀小试"。
相关业务:用户必须注册登录才能用系统里的功能。C 端用户注册登录后,可以查找房源、聊天、租房。
看原型图提属性:从注册登录页面的原型图能看出,用户相关的信息有昵称、电话、微信 openId、头像、密码这些。
得到属性列表:用户昵称、电话号码、微信 openId、头像地址、密码、盐、个人简介。
这里插一句"盐(salt) ",很多人第一次见会懵。盐是密码加密里的一串随机值。底层原理是这样的:如果直接把密码用 MD5 之类的哈希存起来,两个用同样密码的人,数据库里的密文会一模一样,攻击者拿一张"常见密码-哈希"对照表(彩虹表)就能反查出来。加了盐之后,每个人的盐都不同,哈希(密码 + 盐) 的结果就人人不同,彩虹表直接失效。所以 password 和 salt 这两个字段是一对搭档,专门用来安全地存密码。
需求理清了,接下来就该把它画成图了。
四、概念设计:E-R 图,让人和人都能看懂的"数据地图"
概念设计阶段的主角是 E-R 图(Entity-Relationship,实体-关系图)。它干的事就是:用图形化的方式描述有哪些类、每个类有哪些属性、类和类之间是什么关系。
为什么非画图不可? 因为它是一张"沟通地图"。产品、开发、测试、老板,不是每个人都看得懂 SQL 和表结构,但一张 E-R 图大家都看得明白。开评审会的时候,指着图说"用户和房源是这么个关系",比念一百行建表语句高效太多。图形化本身就是它最大的价值。
E-R 图里就三种元素:
-
实体(Entity):现实世界里能区分的对象,比如"用户""房源"。在图里通常画成矩形。
-
属性(Attribute):实体的特征,比如用户的"昵称""电话"。通常画成椭圆。
-
关系(Relationship):实体之间的联系,比如"用户租了房源"。通常画成菱形。
而关系里最重要的,就是三种基数关系:一对一、一对多、多对多。这三个是数据库设计的命根子,我用生活例子讲清楚,再挖它们背后的实现原理。
一对一(1:1)
一个人对应一个身份证号,一个身份证号对应一个人。在租房系统里,一套房源在某一时刻只有一种出租状态,所以"房源"和"房源出租状态"就是一对一。
底层实现:一对一通常有两种做法。要么把两边的字段合并进一张表(如果两边字段都不多、总是一起用);要么拆成两张表,让从表的主键同时也是外键,指向主表。为什么有时候要拆?因为有些字段访问频率低、或者很大(比如一段长文本),拆出去能让主表更"瘦",查询更快------这又涉及到底层存储:一张表每行越小,一个数据页能装的行越多,同样的查询要读的页就越少,IO 就越省。
一对多(1:N)
一个班级有多个学生,一个学生只属于一个班级。在系统里,一个字典类型下有多个字典条目 (比如"房屋朝向"这个类型下,有"南""北""南北"好几个条目),这就是典型的一对多;同理一个聊天会话下有多条聊天内容。
底层实现:一对多的做法是在"多"的那一方(从表)加一个外键字段,指向"一"的那一方(主表)的主键 。比如字典条目表里放一个 type_key 字段,指向字典类型表。为什么外键放在"多"的一方而不是"一"的一方?想想就知道:如果放在"一"的一方,一个字典类型要存它下面所有条目的 id,那得是个列表,一个字段存不下(存得下也违反了后面要讲的"原子性");而放在"多"的一方,每个条目只需记一个"我属于谁",干净利落。
多对多(M:N)
一个学生可以选多门课,一门课也可以被多个学生选。在系统里,一套房源可以打多个标签,一个标签也能被贴在多套房源上 ,这就是多对多;一套房源可以关联多条字典配置(房屋设备、出租类型、朝向、居室数量等等),也是多对多。
多对多是三种关系里最特殊的,因为它没法靠加一个外键字段解决 。底层原理是这样的:假设你硬要在房源表里加个 tag_id 字段,那一个房源有多个标签时你就存不下;反过来在标签表里加 house_id,一个标签贴多个房源又存不下。两边都"装不下"对方的多个值。
唯一的正解是:单独建一张"关系表"(也叫中间表、连接表) ,这张表至少有两列,分别存两边的主键。比如 rel_house_tag 表,一列 house_id、一列 tag_code,每一行代表"某套房源贴了某个标签"。一个房源贴 3 个标签,这张表里就有 3 行;一个标签被 100 套房源用,就有 100 行。多对多就这样被优雅地拆成了两个一对多。
记住这句话:一对多在从表加外键,多对多单独建关系表。 这是后面所有建表的黄金法则。
五、逻辑设计:把图变成能落地的表
概念图画完,进入逻辑设计------把图形翻译成实打实的表结构,定字段名、类型、长度、关系。这一步有大量"约定俗成"的规范,每条规范背后都有原因。我挑最关键的几块深挖。
5.1 表名前缀:不是强迫症,是有用的
项目给数据库起名 bit_zufang,然后给每张表按业务类型加前缀:
-
系统表
sys_:比如系统用户表sys_user、角色表sys_role; -
业务表
app_:比如用户表app_user、房源表app_house; -
字典表
dic_:比如地区表、ICD 编码表; -
关系表
rel_:比如rel_user_role,专门存多对多关系数据; -
统计表
sta_:比如sta_product_sale,从别的表算出来的统计数据。
为什么要费劲加前缀? 底层原因有三:一是可读性 ,一个库几十上百张表,show tables 出来密密麻麻,有前缀你一眼就知道这张表是干啥的、属于哪一类;二是便于管理和分库分表 ,将来数据量大了要拆库,按前缀分组迁移会清晰很多;三是权限控制,可以按前缀给不同角色授不同的读写权限。
顺便说个真实的"坑":这个项目里 B 端用户表建出来叫 sys_usre、C 端用户表叫 app_usre------user 拼成了 usre。这其实是个 typo,但它一旦被建出来、被代码到处引用,改起来就极其麻烦(要同步改所有 SQL、所有 ORM 映射)。这恰好印证了前面说的:命名要一次定对,返工成本极高。 我把它当反面教材记在了小本本上。
5.2 公共字段:每张表都该有的"标配"
项目规定,没有特殊情况,每张表都必须有这几个公共字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
bigint | 编号,主键自增 |
state |
tinyint | 状态,0 正常,1 禁用 |
deleteState |
tinyint | 是否删除,0 否,1 是 |
createTime |
dateTime | 创建时间,精确到秒 |
updateTime |
dateTime | 更新时间,精确到秒 |
这几个字段看着平平无奇,其实每一个都值得深挖。
为什么主键用 bigint 自增,而不是别的? 这里藏着 InnoDB 存储引擎的核心原理。InnoDB 的表数据是按主键顺序组织在一棵 B+ 树 上的(这叫"聚簇索引",后面第九章会详细讲)。如果你用自增 id,新插入的行永远是往树的"最右边"追加,是顺序写入 ,磁盘友好、几乎不会打乱已有结构。但如果你用 UUID 这种随机字符串当主键,新行会随机插到树的各个位置,导致页分裂------原本装满的数据页被硬生生拆成两页,不仅写入变慢,还会让数据碎片化、占用更多空间。所以自增 bigint 是主流选择。用 bigint 而不是 int,是因为 int 最大约 21 亿,大表的自增 id 是可能溢出的,bigint 大到这辈子都用不完。
为什么要用 deleteState 做"逻辑删除",而不是真的 DELETE 掉? 这是新手最容易忽略、但特别重要的设计。真删除(物理删除)的问题在于:数据一旦没了就找不回来,误删就是事故;而且很多数据有关联,删了主表数据,从表里指向它的记录就成了"孤儿"。逻辑删除的做法是:数据其实还在表里,只是把 deleteState 标成 1,查询时统一加一句 where deleteState = 0 把它过滤掉。好处是可追溯、可恢复、不破坏关联完整性,还能保留历史数据做审计。代价是表会越来越大、查询要记得带条件------但总体利大于弊,几乎是互联网项目的标配。
createTime 和 updateTime 有什么用? 除了记录数据的历史,它们在排查问题时是救命的:这条数据啥时候建的、最后一次被谁改的(配合业务日志)、最近有没有异常更新,全靠这俩时间戳。很多团队会让 updateTime 自动更新(ON UPDATE CURRENT_TIMESTAMP),省得每次手动维护。
5.3 字段类型里的大学问
逻辑设计最容易翻车的就是字段类型选错。我挑几个项目里用到、且特别能体现底层原理的讲讲。
金额、面积为什么用 decimal 而不是 float/double? 这是个经典考点,底层原理很有意思。float 和 double 是浮点数,它们在计算机里是用二进制科学计数法存的,而很多十进制小数(比如 0.1、0.2)在二进制里是无限循环 的,存进去只能存个近似值。于是就会出现 0.1 + 0.2 = 0.30000000000000004 这种灵异现象。金额差一分钱都是事故,所以必须用 decimal------它按十进制精确存储,你存 3.14 就是精确的 3.14。项目里 price decimal(10,2) 表示总共 10 位、小数 2 位,正好适合存钱。
经纬度为什么是 decimal(12,7)? 同理,经纬度需要精确到小数点后很多位。经度范围 -180~180,纬度 -90~90,整数部分最多 3 位,加上小数 7 位,总共 10 位就够,这里给到 12 位是留了余量。小数点后 7 位的精度大约是厘米级,对地图定位完全够用。如果这里用 float,定位会出现漂移,那体验就崩了。
出租开始/结束时间为什么用 bigint 存"时间戳",而 createTime 却用 dateTime? 这是一个很值得琢磨的取舍。时间戳(从 1970 年 1 月 1 日到现在的秒数或毫秒数)是个整数,用 bigint 存。它的好处是:占用小、比较和计算快 (整数运算比日期类型快)、天然没有时区歧义 (存的是绝对时刻)。而 dateTime 的好处是人能直接看懂 ,2026-09-09 21:00:00 一眼明了,不用转换。所以项目的选择很讲究:给"人看、用于展示和审计"的字段(创建/更新时间)用 dateTime;给"机器算、用于业务逻辑比较"的字段(租期起止)用 bigint 时间戳。这不是随意的,是按用途分的。
varchar(64)、varchar(255) 这些长度怎么定? varchar 是变长字符串,只占用实际内容长度 + 一点点长度前缀。定长度的原则是"够用 + 留合理余量":昵称给 64,头像 URL 给 255(URL 通常较长),房屋介绍这种大段文字干脆用 text 类型。为什么不全给最大值?因为长度会影响内存分配和索引------比如你要给一个 varchar 字段建索引,长度越大,索引占的空间越大。而且 text/blob 这类大字段在底层是可能溢出到单独的页去存的,把它和常用小字段混在一起会拖慢主查询,所以像"房屋介绍"这种大文本,用独立的 text 类型、甚至有时拆到单独的表,都是这个考虑。
5.4 冗余字段的取舍:明知违反范式,为何还要这么做
细心的话你会发现,房源表 app_house 里既存了 city_id(城市编号,关联行政区域表),又存了 city_name(城市名)。按"规范化"的理论,城市名应该去行政区域表里查,这里存一份是冗余,是违反范式的。
但项目偏偏这么干了,还专门建了个 rel_house_city(房源-城市关联表)来冗余"房源和城市"的关系。文档里说得很直白:"为了首页查询方便"。
这就是工程上的"反范式 "取舍,底层逻辑是这样的:首页要展示"用户所在城市的房源列表",如果城市名不冗余,每次查列表都得把房源表和行政区域表做 JOIN(连接)。JOIN 在数据量大时是很贵的操作------两张表要按关联字段匹配,可能涉及大量磁盘 IO 和内存计算。而租房这种场景是典型的"读多写少 ":一套房源的信息写好之后,会被无数用户反复查看。既然如此,在写入时多存一份城市名(写的时候稍微麻烦点、多占点空间),换来读取时不用 JOIN(快得多) ,这笔买卖非常划算。这就是所谓的"空间换时间""用一点冗余换查询性能"。
所以记住:范式是默认要遵守的好习惯,但它不是铁律。 当你有明确的性能瓶颈、且场景是读多写少时,有意识地、可控地做一点冗余,是成熟工程师的标志。关键是"心里门儿清"------你知道自己违反了什么、为什么违反、代价是什么。
六、把整个租房系统串起来
前面把方法论和原理讲透了,现在我们把 13 张表连起来看,感受一下完整系统的设计脉络。我不逐字段念,只讲每张表"为什么存在"和"关系怎么连"。
B 端用户表 sys_usre:管后台的账号,比普通用户多了个 identity(身份)字段,用来区分是普通管理员还是超级管理员。它的权限具体值放在字典里配置,所以 B 端用户和字典详情是多对一关系。
行政区域表 sys_region:存全国的省市区。它有个精妙的设计------用 parent_id(父级 id)和 level(省-1、市-2、区-3)来表达层级。省包含市、市包含区,通过 parent_id 指向上一级。这意味着这张表自己和自己就是一对多关系 (一个省下多个市)。这种"自关联"结构叫邻接表,是存树形数据最经典的方式,前端那个"省→市→区"级联下拉框,底层就是靠它一层层查出来的。
字典类型表 sys_dic_type + 字典条目表 sys_dic_data:这俩是我最想夸的设计。系统里所有"选择项"------房屋朝向、租房类型、居室数量、租金范围------都不写死在代码里,而是放进字典表。类型和条目是一对多(一个类型下多个条目)。
为什么用字典表而不是直接在代码里写枚举? 底层原因是扩展性 。假设哪天产品说"朝向要再加个'东南'",如果朝向是硬编码在代码里的,你得改代码、重新编译、重新上线,一套流程走下来半天没了;但如果它在字典表里,运营在后台点几下"新增条目"就搞定了,代码一行都不用动。把"会变的业务选项"从代码里抽出来放进数据库,是应对需求变化的经典手法,这也是"良好设计能灵活应对业务变化"的具体体现。
房源标签表 dic_tag:给房源打的标签,比如"近地铁""精装修"。前面说过,房源和标签是多对多 ,所以配套有一张 rel_house_tag 关系表。
房源出租状态表 app_house_status:描述房源是否上架、租期多久。和房源是一对一(同一时刻一套房源只有一种状态)。
房源表 app_house:整个系统的核心大表,字段最多------房东 id、标题、租房类型、楼层、户型、居室、朝向、面积、价格、设备、图片、城市、区域、社区、详细地址、经纬度、介绍......它向外辐射出好几条关系:和出租状态一对一、和标签多对多(经 rel_house_tag)、和行政区域多对一、和字典详情多对多。
房源-城市关联表 rel_house_city:就是 5.4 讲的那个"为首页查询做的冗余优化表"。
聊天会话表 app_session + 聊天内容表 app_message:用户每发起一次聊天,就在会话表加一条记录(记下发起方 send_user_id 和接收方 receiver_user_id);这个会话里产生的每一句话,都记在内容表里,用 session_id 关联回会话。所以会话和内容是一对多 。内容表里还有个 type 字段区分消息类型(文本、语音、图片、视频、房源询价、URL),以及 visited/state 标记已读未读------这个设计支撑了整个 IM 聊天功能。
C 端用户表 app_usre:就是第三章我们"牛刀小试"设计的那张表,普通租房用户。一个用户可以租多套房源,所以和出租状态表是一对多;一个用户可以发起多个会话,和会话表是多对一;聊天中产生多条记录,和内容表是一对多。
系统参数表 sys_argument:存一些系统级别的动态配置参数(用 key-value 形式),比如一些开关、阈值。它比较独立,跟别的表没什么关系。
把上面所有关系汇总一下,你就能在脑子里画出这张系统的"关系网"了:
-
字典类型 → 字典条目:一对多
-
聊天会话 → 聊天内容:一对多
-
房源 → 出租状态:一对一
-
房源 ↔ 标签:多对多(靠
rel_house_tag) -
房源 → 行政区域:多对一
-
行政区域自己 → 自己:一对多(省市区的层级)
七、物理设计与建库:那些藏在 SQL 里的细节
逻辑设计定完,就该落地建库建表了,这属于物理设计范畴。先看开头三句建库 SQL,里面藏了两个高频考点:
-- 删除数据库(如果存在)
drop database if exists bit_zufang;
-- 创建数据库
create database if not exists bit_zufang
character set utf8mb4 collate utf8mb4_0900_ai_ci;
-- 选择数据库
use bit_zufang;
为什么字符集是 utf8mb4 而不是 utf8? 这是 MySQL 一个历史大坑。MySQL 里的 utf8(准确叫 utf8mb3)是"残废版",它每个字符最多只用 3 个字节。而真正的 UTF-8 标准里,有些字符(比如 emoji 😀、一些生僻汉字)需要 4 个字节才能表示。用 utf8 的话,用户昵称里带个 emoji 就直接报错或存成乱码。utf8mb4(mb4 = most bytes 4)才是完整的 UTF-8,能存下所有字符。所以现在的铁律是:永远用 utf8mb4,别用 utf8。
utf8mb4_0900_ai_ci 又是什么鬼? 这是"排序规则"(collation),决定字符怎么比较和排序。拆开看:0900 指基于 Unicode 9.0 标准的排序算法;ai = accent insensitive,不区分重音(é 和 e 视为相同);ci = case insensitive,不区分大小写(A 和 a 视为相同)。它是 MySQL 8.0 的默认排序规则。理解它有什么用?比如你查 where name = 'abc',在 ci 规则下 'ABC' 也会被匹配到------这个行为就是排序规则决定的。
再补一个物理设计绕不开的选择:存储引擎用 InnoDB。 MySQL 有 InnoDB 和 MyISAM 两大引擎,现在无脑选 InnoDB。底层原因是:InnoDB 支持事务 (一组操作要么全成功要么全回滚,转账这种场景的命根子)、支持行级锁 (改一行只锁一行,并发高;MyISAM 是表级锁,改一行锁整张表,并发差)、有聚簇索引和外键支持。MyISAM 除了某些极端读场景外基本已经被淘汰。
至于建表的完整 SQL,就是把前面每张表的字段用 create table 写出来,每个字段带上 not null、default、comment 注释。我贴一段最典型的房源表片段,你能看到规范是怎么落到 SQL 里的:
create table app_house (
id bigint primary key auto_increment comment '编号,主键自增',
user_id bigint not null comment '房东编号,关联 app_user',
title varchar(50) not null comment '标题',
rent_type varchar(20) not null comment '租房类型,关联 sys_dic_data',
floor int not null comment '所在楼层',
price decimal(10,2) not null comment '价格(元)',
city_id bigint not null comment '城市编号,关联 sys_region',
city_name varchar(40) not null comment '城市名(冗余,为首页查询优化)',
intro text comment '房屋介绍',
state tinyint not null default 0 comment '状态,0 正常,1 禁用',
deleteState tinyint not null default 0 comment '是否删除,0 否,1 是',
createTime datetime not null comment '创建时间',
updateTime datetime not null comment '更新时间'
);
注意每个字段的 comment------给字段写注释是个好习惯,半年后你自己回来看表,或者新人接手,全靠这些注释理解字段含义。建完库 show tables 一看,13 张表整整齐齐列在那儿,一个数据库就诞生了。
八、逆向工程:用 Workbench 把数据库"画"回 EER 图
表建好了,怎么验证关系、怎么给团队展示?答案是逆向导出 EER 图。
EER 全称 Enhanced Entity-Relationship Model,增强实体关系图。它比普通 E-R 图更进一步,能直观地用图形显示表结构 以及表与表之间的关系。
工具用 MySQL Workbench,操作很简单:菜单栏 File → New Model 新建模型,然后 Database → Reverse Engineer...(逆向工程),跟着向导选要导出的数据库,它就会自动把表结构画成图。画布太小的话,Model → Diagram Properties and Size 调整一下尺寸。
这里有个特别值得讲的点:逆向出来的图,一开始是没有表间关系连线的。 为什么?因为我们建表的时候,故意没有写数据库层面的外键约束 (foreign key)。
为什么很多互联网项目不在数据库里建外键约束? 这是个高频面试题,底层原因有几条:
-
性能:外键约束意味着每次增删改,数据库都要额外去检查关联表里有没有对应记录、有没有被别的表引用。高并发场景下,这种检查会带来可观的开销。
-
分库分表的阻碍:数据量大了要做分库分表,而外键约束是没法跨库跨表生效的,建了外键反而会成为拆分的路障。
-
灵活性:批量导数据、数据迁移时,外键约束会因为顺序问题频繁报错,很碍事。
所以业界常见做法是:不用数据库的物理外键,而是在应用层(代码逻辑)来保证关联的正确性 。表之间逻辑上依然有关系,只是这个关系由代码维护,而不是由数据库强制。这也是为什么逆向出来的 EER 图需要手动去补画关系线------Workbench 提供了一系列画关系的工具,你根据业务把"字典类型-字典条目是一对多""房源-标签是多对多"这些连线一条条补上,一张完整的系统关系图就出来了。
补完之后,你会得到一张能看清全部表结构和关系的 EER 大图。它的价值不在于给机器看,而在于给人看------开发过程中各小组之间沟通、新人快速理解系统、评审时讲设计,全靠这张图。
到这里,从需求分析、概念设计、逻辑设计、物理设计,到建库建表、逆向出图,一个完整的数据库设计闭环就跑完了。
九、底层原理深水区
前面把流程和案例讲完了,这一章我们扎进水里,把几个"为什么"从根上讲透。这部分是拉开差距的地方,也是面试最爱问的。
9.1 三大范式:它们到底在防什么
范式(Normal Form)是设计关系型数据库时的一套"规范等级",目的是减少数据冗余、避免各种"异常"。要理解范式,得先理解它在防的三种"异常",我用一个反例讲。
假设我们偷懒,把学生选课信息全塞进一张表:
| 学号 | 姓名 | 课程号 | 课程名 | 成绩 |
|---|---|---|---|---|
| 001 | 张三 | C1 | 数据库 | 90 |
| 001 | 张三 | C2 | 操作系统 | 85 |
| 002 | 李四 | C1 | 数据库 | 88 |
这张表会出三种事故:
-
插入异常:想新增一门课"计算机网络",但还没有任何学生选它,因为学号是必需的,这门课就压根插不进去。
-
删除异常:张三退掉了 C1 课,我把这行删了,结果"数据库"这门课的课程名信息也跟着没了(如果没有别人选它)。
-
更新异常:课程 C1 改名了,我得把所有选了 C1 的行全改一遍,漏改一行数据就不一致了。
范式的存在,就是为了消灭这些异常。三大范式层层递进:
第一范式(1NF):字段不可再分,保证原子性。 每个字段里只能存一个值,不能塞"逗号分隔的一堆值"。比如把张三选的所有课程塞进一个字段存成 "C1,C2,C3",就违反了 1NF。为什么原子性这么重要? 底层原因是:非原子的字段没法建索引 (索引是针对单个值的)、没法做有效的约束和条件查询 (你想查"选了 C2 的人",得去字符串里做模糊匹配,效率极低且易错)、没法参与关系连接。原子性是关系型数据库一切能力的地基。
第二范式(2NF):在 1NF 基础上,消除"非主属性对主键的部分依赖"。 这个主要针对联合主键(多个字段一起当主键)的情况。上面那张表如果主键是(学号,课程号),那么"姓名"只依赖"学号"、不依赖"课程号",这就是"部分依赖"------它只依赖了主键的一部分。部分依赖会导致姓名重复存储(张三选几门课,姓名就存几遍)。解决办法是拆分:把"姓名"这种只跟学号有关的,拆到学生表里。
第三范式(3NF):在 2NF 基础上,消除"非主属性对主键的传递依赖"。 传递依赖是 A→B→C 这种链条。比如"学号 → 系号 → 系主任",系主任其实是由系号决定的,不是直接由学号决定的,这就是传递依赖。它会导致系主任信息重复(一个系的学生有多少,系主任就存多少遍)。解决办法也是拆表,把"系号-系主任"单独成表。
一句话记忆:1NF 保证字段原子,2NF 消除部分依赖,3NF 消除传递依赖,核心目标都是"每一张表只描述一件事,每个信息只存一处"。
那范式能解决什么问题? 归纳起来就是:减少数据冗余(省空间、省维护成本)、避免插入/删除/更新异常、保证数据一致性。这也是"数据库设计能降低成本和风险"的理论支撑。
但是------范式不是越高越好。 前面 5.4 已经讲过,冗余的城市名是故意违反 3NF 的。规范化把数据拆到多张表,好处是不冗余,坏处是查询时要频繁 JOIN,而 JOIN 在大数据量下很慢。所以真实项目里的策略通常是:默认按 3NF 设计,保证数据干净;然后针对具体的、明确的性能瓶颈,有选择地做反范式(加冗余字段、加汇总表),用可控的冗余换查询速度。 这就是"理论"和"工程"的平衡艺术。
9.2 B+ 树与索引:为什么查询能从"翻遍全书"变成"查字典"
前面反复提到"自增主键好""JOIN 很贵""建索引要权衡",这些都指向同一个底层结构------索引 ,而 MySQL(InnoDB)索引的核心是 B+ 树。
索引解决什么问题? 没有索引时,查一条数据要全表扫描,就像找字典里一个字却从第一页一页页翻到最后。有了索引,就像查字典的目录,几步就能定位。数据量越大,这个差距越夸张:一百万行,全表扫描最坏要比一百万次,而 B+ 树索引可能只要比三四次。
为什么偏偏是 B+ 树,而不是红黑树、B 树或者哈希表? 这个选型背后是对"磁盘 IO"的深刻理解:
-
对比红黑树/二叉树 :二叉树每个节点只有两个孩子,一百万个数据树高会到 20 层左右。而数据库的数据在磁盘上,每下降一层就约等于一次磁盘 IO ,20 次 IO 太慢了。B+ 树是多叉的(一个节点能存很多 key、指向很多孩子),所以树特别"矮胖"------同样一百万数据,B+ 树通常只有 3~4 层,也就是说最多 3~4 次磁盘 IO 就能找到,快得多。
-
对比 B 树 :B 树的每个节点(包括中间节点)都存实际数据。而 B+ 树把所有实际数据都只存在叶子节点 ,中间节点只存 key 做索引。好处是中间节点更"轻",一个磁盘页能装下更多 key,树能更矮;而且 B+ 树的所有叶子节点用双向链表连了起来,这让"范围查询"(比如查价格 1000~2000 的房源)变得极快------定位到起点后,顺着链表往后扫就行,不用回到树顶重新找。B 树做范围查询就很痛苦。
-
对比哈希表 :哈希查单个值确实快(O(1)),但它完全不支持范围查询和排序(哈希把数据打散了,顺序信息丢了)。数据库大量场景要排序、要范围查,所以哈希索引只能当辅助。
聚簇索引 vs 二级索引,以及"回表"。 在 InnoDB 里,主键索引叫聚簇索引 ,它的叶子节点直接存的是整行数据 ------也就是说,表数据本身就是按主键组织成一棵 B+ 树。而我们额外建的普通索引叫二级索引 (辅助索引),它的叶子节点存的不是整行,而是主键值 。所以用二级索引查询时,是"先在二级索引树里找到主键值,再拿这个主键值去聚簇索引树里找整行数据",这第二次查找就叫回表 。回表是有额外成本的,这也解释了为什么"覆盖索引"(要查的字段刚好都在索引里,不用回表)能提速,以及为什么 select * 不推荐(它逼着数据库回表取所有列)。
现在回头看"自增主键为什么好"就通透了 :聚簇索引是按主键排序的 B+ 树,自增 id 保证新数据永远追加到树的最右端,是顺序写,页不会被频繁撑破;随机 UUID 会让新数据到处乱插,触发大量页分裂(一个满页要拆成两页腾地方),既慢又产生碎片。
索引是不是越多越好? 不是。索引本质是一份额外的、需要维护的 B+ 树。它能加速查询(读),但会拖慢增删改(写) ------因为你每次改数据,所有相关的索引树都得跟着调整。而且索引占磁盘空间。所以索引设计是一门权衡的艺术:给经常出现在 where、order by、join 条件上的字段建索引,但要克制,别无脑给每个字段都加。
9.3 数据完整性三兄弟
数据完整性指的是数据的准确性和一致性,它由三种完整性守护,恰好对应我们前面见过的设计:
实体完整性 :要求每张表都有主键,用来唯一标识每一行记录。这就是为什么公共字段里 id 是雷打不动的主键------它保证了"每一行都是可区分的独立个体"。主键不能重复、不能为空,这是数据库强制的。
参照完整性 :保证表和表之间的关系一致,靠的是外键约束。它的核心思想是:从表里的外键值,必须是主表里真实存在的主键值,不能指向一个不存在的东西(否则就是"孤儿数据")。有意思的是,前面第八章讲过,很多项目不用数据库的物理外键 (为了性能和分库分表),但这不代表不要参照完整性------它只是把这个责任从数据库挪到了应用层代码里,由程序来保证关联正确。完整性要求还在,只是执行者变了。
域完整性 :限制每一列的取值范围,保证数据有效。我们前面用的一堆手段都在做这件事------not null(非空约束)、default(默认约束)、字段类型(比如 tinyint 限定范围)、varchar(64) 限定长度,甚至唯一约束(unique)。它们共同保证"这一列只能存符合规则的合法值"。
这三兄弟加上前面范式讲的减少冗余,就构成了"数据库设计如何保证数据完整性和安全性"这个面试题的完整答案。安全性方面还可以补充:给不同用户授予不同的读写权限(访问控制)、敏感字段加密存储(比如密码加盐哈希就是一种)、开启审计日志追踪操作。
9.4 性能优化:设计阶段就要埋好的伏笔
数据库性能优化不是等系统卡了才去做的事,好的设计从一开始就为性能埋好伏笔。几个核心方向:
索引设计:如前所述,用索引换查询速度,但要权衡写入成本和空间。选对索引字段、控制索引数量、善用覆盖索引。
查询优化 :写高效的 SQL,核心是避免全表扫描 。具体手段包括:where 条件用上索引字段;避免在索引字段上做函数运算或类型转换(会导致索引失效);少用 select *(减少回表和网络传输);合理使用连接和子查询(很多场景 JOIN 比嵌套子查询快)。
存储结构选择:底层的 B+ 树、哈希等结构会影响存取效率,选 InnoDB 就是在选一套成熟的、基于 B+ 树聚簇索引的存储方案。
数据分布 :当单库单表扛不住海量数据时,就要考虑更宏观的策略------读写分离 (写操作走主库,读操作走从库,分担压力)和数据分片/分库分表(把大表按规则拆成多个小表分散到多个库)。这也是为什么前面说"不建物理外键"------因为外键会成为分库分表的路障,好的设计要提前为将来的扩展留余地。这正呼应了"数据库设计要考虑系统扩展性"这个面试题:随着业务发展,能通过加表、加字段、拆库来平滑扩展,而不用推倒重构。
十、收尾:如果面试官问我这些
写完这一大篇,我把开头那几道经典面试题用自己的话答一遍,也算自我检验。
数据库设计的主要步骤? 需求分析(搞清存什么、支持什么业务,产出需求文档)→ 概念设计(画 E-R 图,定实体、属性、关系)→ 逻辑设计(把图变成表结构,定字段类型、命名、关系)→ 物理设计(定存储、索引、分区)。
范式是什么,解决什么问题? 范式是减少冗余、避免异常的设计规范。1NF 保证字段原子,2NF 消除对联合主键的部分依赖,3NF 消除传递依赖。它们解决的是数据冗余和插入/删除/更新异常,让每个信息只存一处、保证一致性。但工程上会根据性能需要适当反范式。
一对一、一对多、多对多怎么实现? 一对一:合并成一张表,或拆两张表让从表主键兼外键。一对多:在"多"的从表里加外键指向"一"的主表。多对多:单独建一张关系表,存两边的主键。
怎么用 E-R 图做设计? 先从需求提取实体和属性,判断实体间的关系基数,画出 E-R 图作为沟通和评审的工具,再据此转成逻辑表结构,最后可以用 Workbench 逆向导出 EER 图来验证和展示关系。
怎么考虑扩展性? 用字典表把易变的业务选项从代码里解耦出来;表结构预留合理的公共字段和余量;不建物理外键以便将来分库分表;读多写少场景用可控冗余换性能,为读写分离和分片留空间。
回头看这一整趟,我最大的收获是那句话:数据库设计的本质,是在"规范化带来的干净"和"反范式带来的性能"之间,在一个个具体字段、具体关系上做权衡。 没有唯一正确答案,只有"知其所以然"之后的合理取舍。
下次再有人问你"不就是个建表吗",你就可以笑着跟他从西红柿炒鸡蛋,一路聊到 B+ 树的第几层了。