去年夏天,我们团队接手了一个高校在线选课系统的开发项目。作为负责数据库设计的工程师,我本以为凭借教科书上的ER图转换规则就能轻松完成任务,没想到从第一版ER图到最终可执行的DDL语句,整整经历了三次推翻重来。本文将用真实项目中的踩坑经历,还原一个中小型系统数据库设计的完整决策过程,特别适合那些已经掌握数据库基础理论,但缺乏实战经验的中级开发者。
客户最初提供的需求文档只有三页纸,核心功能包括:学生选课、教师开课、成绩录入。我们花了三天时间与教务处老师深入沟通,才发现隐藏的业务规则:
第一版ER图的主要问题:
mermaid复制erDiagram
STUDENT ||--o{ COURSE : selects
TEACHER ||--o{ COURSE : teaches
COURSE ||--o{ PREREQ : requires
这个简陋的ER图至少存在三个严重缺陷:
关键教训:需求分析阶段一定要找出所有业务实体和它们之间的基数关系。我们后来采用的方法是让客户描述5个典型用户操作流程,从中提取实体和关系。
第二版ER图完善了实体和关系,但在转换为关系模式时又遇到新问题:
最初的"学生-课程"多对多关系直接转换为:
sql复制CREATE TABLE student_course (
student_id INT,
course_id INT,
PRIMARY KEY (student_id, course_id)
);
这导致无法记录选课时间、成绩等关联属性。正确做法是:
sql复制CREATE TABLE enrollment (
student_id INT,
section_id INT,
enroll_date TIMESTAMP,
grade VARCHAR(2),
PRIMARY KEY (student_id, section_id),
FOREIGN KEY (student_id) REFERENCES student(id),
FOREIGN KEY (section_id) REFERENCES section(id)
);
最初的院系(department)-教师(teacher)设计形成了循环引用:
sql复制CREATE TABLE department (
id INT PRIMARY KEY,
chair_id INT REFERENCES teacher(id)
);
CREATE TABLE teacher (
id INT PRIMARY KEY,
dept_id INT REFERENCES department(id)
);
解决方案是允许chair_id为NULL,先插入院系再更新:
sql复制ALTER TABLE department ALTER COLUMN chair_id DROP NOT NULL;
教学班(section)作为弱实体集,最初错误地只使用自增ID作为主键:
sql复制-- 错误示例
CREATE TABLE section (
id INT PRIMARY KEY,
course_id INT REFERENCES course(id)
);
正确的主键应包含依赖实体course的主键:
sql复制CREATE TABLE section (
course_id INT,
seq_id INT,
semester VARCHAR(10),
year INT,
PRIMARY KEY (course_id, seq_id, semester, year),
FOREIGN KEY (course_id) REFERENCES course(id)
);
经过两次迭代后,我们针对查询性能做了以下优化:
在课程搜索高频场景中,原本需要多表连接:
sql复制-- 原始方案
SELECT c.* FROM course c
JOIN department d ON c.dept_id = d.id
WHERE d.name = '计算机学院';
通过增加冗余字段优化为:
sql复制ALTER TABLE course ADD COLUMN dept_name VARCHAR(50);
-- 优化后查询
SELECT * FROM course WHERE dept_name = '计算机学院';
sql复制-- 高频查询字段索引
CREATE INDEX idx_course_dept ON course(dept_id);
CREATE INDEX idx_section_time ON section(semester, year);
-- 组合索引优化
CREATE INDEX idx_enrollment_student ON enrollment(student_id, enroll_date);
sql复制CREATE TABLE student (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
dept_id INT REFERENCES department(id),
enrollment_date DATE
);
CREATE TABLE course (
id INT PRIMARY KEY,
title VARCHAR(100) NOT NULL,
dept_id INT REFERENCES department(id),
credits SMALLINT CHECK (credits > 0),
dept_name VARCHAR(50) -- 反范式化字段
);
CREATE TABLE section (
course_id INT REFERENCES course(id),
seq_id INT,
semester VARCHAR(10),
year INT,
teacher_id INT REFERENCES teacher(id),
classroom VARCHAR(20),
schedule JSONB,
PRIMARY KEY (course_id, seq_id, semester, year)
);
CREATE TABLE enrollment (
student_id INT REFERENCES student(id),
section_id INT,
course_id INT,
semester VARCHAR(10),
year INT,
enroll_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
grade VARCHAR(2),
PRIMARY KEY (student_id, section_id, course_id, semester, year),
FOREIGN KEY (section_id, course_id, semester, year)
REFERENCES section(seq_id, course_id, semester, year)
);
在实际运行半年后,我们通过慢查询日志发现两个需要改进的地方:
几个特别实用的检查项:
最后分享一个实用脚本,用于生成数据库文档:
python复制# 生成ER图文档的工具脚本
import sqlparse
from eralchemy import render_er
def generate_er_diagram(ddl_file, output_file):
with open(ddl_file) as f:
ddl = sqlparse.format(f.read(), strip_comments=True)
render_er(ddl, output_file)
generate_er_diagram('schema.sql', 'er_diagram.pdf')