一次查询没带所属范围,列表里出现了别人的标题;另一次改动要换主键、套用手册的时间列名。
要加表或索引,先看字段、类型与索引的约定;发版前用迁移文件模板里的 make migrate-roundtrip 确认能回滚。
把请求体里的 scope_id 换成另一个值重发一次,结果会不会变?
答「会」,说明所属范围来自请求而不是会话,这是本层最严重的一类问题。要改的是查询的绑定来源,不是再加一个判断。
打开建表语句能看到列名、类型、主键、索引和外键;看不到的是查询时 scope_id 从会话来还是从请求来。所以约定分两半:表结构这一半写进迁移脚本,信任边界那一半写进决议和仓库说明。
| 对象 | 约定 | 判据 | 违反后果 |
|---|---|---|---|
| 表名 | 小写、单数、不用保留字 | 建表语句里不出现 desc、range、match 这类词作表名 | 每条查询都要加反引号,助手常忘 |
| 字段名 | 小写加下划线;时间列跟随同库已有脚本 | 一个库里只有一套时间列名 | 同库并存 created_at 与 gmt_create,读代码要每次确认 |
| 主键 | BIGINT 自增,不改成 UUID | SHOW CREATE TABLE 一遍 | 改主键要同时改所有外键与代码里的类型 |
| 小数 | DECIMAL,不用 FLOAT 与 DOUBLE | 建表语句里搜不到 float、double | 金额累加出现分位误差,且只在数据多了以后显形 |
| 长文本 | 按长度选类型。MySQL 的 TEXT 约 64 KiB,上传上限到数 MiB 时用 MEDIUMTEXT | 字段类型与上传上限对照 | 超长内容被截断,且不报错 |
| 字符集 | 库与表用 utf8mb4 | SHOW CREATE TABLE 输出里是 utf8mb4 | 四字节字符插入失败,用户看到的是一条 500 |
| 索引名 | 前缀区分:pk_、uk_、idx_ | 见建表语句 | 同名索引互相覆盖,加索引时静默失败 |
| 唯一性 | 业务上必须唯一的组合建唯一索引 | 例如同一 scope_id 下的外部编号 | 只在应用里先查再插入挡不住并发,出现重复行 |
| 关联列 | 两侧类型必须一致 | 对照外键两侧的列定义 | 索引失效,关联查询全表扫描 |
字符集与引擎写在建表语句里。标题长度用 VARCHAR;限制长度时用 CHAR_LENGTH,LENGTH 按字节算,中文与 emoji 会算多。
CREATE TABLE notes (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
scope_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
file_path VARCHAR(512) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_notes_scope_created (scope_id, created_at),
CONSTRAINT fk_notes_scope FOREIGN KEY (scope_id) REFERENCES scopes(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
与手册不同的一处:外键这条与阿里巴巴手册相反。手册禁止外键是因为高并发分库下级联更新会阻塞;本服务的写入在同一个数据库,scope_id 必须指向存在的范围行,所以要保留外键,并在设计说明里写明与手册不同。分库分表不在本学期的范围内。
迁移规范只有三句话:编号从 0001 起连续、每个编号成对出现 up 与 down、down 必须是真的能执行的反向操作。三句话都有确定判据,所以把它们写成了脚本。
文件名 NNNN_动词_对象,一次提交这一对。第一行注释写这次改动做什么,方便 migrate 日志里对上号。
migrations/0007_add_notes_scope_index.up.sql migrations/0007_add_notes_scope_index.down.sql
-- 0007 列表按所属范围倒序读取,补组合索引。
-- 等值条件 scope_id 放左边,排序用的 created_at 放右边。
ALTER TABLE notes
ADD KEY idx_notes_scope_created (scope_id, created_at);
把 down 写成空文件是最常见的违规。不能反向执行的改动(例如删列后再恢复会丢数据),在发布说明里写清停在哪一步,见发布。
-- 0007 回滚:删掉本编号新增的索引。
ALTER TABLE notes
DROP KEY idx_notes_scope_created;
三条检查一次跑完:文件名格式、编号连续、up 与 down 成对。down 是否真的能执行,脚本查不出来,靠下面 migrate-roundtrip——两个都要跑。
#!/bin/sh
# 迁移文件规范检查:文件名、编号连续性、up/down 成对。
dir=migrations
status=0
for f in "$dir"/*.sql; do
name=$(basename "$f")
echo "$name" | grep -Eq '^[0-9]{4}_[a-z0-9_]+\.(up|down)\.sql$' \
|| { echo "文件名不合规: $name"; status=1; }
done
expect=1
for f in "$dir"/*.up.sql; do
num=$(basename "$f" | cut -c1-4 | sed 's/^0*//')
[ -z "$num" ] && num=0
if [ "$num" -ne "$expect" ]; then
echo "编号不连续: 期待 $(printf '%04d' "$expect"),实际 $(basename "$f")"
status=1
fi
expect=$((expect + 1))
[ -f "$dir/$(basename "$f" .up.sql).down.sql" ] \
|| { echo "缺少 down: $(basename "$f")"; status=1; }
done
exit $status
migrate-roundtrip 才是真正证明 down 能跑的那一步:上、全部下、再上。它需要一个空的测试库,用环境配置里的 TEST_MYSQL_DSN;变量未设置时它会跳过。make check 的聚合入口只在质量门禁里定义一次,这一页只加子目标。
.PHONY: migrations migrate-roundtrip migrations-check migrations: sh scripts/check-migrations.sh migrate-roundtrip: migrate -path migrations -database "$(TEST_MYSQL_DSN)" up migrate -path migrations -database "$(TEST_MYSQL_DSN)" down -all migrate -path migrations -database "$(TEST_MYSQL_DSN)" up # 挂进 check:check: fmt-check lint type migrations-check secret complexity test migrations-check: migrations ruff check --output-format=concise .
别把这条命令指向真库:down -all 会清空数据库,只能对测试库跑。命令里用变量而不是写死连接串,就是防止有人把生产连接串贴进去。migrate 的参数以官方文档为准。
建表语句的缩进和大小写已经交给脚本,评审时不再看。要看的是三件事:索引能不能被真实查询用上、迁移能不能反向、信任边界有没有被绕过。
grep -rnE '(FROM|JOIN)[[:space:]]+notes' internal/。索引只能为这些查询服务,凭空加的索引只是写入时的负担。EXPLAIN,看 key 列跑 EXPLAIN SELECT id, title FROM notes WHERE scope_id = 1 ORDER BY created_at DESC LIMIT 20;。key 为 NULL 说明没走索引;出现 Using filesort 说明排序列不在索引里。(scope_id, created_at);反过来则两个都用不上。make migrate-roundtrip。只跑 up 通过的迁移不算通过。| 现象 | 原因 | 修法 |
|---|---|---|
EXPLAIN 的 key 是 NULL | 没有可用索引,或列序与查询对不上 | 加组合索引,等值列在前 |
出现 Using filesort | 排序列不在索引末尾 | 把排序列加进索引;列表页同时给出稳定的次序字段 |
迁移 up 能过、down 报错 | down 是空的,或写的不是反向操作 | 补 down 并跑 make migrate-roundtrip |
改动差异里出现 scope_id = req.body.xxx | 信任边界被绕过 | 挡回。改绑定来源,不是加判断 |
| 一次改动里改了主键类型或删了已有外键 | 把设计说明和实现放在同一次改动里 | 拆开:先改设计说明并评审,再改实现 |
为什么小数据量也要跑:第 2 步的 EXPLAIN 在课程项目的几十行数据上看不出快慢差别,它回答的是索引能否被用上。数据少不能当作「索引对不对不重要」的理由——第一学期数据量小,第二学期同一个库可能就有上万行。
rows, err := db.Query(
"SELECT id, title FROM notes WHERE id = ?", id)
↑ 少了 scope_id
认它的办法:把请求里的 scope_id 换成另一个值重发一次,结果会不会变。上面这句换什么值结果都一样——它查的是全表,谁都能查到谁的记录。这条在测试里照样通过,因为测试造的数据通常只落在一个范围内,测不出「越范围能不能看到」。
grep -rn 'scope_id' internal/ 的每一处绑定值都来自会话对象,且 SQL 用占位符不用字符串拼接。违反后果:换一个请求参数就能读到别人的数据。sh scripts/check-migrations.sh 退出码 0,且 make migrate-roundtrip 三段都不报错。违反后果:发布出问题只能手工改库,回滚步骤写不出来。utf8mb4、时间列名跟随同库已有脚本判据:SHOW CREATE TABLE 里是 utf8mb4;同库不出现第二套时间列名。违反后果:四字节字符插入失败;读代码要每次确认列名属于哪套。MODIFY ... id、DROP FOREIGN KEY 时,先要求补一份设计说明。违反后果:类型改动同时波及代码与外键,回滚要按相反顺序来,而出事时往往没有这份顺序。这两条有例外:第三条里的「一套时间列名」只约束同一个数据库。以后接入第二个库、而那个库的历史写法不同时,按库各写一份,不强行统一。第四条对纯文本、无文件的表不适用——没有文件与向量点时,删除只涉及行。
采纳与不采纳的理由都要写下来,理由决定下次遇到相关条款时往哪边判。
| 章 | 采纳 | 不采纳 |
|---|---|---|
| 建表 | 小写单数表名;避开保留字;小数用 DECIMAL;索引名前缀;长文本按长度选;utf8mb4;长度限制用 CHAR_LENGTH | 每表必备 id、gmt_create、gmt_modified(同库已有 created_at);禁止外键与级联(本服务需要外键) |
| 索引 | 业务唯一组合建唯一索引;前置模糊 LIKE '%词' 用不上普通索引,关键字走全文索引或检索引擎;组合索引等值条件放左边;超过三张表的 JOIN 先拆开 | 单表行数与容量远没到五百万行或 2GB 时,不在建表时分库分表 |
| SQL | NULL 用 IS NULL;统计用 COUNT(*),COUNT(列) 会跳过空值;不用存储过程,逻辑留在 Go 里才能测试;订正或删除前先 SELECT;开发代码里不用 TRUNCATE;关联列类型一致 | 把手册的排序表当作性能结论引用。索引是否被用上以 EXPLAIN 为准 |
什么时候这条不再成立:COUNT(*) 与分页深翻页在数据量大时会变慢。课程项目的量级下不必为它换写法;真到了那个量级,改的是分页方式,不是这一条。