本层判断标准

把请求体里的 scope_id 换成另一个值重发一次,结果会不会变?

答「会」,说明所属范围来自请求而不是会话,这是本层最严重的一类问题。要改的是查询的绑定来源,不是再加一个判断。

字段、类型与索引的约定

打开建表语句能看到列名、类型、主键、索引和外键;看不到的是查询时 scope_id 从会话来还是从请求来。所以约定分两半:表结构这一半写进迁移脚本,信任边界那一半写进决议和仓库说明。

对象约定判据违反后果
表名小写、单数、不用保留字建表语句里不出现 desc、range、match 这类词作表名每条查询都要加反引号,助手常忘
字段名小写加下划线;时间列跟随同库已有脚本一个库里只有一套时间列名同库并存 created_at 与 gmt_create,读代码要每次确认
主键BIGINT 自增,不改成 UUIDSHOW CREATE TABLE 一遍改主键要同时改所有外键与代码里的类型
小数DECIMAL,不用 FLOAT 与 DOUBLE建表语句里搜不到 float、double金额累加出现分位误差,且只在数据多了以后显形
长文本按长度选类型。MySQL 的 TEXT 约 64 KiB,上传上限到数 MiB 时用 MEDIUMTEXT字段类型与上传上限对照超长内容被截断,且不报错
字符集库与表用 utf8mb4SHOW CREATE TABLE 输出里是 utf8mb4四字节字符插入失败,用户看到的是一条 500
索引名前缀区分:pk_、uk_、idx_见建表语句同名索引互相覆盖,加索引时静默失败
唯一性业务上必须唯一的组合建唯一索引例如同一 scope_id 下的外部编号只在应用里先查再插入挡不住并发,出现重复行
关联列两侧类型必须一致对照外键两侧的列定义索引失效,关联查询全表扫描

与现有库一致的新表(migrations/0001_create_notes.up.sql)

字符集与引擎写在建表语句里。标题长度用 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 必须是真的能执行的反向操作。三句话都有确定判据,所以把它们写成了脚本。

一对迁移文件(up 与 down 一起提交)

文件名 NNNN_动词_对象,一次提交这一对。第一行注释写这次改动做什么,方便 migrate 日志里对上号。

migrations/0007_add_notes_scope_index.up.sql
migrations/0007_add_notes_scope_index.down.sql

0007_add_notes_scope_index.up.sql

-- 0007 列表按所属范围倒序读取,补组合索引。
-- 等值条件 scope_id 放左边,排序用的 created_at 放右边。
ALTER TABLE notes
    ADD KEY idx_notes_scope_created (scope_id, created_at);

0007_add_notes_scope_index.down.sql

把 down 写成空文件是最常见的违规。不能反向执行的改动(例如删列后再恢复会丢数据),在发布说明里写清停在哪一步,见发布。

-- 0007 回滚:删掉本编号新增的索引。
ALTER TABLE notes
    DROP KEY idx_notes_scope_created;

scripts/check-migrations.sh

三条检查一次跑完:文件名格式、编号连续、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

Makefile(并进 check)

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 通过的迁移不算通过。
查 scope_id 的来源跑 git diff main...HEAD -U0 | grep -nE '^\+.*scope_id',逐条确认绑定值来自会话。来源判定见数据边界。
现象原因修法
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 用占位符不用字符串拼接。违反后果:换一个请求参数就能读到别人的数据。
迁移 up 与 down 成对,且 down 真的能跑判据:sh scripts/check-migrations.sh 退出码 0,且 make migrate-roundtrip 三段都不报错。违反后果:发布出问题只能手工改库,回滚步骤写不出来。
字符集 utf8mb4、时间列名跟随同库已有脚本判据:SHOW CREATE TABLE 里是 utf8mb4;同库不出现第二套时间列名。违反后果:四字节字符插入失败;读代码要每次确认列名属于哪套。
删除记录时把行、文件、向量点一起处理判据:删除路径的测试里,删除后三处都查不到。违反后果:检索仍会命中已经不存在的文本,用户点进去得到 500。
不在同一次改动里改主键类型或删已有外键判据:改动差异里出现 MODIFY ... id、DROP FOREIGN KEY 时,先要求补一份设计说明。违反后果:类型改动同时波及代码与外键,回滚要按相反顺序来,而出事时往往没有这份顺序。

这两条有例外:第三条里的「一套时间列名」只约束同一个数据库。以后接入第二个库、而那个库的历史写法不同时,按库各写一份,不强行统一。第四条对纯文本、无文件的表不适用——没有文件与向量点时,删除只涉及行。

手册三章里采纳和不采纳的

采纳与不采纳的理由都要写下来,理由决定下次遇到相关条款时往哪边判。

章采纳不采纳
建表小写单数表名;避开保留字;小数用 DECIMAL;索引名前缀;长文本按长度选;utf8mb4;长度限制用 CHAR_LENGTH每表必备 id、gmt_create、gmt_modified(同库已有 created_at);禁止外键与级联(本服务需要外键)
索引业务唯一组合建唯一索引;前置模糊 LIKE '%词' 用不上普通索引,关键字走全文索引或检索引擎;组合索引等值条件放左边;超过三张表的 JOIN 先拆开单表行数与容量远没到五百万行或 2GB 时,不在建表时分库分表
SQLNULL 用 IS NULL;统计用 COUNT(*),COUNT(列) 会跳过空值;不用存储过程,逻辑留在 Go 里才能测试;订正或删除前先 SELECT;开发代码里不用 TRUNCATE;关联列类型一致把手册的排序表当作性能结论引用。索引是否被用上以 EXPLAIN 为准

什么时候这条不再成立:COUNT(*) 与分页深翻页在数据量大时会变慢。课程项目的量级下不必为它换写法;真到了那个量级,改的是分页方式,不是这一条。