
从需求到建成表我用飞算JavaAI设计数据库踩了这6个坑「选课人数」这个字段我在 course 表上加了三次又删了三次。一、四句话的需求我改了十一次表接着前面几篇那个在线教育项目往下做。课程管理后台那部分课程、分类、审核日志上一轮已经建好这次轮到学员侧需求文档里一共四句话学员可以选课一门课只能选一次选完可以按顺序学也能跳着学系统要记录每个学员每个章节学到哪、看了多久、看完没有课程详情页要显示「已有多少人选了这门课」四句话。我第一版建了四张表第三天开始改前前后后改了十一次。复盘的时候我意识到一个事问题不在 SQL 写得烂在于从需求文档到表结构这一步我跳得太快。中间少了几次翻译而缺掉的每一次翻译后面都得靠改表来还。PRD 确认完之后我照例把它丢给/前后端设计让它先出一版数据库文档设计这个流程上一篇讲过不重复。它给的初稿字段挺规整注释也写得全但有几处我看一眼就知道上线会出事。这篇文章讲的就是这几处以及我自己踩过的六个坑。二、从需求文档到建表中间有七次翻译现在我建表不直接写CREATE TABLE先在本子上过七步步骤要回答的问题这步偷懒的后果1. 找实体这段话里有几个名词是「东西」两张实体混成一张表2. 定关系一对一、一对多还是多对多该拆的中间表没拆3. 定基数一次关联最多能有几条唯一约束想错方向4. 定字段有哪些属性哪些是状态状态散成一堆布尔值5. 定类型每个字段什么类型和长度金额算错、时间对不上6. 定生命周期记录会不会被删删了怎么算软删除没做数据找不回7. 定查询怎么查、多久查一次、会有多大索引建错慢 SQL前三步最容易跳过代价也最大。我第一版就栽在第 2 步学员和课程明明是多对多我在心里却当成一对多处理先想往course塞一个student_count又想过塞一个student_ids用逗号隔开。这两个想法都是典型的该拆中间表没拆。正确的拆法是三张实体加两张关系学员复用已有的用户表不新建course课程上一篇已建这次一个字段都不加course_chapter章节课程到章节是 1:Ncourse_selection选课记录学员到课程是 N:M这张是中间表study_record学习记录选课记录到章节是 1:N第 3 步定基数特别关键。「一门课只能选一次」这句话的终点不是注释是course_selection上的唯一索引uk_student_course (student_id, course_id)。这句话如果没被翻译成数据库约束它就会退化成一句「前端会控制」的口头承诺然后在某个并发请求的下午变成两条重复记录。三、字段类型的取舍这几处我全吃过亏类型选择这件事看着琐碎但它决定了后面两年你会不会半夜被告警叫醒。我把自己改过的地方整理成表字段场景我以前的写法我现在的写法原因金额DOUBLEDECIMAL(12,2)DOUBLE 有精度损失累加到一定量级会差出几分钱对账对不上状态VARCHAR(20)TINYINT Java 枚举字符串占空间、比较慢改一次取值要刷历史数据时间DATETIME / TIMESTAMP / VARCHAR 混用统一DATETIME(3)TIMESTAMP 有 2038 问题VARCHAR 排序结果不对主键INTBIGINTINT 到两千多万就爆流水表撑不过一个促销季布尔INT/CHAR(1)TINYINT(1)语义清晰别让is_free能存 99大文本直接放主表TEXT拆到副表列表页回表代价高扩展属性无脑JSONJSON慎用没法建有效索引查询只能全表扫DECIMAL(12,2)这个精度也是被教育出来的。一开始我按通用经验写的DECIMAL(10,2)后来业务方说企业内训课单价能到六位数一个字段改过去连带改了 DTO、校验注解和前端的格式化逻辑。从那以后我定金额精度第一件事是问最大值不是抄模板。四、六个坑我一个都没躲过4.1 坑一把统计值堆在主表「课程详情页要显示已有多少人选了这门课」——我的第一反应是给course加一列student_count选课成功就1。前三周没事。第四周开始出现两个问题一是运营批量导入了三千条选课记录course表上几千行被反复更新慢查询日志里全是它二是这个数跟course_selection里的实际条数对不上差了七条没人知道这七条是怎么丢的。我现在的做法是主表不堆统计值单独建一张统计表并且明确写进设计文档统计表允许短暂不一致靠定时任务或者异步消息刷新业务上不要求实时精确。-- 统计表的刷新走定时任务不跟主流程抢锁UPDATEcourse_learn_stat sJOIN(SELECTcourse_id,COUNT(*)ASselection_cnt,SUM(CASEWHENstatus2THEN1ELSE0END)ASfinish_cntFROMcourse_selectionGROUPBYcourse_id)tONt.course_ids.course_idSETs.selection_cntt.selection_cnt,s.finish_cntt.finish_cnt;这不是说反范式一定有罪。我在study_record里就故意冗余了student_id和course_id理由很具体这张表一年几千万行按student_id查询是最热的一条路径每次都去 joincourse_selection扛不住而且将来要分片student_id是天然的分片键。冗余可以但每一列冗余都得写清楚「为什么」和「谁来保证一致」否则三个月后没人敢动它。4.2 坑二软删除只加了一半我以前的软删除就是加一个is_deleted TINYINT DEFAULT 0加完就以为完事了。直到有一天运营说「有个学员退课了现在又想学重新选提示已选过」。原因很直白uk_student_course唯一索引还在is_deleted 1的那条记录照样占着坑。这是我第一次意识到软删除和唯一索引是一对天生的矛盾必须一起设计。我现在的规则是分三类处理业务上「可撤销、可重做」的选课、收藏、关注不做删除用状态字段表达退课就是status 3 已退课重选时把状态改回来业务上「删了就是删了」的章节、课程软删除deleted_at记时间并且明确哪些查询要过滤、哪些报表要包含已删除数据流水型数据学习记录、日志不删只归档选课表最终一个删除字段都没有。看起来的「退课」其实是状态变更唯一索引顺理成章地保住了「一人一课一次有效记录」这个约束。4.3 坑三时间字段三种类型混在一张库里这是历史项目的通病也传染到了我的新表。同一个库里create_time是DATETIMEupdate_time是TIMESTAMP还有个pay_time被前人写成了VARCHAR(20)。后果是排序按字符串排2026-9-1排在2026-10-1后面。我现在的统一约定只有三条所有时间列一律DATETIME(3)带毫秒create_time默认CURRENT_TIMESTAMP(3)update_time必须带ON UPDATE CURRENT_TIMESTAMP(3)业务时间选课时间、完成时间和审计时间分开存不要用update_time冒充业务时间带毫秒这一条是被幂等逻辑逼出来的。前端在弱网下会把同一个请求发两次两次间隔可能只有几十毫秒秒级时间戳判重直接判成两条。4.4 坑四「前端会控制」不算控制选课按钮前端确实置灰了。但置灰挡不住三件事用户双击、网络重试、还有人在 Postman 里直接调接口。真正兜底的只有数据库约束。所以我给course_selection加了唯一索引写入用ON DUPLICATE KEY UPDATE兜住重复请求。这三行 SQL 的价值比我在前端加的十个判断都大。还有个反过来的坑唯一索引建了但没有想清楚重复了应该怎么办。是幂等返回成功还是报「已选过」我的处理是——如果是同一个人重复提交返回成功幂等如果状态是「已退课」这次提交视为重新选课把状态改回来。这个分支必须在写 SQL 之前想清楚不然唯一索引会变成一个新的 bug 来源。4.5 坑五状态字段到处开花第一版我在course_selection里放了三个布尔is_deleted、is_finished、is_valid。写完我就发现这三个值能组合出八种状态其中五种是不可能出现的。状态字段的正确做法是一个字段表达一个状态机的当前节点取值用枚举收敛并且在代码里用枚举把合法流转写死这个写法前面那篇讲状态机的时候贴过。表里存TINYINTJava 里用枚举两边靠注释和常量对上。顺带一提状态的取值我习惯从 1 开始而不是从 0 开始。因为很多 ORM 和手写 SQL 里0太容易被当成「没设置」一个字段的默认值到底是有意设的还是没设日后排查时根本分不清。4.6 坑六大字段和高频查询挤在一张表course_chapter第一版我把章节正文content TEXT直接放在表里。列表页只查标题和时长但因为SELECT *加上 InnoDB 的行存储TEXT 字段让每个数据页能放的行数变少列表查询的 IO 明显变高。改法很简单正文拆到course_chapter_content主表只留列表和播放需要的字段。这条规则的通用形式是把「列表要什么」和「详情要什么」分开两张表各走各的查询路径。学习记录这张表还有个额外的问题它是全库写压力最大的表。我一开始的设计是「每次播放心跳插一条流水」算了一下一万个学员每天看半小时课心跳 15 秒一次一天就是七百多万行。后来改成一个学员一个章节只留一条累计记录播放心跳先进 Redis攒够了或者页面关闭时再落库。真要留明细那就单独走一张按月分区的流水表别和业务表混。五、改完之后一份能直接跑的建表 SQL这是最终版本MySQL 8.0 InnoDB utf8mb4。course 表承接上一篇这次一个字段都没动。-- -- 选课与学习记录域 建表脚本-- -- 课程章节表course 1 : N chapterCREATETABLEcourse_chapter(idBIGINTNOTNULLAUTO_INCREMENTCOMMENT主键,course_idBIGINTNOTNULLCOMMENT所属课程ID,titleVARCHAR(200)NOTNULLCOMMENT章节标题,sort_noINTNOTNULLDEFAULT0COMMENT章节序号同一课程内唯一,video_urlVARCHAR(500)DEFAULTNULLCOMMENT视频播放地址,duration_secINTNOTNULLDEFAULT0COMMENT视频时长秒,is_freeTINYINTNOTNULLDEFAULT0COMMENT是否免费试看0否 1是,statusTINYINTNOTNULLDEFAULT1COMMENT状态0下架 1上架,create_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)COMMENT创建时间,update_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)ONUPDATECURRENT_TIMESTAMP(3)COMMENT更新时间,deleted_atDATETIME(3)DEFAULTNULLCOMMENT软删除时间NULL 表示未删除,PRIMARYKEY(id),UNIQUEKEYuk_course_sort(course_id,sort_no),KEYidx_course_status(course_id,status,sort_no))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_general_ciCOMMENT课程章节表;-- 章节正文表大字段单独拆出避免拖慢章节列表查询CREATETABLEcourse_chapter_content(chapter_idBIGINTNOTNULLCOMMENT章节ID与 course_chapter.id 一一对应,contentTEXTCOMMENT章节正文富文本,update_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)ONUPDATECURRENT_TIMESTAMP(3)COMMENT更新时间,PRIMARYKEY(chapter_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_general_ciCOMMENT章节正文表;-- 学员选课表student 与 course 的多对多中间表-- 注意本表不做物理删除也不做软删除退课用 status 表达CREATETABLEcourse_selection(idBIGINTNOTNULLAUTO_INCREMENTCOMMENT主键,student_idBIGINTNOTNULLCOMMENT学员ID,course_idBIGINTNOTNULLCOMMENT课程ID,order_idBIGINTDEFAULTNULLCOMMENT关联订单ID免费课程为空,sourceTINYINTNOTNULLDEFAULT1COMMENT来源1主动选课 2后台导入 3赠送,statusTINYINTNOTNULLDEFAULT1COMMENT状态1学习中 2已完成 3已退课,progressSMALLINTNOTNULLDEFAULT0COMMENT学习进度百分比 0-100异步刷新,enroll_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)COMMENT首次选课时间,finish_timeDATETIME(3)DEFAULTNULLCOMMENT完成时间,last_chapter_idBIGINTDEFAULTNULLCOMMENT最近学习的章节ID,last_study_timeDATETIME(3)DEFAULTNULLCOMMENT最近学习时间,create_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)COMMENT创建时间,update_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)ONUPDATECURRENT_TIMESTAMP(3)COMMENT更新时间,PRIMARYKEY(id),UNIQUEKEYuk_student_course(student_id,course_id),KEYidx_course_status(course_id,status),KEYidx_student_last(student_id,last_study_time))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_general_ciCOMMENT学员选课表;-- 章节学习记录表一个学员一个章节只保留一条累计记录-- student_id / course_id 为有意冗余用于避免回表及后续分片CREATETABLEstudy_record(idBIGINTNOTNULLAUTO_INCREMENTCOMMENT主键,selection_idBIGINTNOTNULLCOMMENT选课记录ID,student_idBIGINTNOTNULLCOMMENT学员ID冗余,course_idBIGINTNOTNULLCOMMENT课程ID冗余,chapter_idBIGINTNOTNULLCOMMENT章节ID,watch_secINTNOTNULLDEFAULT0COMMENT累计观看秒数,total_secINTNOTNULLDEFAULT0COMMENT视频总时长快照避免回表,max_positionINTNOTNULLDEFAULT0COMMENT最大播放位置秒,finishedTINYINTNOTNULLDEFAULT0COMMENT是否学完0否 1是,first_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)COMMENT首次学习时间,last_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)ONUPDATECURRENT_TIMESTAMP(3)COMMENT最近学习时间,PRIMARYKEY(id),UNIQUEKEYuk_selection_chapter(selection_id,chapter_id),KEYidx_student_last(student_id,last_time))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_general_ciCOMMENT章节学习记录表;-- 课程学习统计表主表不堆统计字段这里允许短暂不一致CREATETABLEcourse_learn_stat(course_idBIGINTNOTNULLCOMMENT课程ID,selection_cntINTNOTNULLDEFAULT0COMMENT累计选课人数,finish_cntINTNOTNULLDEFAULT0COMMENT完成学习人数,update_timeDATETIME(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3)ONUPDATECURRENT_TIMESTAMP(3)COMMENT统计刷新时间,PRIMARYKEY(course_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COLLATEutf8mb4_general_ciCOMMENT课程学习统计表;配套的写入语句我也一起定死把并发和乱序这两类问题挡在 SQL 层-- 选课并发重复提交由唯一索引兜底已退课的视为重新选课INSERTINTOcourse_selection(student_id,course_id,source,status,enroll_time)VALUES(10001,88,1,1,NOW(3))ONDUPLICATEKEYUPDATEstatusIF(status3,1,status),enroll_timeIF(status3,NOW(3),enroll_time),update_timeNOW(3);-- 学习进度上报只往前推进防止客户端乱序上报把进度打回去INSERTINTOstudy_record(selection_id,student_id,course_id,chapter_id,watch_sec,total_sec,max_position,finished)VALUES(5566,10001,88,321,15,900,485,0)ONDUPLICATEKEYUPDATEwatch_secwatch_secVALUES(watch_sec),max_positionGREATEST(max_position,VALUES(max_position)),finishedIF(GREATEST(max_position,VALUES(max_position))*100/VALUES(total_sec)90,1,finished),last_timeNOW(3);第二条里的GREATEST是必须的。客户端会乱序上报用户在网络差的时候拖回进度条再拖回来如果直接覆盖写入进度会莫名其妙倒退。IF(... 90, 1, ...)这个 90% 算学完的规则来自需求文档这种数字千万别散落在各个 Service 里要么写成常量要么进配置项。六、AI 生成的表结构这八处我一定人工复核/前后端设计给的数据库文档设计结构上是合格的字段命名和注释都比我手写的规整。但下面这八处它给不了正确答案我每次都自己过一遍复核项初稿常见的样子我怎么改为什么必须自己看业务唯一约束只有主键关联列上给普通索引补UNIQUE KEY并发下的重复数据只有数据库能拦删除策略默认给is_deleted按「可撤销 / 真删除 / 只归档」分类删了还能不能重做是业务规则时间精度DATETIME无精度update_time有时不自动更新统一DATETIME(3)并补ON UPDATE幂等判重和排序依赖精度金额精度DECIMAL(10,2)居多按业务单价上限调精度跟你们的单价量级强相关冗余字段统计值直接挂主表拆统计表或写明刷新机制主表被高频更新会成热点联合索引顺序单列索引一堆顺序随意按「等值列在前排序列在后」重排最左前缀顺序错了等于白建大字段TEXT常和主表混在一起拆副表影响列表页的 IO数据量级基本不考虑预估行数定归档 / 分区策略几千万行的表和几万行不是一回事这八条里我最固执的是第一条。AI 不知道你们系统里有没有人绕开前端直接调接口也不知道运营每个月会不会批量导数据它按「正常流程」设计出来的表在异常流程面前是裸奔的。复核完之后还有一步不能省把改完的结论回喂给后续环节。我会在会话里把最终确认的表结构和规则贴回去让后面的接口设计和代码生成基于这一版而不是基于初稿。你只在脑子里改了AI 不知道。七、SQL 写完我不急着写接口建完表我会花十分钟做三件事做完才动接口。第一跑一遍索引验证。把最核心的三条查询写出来直接EXPLAIN看有没有走索引、有没有filesort。我一般会在这时候发现联合索引顺序建反了。第二估算数据量和写入压力。学习记录这种表我会在文档里写清楚预计一年多少行、按什么键分片、什么时候归档。这个数字不用精确到个位但必须有一个量级否则上线半年后第一次慢查询来得毫无征兆。第三写巡检 SQL。上线前后跑一遍确认没有已经产生的脏数据。-- 巡检一检查是否已有重复选课上线前必须为空SELECTstudent_id,course_id,COUNT(*)AScntFROMcourse_selectionGROUPBYstudent_id,course_idHAVINGcnt1;-- 巡检二检查进度与学习记录是否明显对不上允许短暂延迟差值过大说明刷新任务挂了SELECTs.id,s.progress,COUNT(r.id)ASchapter_cntFROMcourse_selection sLEFTJOINstudy_record rONr.selection_ids.idANDr.finished1WHEREs.status2GROUPBYs.id,s.progressHAVINGs.progress100ANDchapter_cnt0;第二条巡检救过我一次。那次是异步刷新任务的消息队列积压了进度一直是 100但一个章节都没标记完成。如果没这条巡检这个问题会等到用户投诉「我学完了为什么没证书」才被发现。八、写在最后做了五年后端我对建表这件事的态度变过三次。刚入行觉得建表是最简单的活有手就行中间被改表改怕了觉得建表是架构师的活得小心现在我觉得它是需求文档的第一行代码——你在这张表里做的每一个决定都是对业务规则的一次表态改表本质上是在改需求。AI 在这件事上帮我的是把「字段该叫什么、注释该怎么写、类型大概用什么」这种体力活做掉让我有时间去想「这个约束该不该建、这条记录删了还能不能回来、这张表一年会涨到多少行」。前者它比我做得规范后者它一点忙都帮不上。说句扎心的实话大部分上线后被改了十几次的表不是因为设计者不懂三范式是因为他在建表的时候从来没问过运营一句「这个数据如果录错了你们是怎么补救的」。下一篇我讲接口为什么前后端联调会互相甩锅以及一份能定死责任的接口契约长什么样。