Kratos 云盘项目:GORM 数据建模与分页查询

2025-01-15T10:30:00+08:00 | 34分钟阅读 | 更新于 2025-01-15T10:30:00+08:00

@

本文基于真实开源项目 kratos-cloude-disk(Kratos 云盘)的源码梳理,所有表结构、字段、常量、代码均来自 internal/datainternal/biz,可作为 Go 后端面试的「数据库建模 / GORM / 分页」专题复习笔记。

本文怎么读

这是一篇「教材化 + 面试问答」双视角的笔记。每一节都以 ### Qn. 提出问题,先给一个生活化类比帮你建立直觉,再落到项目的真实代码与字段讲工程取舍。文中所有 file.go:88mysql.go:34 这类标注都是可直接 grep 的定位,建议边读边打开源码对照。Mermaid 图(共 8 张)把 ER 关系、状态机、分页对比、扣减时序等「说不清、画得清」的关系可视化,面试白板上照着画就能拿分。读到 > ⚠️ 标注处请停下——那都是本项目真实踩过的坑,也是面试官最爱追问的「你以为对的其实有坑」环节。

云盘系统看似简单,却几乎集齐了后端数据库的全部高频考点:连接池、索引、软删除、分页、并发扣减、N+1。把这一篇吃透,等于把「GORM 与数据库建模」的面试题库过了一遍。

学习目标

读完本文,你应该能够把下面 5 件事讲清楚,并且能在白板上画出来:

  1. GORM 初始化与连接池gorm.Open(mysql.Open(dsn)) 背后发生了什么,SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime 分别解决什么问题,为什么 DSN 里必须带 parseTime=True&loc=Local
  2. 数据建模与索引:云盘 8 张核心表怎么设计,自引用树(ParentID)与多对多(file_folder_junctions)分别解决什么场景,5 个复合索引如何按查询维度落地、最左前缀原则怎么用。
  3. 自定义软删除状态机:为什么本项目不用 GORM 自带的 DeletedAt,而是用 Status tinyint(0 正常 / 1 回收站 / 2 已删)表达「中间态」。
  4. 键集分页offset 深翻页的性能陷阱,以及 ListFiles 如何用 created_at 游标(NextCursor / HasMore)跳过 offset,为什么只缓存第一页。
  5. 原子存储扣减与并发安全UpdateUsedStorageAtomic 如何把「检查 + 扣减」压进一条带行锁的原子 UPDATE,配合分布式锁与 RecalibrateStorage 定时校准,避免超卖配额。

前置知识

  • 基本的 MySQL 索引与 EXPLAIN 概念;
  • Go 语言基础(struct tag、interface、错误处理);
  • 了解 Kratos 的 wire 依赖注入与 biz / data / service 分层(本文重点在 databiz)。

动手 3 件

  1. configs/config.yaml:11 的 DSN 复制到本地,用 mysql -e "SELECT ..." 验证 parseTime 关闭时 time.Time 扫描会报错。
  2. files 表上 EXPLAIN 一条 WHERE user_id=? AND parent_id IS NULL AND status=0 ORDER BY created_at 的查询,确认命中 idx_user_parent_status_time
  3. ListFiles 里临时把 limit+1 改成 limit,观察 HasMore 永远为 false 的后果。

一、GORM 初始化:从 DSN 到连接池

Q1. GORM 是怎么连上 MySQL 的?连接池参数怎么配才不踩坑?

答: 把 GORM 想成「带 ORM 语法糖的数据库驱动壳」。它底层还是 database/sql,真正干活的是 go-sql-driver/mysql。初始化分三步:拼 DSN → mysql.Open 包装 → gorm.Open 建立会话 → 取出底层 *sql.DB 调连接池

先看项目里真实的初始化代码(internal/data/mysql.go:21):

db, err := gorm.Open(mysql.Open(c.Source), &gorm.Config{
    SkipDefaultTransaction: true,  // 关掉每个写操作的隐式事务,减少开销
    PrepareStmt:            true,  // 开启预编译语句缓存,提升重复 SQL 性能
})

c.Source 就是 DSN,来自 configs/config.yaml:11

source: root:${MYSQL_ROOT_PASSWORD:admin123}@tcp(${MYSQL_HOST:127.0.0.1}:${MYSQL_PORT:3306})/${MYSQL_DATABASE:cloud_disk}?parseTime=True&loc=Local

连接池配置在 mysql.go:34

sqlDB.SetMaxIdleConns(50)      // 空闲连接数:从 10 提升到 50,减少重建连接开销
sqlDB.SetMaxOpenConns(200)     // 最大连接数:从 100 提升到 200,应对高并发
sqlDB.SetConnMaxLifetime(30 * time.Minute) // 连接最大存活 30 分钟,避免长连接泄漏
sqlDB.SetConnMaxIdleTime(5 * time.Minute)  // 空闲连接 5 分钟后回收

几个面试常问的点:

  • SkipDefaultTransaction: true:GORM 默认给每次 Create/Update/Delete 包一层事务。高频写入场景下这会带来不必要的 BEGIN/COMMIT 开销。本项目因为存储扣减、上传等走的是「行锁 + 原子 UPDATE / 分布式锁」而非大事务(见第五节),所以可以安全关闭默认事务。
  • PrepareStmt: true:开启后 GORM 会把编译好的执行计划缓存起来,对重复执行的参数化 SQL 有明显加速,代价是多一点内存。

连接池的三个参数到底怎么配合? 这是面试官最爱追问的细节,建议背下来:

  • MaxOpenConns 是上限:任意时刻与数据库的最大物理连接数。请求到来时,若池里没空闲连接且未达上限,就新建一个;已达上限,请求阻塞等待直到有连接归还或 context 超时。设太小,高并发时请求排队饿死;设太大,MySQL 侧连接数被打满。
  • MaxIdleConns 是保活:连接用完不立即关闭,留在池里待复用。它必须 MaxOpenConns,否则多余空闲连接永远不会被创建(Go 的 database/sql 会默默忽略)。本项目 50 < 200,合理。
  • ConnMaxLifetime / ConnMaxIdleTime 是防腐:云厂商的 LB、MySQL 的 wait_timeout 都会在空闲一段时间后悄悄断开连接。如果 Go 还以为那条连接活着,拿去执行就会报 driver: bad connection,Go 会自动重试一次但浪费延迟。设 MaxLifetime=30m 主动在连接「变老」前轮换掉,规避半开连接。
  • 为何 SkipDefaultTransaction: true 配合连接池更安全:默认每个写操作都开事务,事务内连接被独占,连接占用时间变长,池更容易枯竭。关掉默认事务后,单条 UPDATE 几乎瞬时归还连接,池吞吐更高——前提是你自己用「行锁原子 UPDATE / 分布式锁」保证一致性(见第九节),而不是依赖隐式事务的回滚。

⚠️ SetMaxOpenConns 不是越大越好。MySQL 默认 max_connections 约 151,设成 200 已经超过默认值,生产必须同步调高 MySQL 侧的 max_connections,否则会在连接风暴时拿到 Too many connections。注释里写的「针对压测发现的 C200 连接池耗尽问题调优」就是这个意思——是压测后的结果,不是拍脑袋。

⚠️ 坑(连接泄漏):任何 db.Query / db.Exec 后忘了处理,或 rowsClose(),连接就不会归还池,最终 MaxOpenConns 被占满、全站卡死。本项目统一用 r.db.WithContext(ctx) 并在 context 上挂超时,超时后连接会被强制回收,是一道兜底防线。

Q2. DSN 里的 parseTime=True&loc=Local 到底在干什么?不加会怎样?

答: parseTime=True 让 MySQL 驱动把 DATETIME/TIMESTAMP 列直接解析成 Go 的 time.Time,而不是返回原始字节串。loc=Local 约定时区为服务器本地时区,保证 time.Time 和数据库里的时间戳语义一致。

类比parseTime 就像快递柜的「自动拆箱」开关。关掉它,驱动把时间当「一堆字节」丢给你,你还得自己按 MySQL 的二进制格式去拆;打开它,驱动帮你拆好成 time.Time,直接能 .Format()

⚠️ :如果不加 parseTime=True,用 SELECT * 扫到 CreatedAt time.Time 字段时,GORM 会扫描失败或拿到乱码字符串,直接导致列表接口 500。这是新手接 MySQL 时最常见的「明明 SQL 能跑、Go 一查就崩」的元凶之一。

⚠️ loc=Local 配合容器化要小心。如果应用容器时区是 UTC、MySQL 是 +08:00,时间字段会出现 8 小时偏差。生产建议统一设 loc=Asia%2FShanghai(URL 编码)或全程 UTC,别依赖宿主机 Local

parseTimeloc 是两个独立开关,别混为一谈parseTime=True 决定「驱动是否帮你把字节解析成 time.Time」,loc 决定「解析时用哪个时区」。即使开了 parseTimeloc 设错照样时区错乱。本项目 MySQL 8.0(docker-compose.yml:5)默认 time_zone 是系统区,应用容器若没设 TZLocal 就取容器时区——所以时区一致性要在 MySQL、应用、DSN 三处同时保证,这是分布式系统时间问题的通病。

更进一步:DATETIME 还是 TIMESTAMP 本项目用 time.Time + GORM autoCreateTime,GORM 默认落成 DATETIMEDATETIME 存的是「字面时间」、不随时区转换,配合 loc=Local 由驱动解释;TIMESTAMP 则在存储时转 UTC、读取时转回会话时区。云盘这类需要跨时区展示「上传于 3 天前」的相对时间场景,DATETIME + 统一时区 反而更可控,避免 TIMESTAMP 在时区变更时显示漂移。


二、AutoMigrate:开发爽,生产慎

Q3. AutoMigrate 这么方便,生产也能直接用吗?

答: AutoMigrate 是开发期的「懒人建表神器」——启动时对照 Go struct 自动 CREATE TABLE IF NOT EXISTS、加缺失的列和索引。但生产环境直接用它是有风险的

项目在 internal/data/data.go:60 启动时调用:

if db != nil {
    if err := model.AutoMigrate(db); err != nil {
        return nil, nil, err
    }
}

model.AutoMigrateinternal/data/model/migration.go:21)做两件事:先手动 DROP 掉一个废弃的 idx_users_email 唯一索引,再对 8 张表执行 db.AutoMigrate(...)

// 先清理历史遗留索引
if exists, err := indexExists(db, "users", "idx_users_email"); err == nil && exists {
    db.Exec("ALTER TABLE users DROP INDEX idx_users_email")
}
models := []interface{}{
    (*User)(nil), (*File)(nil), (*Folder)(nil),
    (*FileFolderJunction)(nil), (*Share)(nil), (*RecycleBin)(nil),
    (*UploadSession)(nil), (*UploadChunk)(nil),
}
db.AutoMigrate(models...)

生产三大风险(面试高频):

  1. 无版本管理:AutoMigrate 只「追平」结构,不记录你改过几次、为什么改。回滚、灰度、审计全没有。
  2. 锁表风险:加列 / 改类型在大表上会锁表(尤其 MySQL 5.6 以前)。AutoMigrate 启动时一股脑执行,可能把正在跑的业务卡死。
  3. 不可控的破坏性变更:AutoMigrate 不会删列、不会缩列宽,但它会尝试加索引——大表加索引在没 ALGORITHM=INPLACE 时会长时间锁表。

工程正确做法:开发用 AutoMigrate 快速迭代,生产改用 golang-migrateAtlas,把每次 schema 变更写成带版本号的 up/down 迁移文件,走 CI 审核、可回滚、可灰度。

golang-migrate 与 Atlas 怎么选? 两者理念不同:golang-migrate 是「你手写 SQL 迁移文件(0001_create_users.up.sql / .down.sql),它按顺序执行并记录版本到 schema_migrations 表」——简单、可控、对 DBA 友好;Atlas 更进一步,支持「声明式」:你写期望的终态 schema,它自动 diff 出增量迁移,还能做「漂移检测」(发现有人手改了线上表结构会报警)。小团队从 golang-migrate 起步足够。

迁移文件长什么样? 以本项目 files 表为例,一个迁移文件大概是这样(关键在 up 建表、down 删表):

-- 0001_create_files.up.sql
CREATE TABLE files (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  uuid CHAR(36) NOT NULL,
  user_id BIGINT UNSIGNED NOT NULL,
  parent_id BIGINT UNSIGNED DEFAULT NULL,
  status TINYINT NOT NULL DEFAULT 0,
  created_at DATETIME(3) NOT NULL,
  UNIQUE KEY uk_uuid (uuid),
  KEY idx_user_parent_status_time (user_id, parent_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 0001_create_files.down.sql
DROP TABLE files;

注意 DATETIME(3)(3) 就是毫秒精度,和代码里 Format("2006-01-02 15:04:05.000") 一一对应——这也是为什么本项目游标能精确到毫秒。

迁移执行的时机:生产不会在「每次进程启动」跑 AutoMigrate,而是发版时单独跑一次迁移 job(K8s Job / CI step),确认成功后再滚动部署新版本代码。这样代码和 schema 的版本严格分离,回滚也互不拖累。

⚠️ :本项目 AutoMigrate 第一行就手写了 DROP INDEX idx_users_email——这正是 AutoMigrate「删不掉旧索引」的补丁。说明真实项目里 AutoMigrate 的「自动」是有盲区的,仍要人肉兜底。


三、核心表设计:ER 关系与自引用树

Q4. 云盘 8 张表之间是什么关系?为什么 file 和 folder 用 ParentID 自引用?

答: 先建立全局认知。云盘的本质是「用户 → 文件树 → 分享 / 回收 / 上传」。

下面的 ER 关系图(用 --> 表示「拥有 / 关联」,标注 1:N 或自引用):

flowchart LR
    U["users
用户表"] -->|拥有 1:N| F["files
文件表"] U -->|拥有 1:N| FO["folders
文件夹表"] U -->|创建 1:N| S["shares
分享表"] U -->|记录 1:N| R["recycle_bins
回收站表"] U -->|发起 1:N| US["upload_sessions
上传会话表"] U -->|产生 1:N| L["file_access_logs
访问日志表"] FO -->|自引用 ParentID| FO2["folders
父子文件夹"] F -->|自引用 ParentID| F2["files
根/子文件"] FO -->|多对多| JF["file_folder_junctions
文件-文件夹关联"] F -->|多对多| JF US -->|包含 1:N| UC["upload_chunks
分片表"] S -->|关联 ItemID| F S -->|关联 ItemID| FO

关键点:

  • files.ParentID *uint64folders.ParentID *uint64 都是自引用model/file.go:15model/folder.go:11)。*uint64 用指针表示「可空」——nil 即根目录(parent_id IS NULL)。这就是文件树的存储方式:不用闭包表、不用路径串,用「每行记父节点」的邻接表。
  • 根目录怎么查? data/file.go:93parentID == nil 时拼 parent_id IS NULL,这是邻接表的标准查法。
  • file_folder_junctions 解决「一个文件在多个文件夹」:普通网盘文件只能在一个目录,但支持「归类 / 快捷方式」的场景需要多对多。本项目用 FileFolderJunctionmodel/file_folder_junction.go),FileID + FolderID 建复合唯一索引防止重复关联。

⚠️ :邻接表(ParentID)查「某目录所有子孙」需要递归 WITH RECURSIVE 或应用层循环。本项目 ListAllSubDirectoryIDsbiz/file.go:304)就是递归收集子目录 ID 列表,再 parent_id IN (...)。目录很深时这条 SQL 的 IN 会很长,是潜在的性能热点。

自引用树还有别的存法吗? 面试常被追问「邻接表 vs 路径串 vs 闭包表」:

  • 邻接表(本项目):每行记 parent_id。优点——增删节点只改一行,简单;缺点——查子树要递归,深目录慢。适合目录不深、读多写少的网盘。
  • 路径串(Materialized Path):每行记完整路径如 /1/5/23/。查子树变成 WHERE path LIKE '/1/5/%',一次搞定;缺点——移动节点要改整棵子树所有行的前缀,写代价大。
  • 闭包表(Closure Table):单独一张 ancestor, descendant, depth 表存所有祖先-后代关系。子树、层级、距离查询都极快,但空间和写复杂度高,适合树极深、读极重的场景。

本项目选邻接表是「够用且最简单」的工程判断,不必为理论最优过度设计——这正是 KISS 原则的体现。


四、GORM Model 定义与 tag:主键、索引、自定义软删除

Q5. 项目的 GORM tag 是怎么写的?为什么用 Status 而不是 GORM 的 DeletedAt 做软删除?

答: 先看 File 这个最核心的模型(model/file.go:6):

type File struct {
    ID        uint64    `gorm:"primaryKey;autoIncrement"`
    UUID      string    `gorm:"type:char(36);uniqueIndex;not null"`
    UserID    uint64    `gorm:"index:idx_user_parent_status_time;not null"`
    Name      string    `gorm:"type:varchar(512);not null"`
    Size      int64     `gorm:"not null;default:0"`
    Type      string    `gorm:"type:varchar(128)"`             // MIME type
    Hash      string    `gorm:"type:char(64);index:idx_hash_status"` // SHA-256,秒传用
    Path      string    `gorm:"type:varchar(1024);not null"`   // 物理存储路径
    ParentID  *uint64   `gorm:"index:idx_user_parent_status_time"` // 父目录,根目录为 NULL
    Status    int32     `gorm:"type:tinyint;default:0;index:idx_user_parent_status_time;index:idx_hash_status;not null"` // 0=正常 1=回收站 2=已删
    Starred   bool      `gorm:"type:tinyint(1);default:0;index:idx_user_starred"`
    CreatedAt time.Time `gorm:"autoCreateTime;index:idx_user_parent_status_time"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
    User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (File) TableName() string { return "files" }

软删除状态机是本项目最大的设计亮点。GORM 默认软删除是 DeletedAt *time.Time,删了就「看不见但还在」。但云盘需要「回收站」这个中间态——用户删文件,先进回收站可恢复,超过保留期才彻底删。三态设计如下:

stateDiagram-v2
    [*] --> Normal["Status = 0
正常可见"] Normal --> Trashed["Status = 1
回收站
可恢复"] Trashed --> Normal Trashed --> Deleted["Status = 2
已删除
等待物理清理"] Deleted --> [*] Normal --> Deleted["直接永久删除
(极少用)"]

为什么不用 DeletedAt

  • DeletedAt 只有「存在 / 已删」两态,表达不了「在回收站里」这个中间态;
  • 业务查询几乎都要带 status = 0(只看正常文件),用自定义 Status 字段顺便把它塞进复合索引,查询更顺手;
  • 回收站列表查 status = 1,永久删除任务查 status = 2,调度清晰。

关键代码佐证data/file.go:90 所有列表查询都写死 status = ?(ListByParent 传 0 表示正常):

query := r.db.WithContext(ctx).Model(&model.File{}).
    Where("user_id = ? AND status = ?", userID, status)

⚠️ :用自定义 Status 后,每一个查询都必须显式带 status 条件,否则会连回收站、已删文件一起捞出来。本项目用 FindByHash 秒传时也带 status = 0data/file.go:73),漏一个就出 bug。这正是「自定义软删除」比 GORM 自带 DeletedAt(自动注入 deleted_at IS NULL)更容易出错的地方。

GORM 的 DeletedAt 为什么「自动」? 因为 GORM 在 db.Where 链里识别到模型有 gorm.DeletedAt 字段时,会自动给所有查询追加 deleted_at IS NULL,你不用每次手写。代价就是它只有两态。本项目的三态需求超越了它,所以放弃自动注入、改用 Status——这是一个「放弃框架便利、换取业务表达力」的典型决策。面试时能把「便利 vs 表达力」的 trade-off 讲清楚,比背 API 更有价值。

Statustinyint 而非 enum 的原因:MySQL ENUM 改值(加一个状态)要 ALTER TABLE,且 ENUM 的排序/比较容易踩坑;tinyint 只是个数字,扩展状态只改代码常量、不动表结构,迁移成本极低。本项目 Status int32 配注释 0=正常 1=回收站 2=已删 就是文档即代码的做法。

回收站过期清理怎么联动三态? recycle_bins 表用独立 ExpireAtmodel/recycle_bin.go:16)记录「何时彻底删」。定时任务扫 recycle_bins WHERE expire_at < NOW(),把过期项对应的 filesstatus 置 2、并触发物理删除。注意 files.status=2 表示「逻辑已删、等物理清理」,真正的 DELETE 行发生在物理文件删除成功之后——再次印证第八节「先物理、后逻辑」的顺序。


五、复合索引:按查询维度建,吃透最左前缀

Q6. 项目说有「5 个复合索引」,分别是哪些?最左前缀原则怎么用?

答: README 明确写了「5 个复合索引」。逐一核对 model 定义,确实是 5 个:

索引名字段顺序服务的查询
idx_user_parent_status_timefilesuser_id, parent_id, status, created_at列目录、分页
idx_hash_statusfileshash, status秒传查重
idx_user_starredfilesuser_id, starred收藏列表
idx_user_accessedfile_access_logsuser_id, file_id, accessed_at最近使用
idx_status_createdusersstatus, created_at按状态筛选用户

最左前缀原则:复合索引 (a,b,c) 能加速 WHERE a=?(a,b)(a,b,c),但不能跳过左边直接吃 bc。本项目把查询频率最高、选择性最好的 user_id 放在最左,正是为了让「按用户隔离」成为所有查询的公共前缀。

实战例子——列目录查询(data/file.go:89):

query := r.db.WithContext(ctx).Model(&model.File{}).
    Where("user_id = ? AND status = ?", userID, status)
if parentID == nil {
    query = query.Where("parent_id IS NULL")
} else {
    query = query.Where("parent_id = ?", *parentID)
}
// 最后 ORDER BY created_at
query.Order(orderClause).Limit(limit + 1)

这条 SQL 的 WHERE 顺序是 user_id → status → parent_id,而索引顺序是 user_id, parent_id, status, created_at注意statusparent_id 之前出现在 SQL 里,但索引里 parent_idstatus 之前。MySQL 优化器实际能根据索引顺序重新排列 AND 条件的使用——只要出现 user_idparent_idstatus 都用上,idx_user_parent_status_time 就能被充分利用,created_at 还顺便覆盖了 ORDER BY,实现「索引覆盖排序」,避免 filesort。

flowchart TD
    A["WHERE user_id = ?
命中索引第1列"] --> B["AND parent_id = ?
命中索引第2列"] B --> C["AND status = ?
命中索引第3列"] C --> D["ORDER BY created_at
命中索引第4列
免 filesort"] D --> E["Using index
覆盖扫描 高性能"]

⚠️ :如果你把 ORDER BY name 和这个索引混用,因为 created_at 是索引最后一列,按 name 排序就会掉出索引顺序,触发 filesort。本项目 ListByParent 的排序字段可以选 name/size/created_at,但分页游标统一用 created_at(见下一节),正是为了不让排序破坏 keyset 分页的稳定性。

索引选择性(cardinality)的取舍:复合索引字段顺序还受「选择性」影响——选择性高的列(如 user_id,几乎每行不同)放前面能更快缩小扫描范围。status 只有 3 个值、选择性低,放 user_id 之后是合理的。但如果某查询是「按 status 全局扫回收站(WHERE status=1 不带 user_id)」,这个复合索引就完全用不上(违背最左前缀),只能新建独立索引。本项目恰好没有这种全局扫描需求,所以复用复合索引够用。

idx_hash_status 为什么把 hash 放最左? 秒传场景是 WHERE hash=? AND status=0data/file.go:73),hash 是 64 位 SHA-256、选择性极高,status 只是过滤已删,所以 hash 在前。hashchar(64) 定长,索引更紧凑。

一个容易被忽略的细节files 上有 3 个索引都涉及 user_ididx_user_parent_status_timeidx_user_starred、单列的 UserID 之外)。索引不是免费的——每次 INSERT/UPDATE 都要维护它们,写放大随索引数线性增长。面试时要能说出「索引是读写权衡」,本项目索引是为「列目录、秒传、收藏、最近使用」这些高频读路径定制的,而非越多越好。


六、唯一索引兜底并发注册

Q7. 并发注册怎么防止重复用户名?应用层查过就够了吗?

答: 不够。这是经典的「检查再插入」竞态(TOCTOU)。正确做法是应用层先查(第一道防线,提升正常路径体验)+ 数据库唯一索引(第二道防线,兜住并发)

项目 User 模型对 Username 建了唯一索引(model/user.go:8):

Username string `gorm:"type:varchar(64);uniqueIndex;not null"`

注册流程(biz/user.go:172)先查一遍:

// 第一道防线:应用层查重
existing, err := uc.repo.FindByUsername(ctx, username)
if existing != nil && existing.ID > 0 {
    return nil, ErrUserAlreadyExists
}
// ... 密码哈希 ...
// 第二道防线:DB 唯一索引
created, err := uc.repo.Create(ctx, user)
if err != nil {
    // 检查错误是否为重复条目(MySQL 错误 1062)
    if errors.IsConflict(err) {
        return nil, ErrUserAlreadyExists
    }
    return nil, err
}

为什么需要第二道防线? 两个请求同时过 FindByUsername 都返回「不存在」,然后都去 INSERT——只有一个能成功,另一个撞唯一索引报错。MySQL 错误码 1062(Duplicate entry) 被驱动返回,GORM 包成 ErrDuplicatedKey,Kratos 的 errors.IsConflict 识别后转成 ErrUserAlreadyExists(409 Conflict)。

sequenceDiagram
    participant U1 as 请求A
    participant U2 as 请求B
    participant DB as MySQL
    U1->>DB: FindByUsername(重名) 未命中
    U2->>DB: FindByUsername(重名) 未命中
    U1->>DB: INSERT 成功
    U2->>DB: INSERT 触发 1062 唯一索引冲突
    DB-->>U2: 1062 -> IsConflict -> ErrUserAlreadyExists

⚠️ errors.IsConflict(err) 能识别 1062,前提是 MySQL 驱动返回的错误类型被 Kratos 错误包正确包装。如果 repo 层把 errfmt.Errorf("...: %w", err) 包裹而忘了 %wIsConflict 就匹配不到,唯一索引冲突会变成 500 而非 409。本项目 data/user.goCreate 直接 return nil, err 透传,是正确的。

⚠️ :邮箱唯一索引的教训就写在 migration.go:22——idx_users_email 被手动 DROP 掉了,因为「安全问题中去掉了邮箱必填」。说明唯一索引一旦建了又想放宽是破坏性操作,中间态字段(如可选邮箱)不要盲目加唯一索引,否则空字符串 '' 也会被唯一约束挡住第二条记录。

为什么「应用层先查」是必要的? 如果只靠唯一索引兜底,每次重复注册都会真的打到 DB 触发 1062,产生一次失败的写事务、一条错误日志、一次 409 返回——对正常用户体验无碍,但对「撞用户名」这种高频误输场景,会白白浪费 DB 写能力和日志噪声。应用层先 FindByUsername 命中就直接返回,绝大多数冲突在到达 DB 前就被优雅拦截,唯一索引只兜底那极小概率的并发窗口。这是「绝大部分走快路径、异常靠索引兜底」的分层防御思想。

唯一索引失败一定是「重复」吗? 不一定。1062 也可能来自其他唯一键(本项目 UUIDShareCodeUploadID 都是唯一索引)。所以 errors.IsConflict 只能告诉你「撞了某个唯一约束」,具体是哪个键要靠错误信息里的 key 字段解析。本项目因为只有 Username 在注册路径上唯一,所以简单映射成 ErrUserAlreadyExists 是对的;但若表上有多个唯一键,更严谨的做法是解析 err.Error() 判断 idx_users_username 还是别的。


七、分页:offset 深翻页 vs 键集分页

Q8. offset 分页有什么问题?本项目的 keyset/cursor 分页是怎么落地的?

答: 类比:offset 分页像「从第 1 页开始数,数到第 10000 条再取 20 条」——每次都要把它前面的 9999 条扫一遍再丢掉的。keyset(游标)分页像「记住上一页最后一条的书签,下次直接从书签往后取」,数据库用索引直接定位,省掉前面的扫描。

offset 深翻页的性能问题LIMIT 10000, 20 在 InnoDB 里要先读 10020 行、丢弃前 10000 行,越翻越慢,且翻页过程中数据插入会导致「重复 / 漏读」。

本项目 ListFilesbiz/file.go:223)用 keyset 分页:

const defaultPageSize = 20   // biz/file.go:213

func (uc *FileUsecase) ListFiles(ctx, userID, parentID, cursor, pageSize, sortBy, sortOrder) (*ListResult, error) {
    if pageSize <= 0 {
        pageSize = defaultPageSize
    }
    // ...
    files, fileCursor, err := uc.fileRepo.ListByParent(ctx, userID, parentID, 0, cursor, pageSize, sortBy, sortOrder)
    folders, folderCursor, err := uc.folderRepo.ListByParent(ctx, userID, parentID, cursor, pageSize, sortBy, sortOrder)
    nextCursor := fileCursor
    if folderCursor > nextCursor { nextCursor = folderCursor }
    result := &ListResult{
        Files:      files,
        Folders:    folders,
        NextCursor: nextCursor,
        HasMore:    len(files) >= pageSize || len(folders) >= pageSize,
    }
    return result, nil
}

核心是 data/file.go:88ListByParent——游标统一是上一页最后一条的 created_at 时间戳

// 键集分页边界:基于 created_at 比较
if cursor != "" {
    if sortOrder == "desc" {
        query = query.Where("created_at < ?", cursor)
    } else {
        query = query.Where("created_at > ?", cursor)
    }
}
// 关键:多取 1 条用于判断 HasMore
var pos []model.File
query.Order(orderClause).Limit(limit + 1).Find(&pos)
hasMore := len(pos) > limit
if hasMore {
    pos = pos[:limit]   // 截掉多取的那条
}
// 下一页游标 = 当前页最后一条的 created_at(毫秒精度)
var nextCursor string
if len(pos) > 0 {
    nextCursor = pos[len(pos)-1].CreatedAt.Format("2006-01-02 15:04:05.000")
}

HasMore 怎么判断? 经典技巧:要 limit 条,但 SQL 写 LIMIT limit+1。如果真拿到 limit+1 条,说明后面还有,HasMore=true,再截掉第 limit+1 条。这样不需要再发一条 COUNT(*) 去数总数,省一次全表扫描。

offset vs keyset 对比图

flowchart TD
    subgraph OFF["OFFSET 分页"]
        O1["LIMIT 10000, 20"] --> O2["扫描 10020 行"]
        O2 --> O3["丢弃前 10000 行"]
        O3 --> O4["返回 20 行
越深越慢"] end subgraph KEY["KEYSET 分页"] K1["WHERE created_at 晚于 书签"] --> K2["索引定位 直接跳到书签"] K2 --> K3["取 limit+1 行"] K3 --> K4["截掉 1 行 得 HasMore
稳定高效"] end

⚠️ 坑(本项目真实设计取舍)ListFiles 把文件和文件夹各查一次,游标都基于 created_at。代码注释(data/file.go:84)明确写了——排序字段可以是 name/size,但分页边界统一用 created_at,否则文件用 created_at 游标、文件夹用别的游标会出现「翻页错乱」。注释也坦白:商用完整版应分别返回 FileCursor / FolderCursor(需改 API 契约)。面试时主动讲这个权衡,比死记「keyset 一定好」更显功力。

keyset 分页能随机跳页吗? 不能——这是它的最大代价。offset 能 ?page=100 直接跳,keyset 只能「上一页 → 下一页」顺序翻,因为它依赖上一页的游标。需要「跳到第 N 页」的后台管理场景,offset 仍然更合适。选型口诀:面向用户的无限滚动 / 翻页器用 keyset(性能稳);后台报表 / 管理页用 offset(能跳页)。

游标里塞什么才稳? 理想游标应是「唯一且单调」的排序键。本项目用 created_at 毫秒串,但同一毫秒并发写入会有重复值,此时 created_at > 书签 的边界会漏 / 重。工业级做法是用复合游标created_at + id 双键,例如游标编码成 2025-01-15 10:30:00.123|9527,比较时先比时间、时间相等再比 id。本项目简化版只用了 created_at,注释里也承认这是「商用完整版应改进」的点——面试讲出这个局限,比只夸 keyset 更专业。

游标要不要加密 / 编码? 本项目游标直接是可读时间戳字符串,前端能直接看到内部时间字段。生产建议对游标做 base64 或签名编码,既隐藏实现细节,也防止客户端伪造游标做越权扫描(虽然本项目还有 user_id 硬约束兜底)。

⚠️ :keyset 分页依赖排序键唯一且稳定。若两条记录 created_at 完全相同(同一毫秒写入),created_at > 书签 可能把边界那条漏掉或重复。本项目用 Format("... .000") 毫秒精度缓解了绝大多数情况,但高并发同一毫秒批量插入仍有极小概率。生产可加第二排序键(如 id)组成复合游标。

缓存与分页的协作边界:本项目缓存 key 是 files:list:{userID}:{parentID}:{sortBy}:{sortOrder}不含游标——意味着只有「第一页(cursor 为空)」能命中这一固定 key。后续页用各自不同的游标,天然不会进这个缓存,避免「游标页被旧缓存污染」。删除 / 移动文件时(biz/file.go:204invalidateListCache),用 DeleteByPattern("files:list:%d:%s:*") 把该目录所有排序组合的第一页缓存一次性清掉,保证列表在写操作后能看到最新数据。这是「缓存键设计 + 失效策略」配合的经典范式。

TTL 抖动为什么是 42s ± 18s? 42*time.Second + time.Duration(time.Now().Nanosecond()%18)*time.Second——用纳秒取模造一个 0~18s 的随机偏移,让同批缓存的过期时间分散开,避免「缓存同时失效 → 瞬时全打 DB」的缓存雪崩。42s 是经验值(目录列表变更不频繁,半分钟缓存收益高、陈旧可接受)。

Q9. 为什么只缓存「第一页」结果?

答:biz/file.go:233

// 只有 cursor == ""(第一页)才尝试缓存
if cursor == "" && uc.cache != nil {
    cacheKey = fmt.Sprintf("files:list:%d:%s:%s:%s", userID, parentStr, sortBy, sortOrder)
    // 命中则直接返回
}
// ... 查库 ...
// 只有第一页才写缓存,TTL 带抖动 42s ± 18s
if cursor == "" && cacheKey != "" && uc.cache != nil {
    uc.cache.Set(ctx, cacheKey, string(data), ttl)
}

原因:第一页是热点(用户打开目录默认看第一页),命中率最高、收益最大;深翻页访问稀疏,缓存命中率低还占内存。而且带游标的深翻页结果如果缓存,容易因数据变更返回过期数据(注释原话:「基于游标分页时跳过以避免过期结果」)。TTL 加随机抖动是为了防止缓存同时失效造成「惊群」。


八、软删除 vs 硬删除:物理文件与 DB 状态一致性

Q10. 回收站删除、永久删除,物理文件和数据库状态怎么保持一致?

答: 云盘删文件有两类动作:

  • 移入回收站(软删除):仅把 files.Status0 改成 1物理文件不动。用户能在回收站看到、能恢复。
  • 永久删除(硬删除的「半硬」):把 Status 改成 2(已删),由后台清理任务去删物理文件(Path 指向的磁盘 / OSS 对象)。

一致性原则(面试高频)先删物理文件,再标记 DB 状态;如果物理删除失败,保留 DB 记录(仍是 status=2)等重试,绝不能先标记已删、物理还在却查不到了(那样文件永远删不掉、空间也回收不了)。

recycle_bins 表(model/recycle_bin.go)是回收站的「快照」:删文件进回收站时,把 OriginalPath / Name / FileSize / FileType 缓存进回收站表,即使原 files 行后来被清理,回收站列表照样能展示名称、大小。ExpireAt 字段驱动定时任务把过期回收项彻底清理。

flowchart TD
    A["用户点删除"] --> B["Status 0 → 1 进回收站
物理文件保留"] B --> C["回收站可恢复 Status 1 → 0"] B --> D["超过保留期 / 点彻底删除"] D --> E["Status → 2 已删"] E --> F["后台任务先删物理文件 Path"] F --> G["物理删除成功 再清 DB 行"] F -->|失败| H["保留 status=2 记录
下次重试"]

⚠️ :回收站恢复时要是目标父目录也被删了(ParentID 指向已删文件夹),恢复后文件会「挂在虚空」。本项目用 file_folder_junctionsparent_id IS NULL OR parent_id IN (SELECT id FROM folders ...)data/file.go:220)来避免「孤儿文件」出现在「全部图片」里。

「先物理、后逻辑」失败的补偿怎么做? 假设物理删除 Path 时磁盘报错(权限、OSS 网络抖动),本项目保留 files.status=2 这行不删,由后台重试任务下次再清。这样不会出现「DB 说删了、磁盘文件还在」的幽灵文件(占着空间但查不到),而是「DB 标记待删、物理最终一致删除」——幽灵文件比「待删文件」危害小得多,因为前者永远回收不了空间。这个优先级判断(宁可留待删、不要变幽灵)是存储系统一致性的常识。

物理删除的「软清理」进阶:成熟网盘不会真删文件,而是进「墓碑 + 延迟彻底回收」——因为用户可能反悔、因为要防误删、因为要支持合规留存。本项目的 recycle_bins + files.status=2 已经是这个思路的简化版。秒传(Same-Content)对删除的影响:多个「逻辑文件」可能指向同一物理 Path(相同 Hash 秒传)。删一个文件时不能真删物理文件,否则其他秒传副本会坏——所以物理删除前必须 COUNT 一下还有没有别人引用同一 Hash/Path,引用数为 0 才真删。这是云盘删除逻辑里最容易漏的坑,面试能点出来非常亮眼。


九、原子存储扣减与并发安全

Q11. 多个人同时上传,怎么防止 storage 配额被「超卖」?

答: 这是库存超卖问题的云盘版。配额字段在 users 表:StorageQuota / TotalStorage(总额,默认 10GB)与 UsedStorage(已用)。

错误做法:先 SELECT used_storage 判断够不够,再 UPDATE used_storage = used + size。两个请求同时过 SELECT,都以为够,都去加,结果超配额——经典 TOCTOU。

正确做法(本项目):把「判断 + 扣减」压进同一条带行锁的原子 UPDATE,让数据库在单行上加写锁串行化。

data/user.go:117UpdateUsedStorageAtomic

// 增加操作:必须带上限约束,防止并发通过前置检查后超配额(TOCTOU)
result := r.db.WithContext(ctx).Model(&model.User{}).
    Where("id = ? AND used_storage + ? <= total_storage", userID, delta).
    UpdateColumn("used_storage", gorm.Expr("used_storage + ?", delta))

if result.Error != nil { return result.Error }
if result.RowsAffected == 0 {
    // 区分用户不存在 vs 空间不足
    var count int64
    r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Count(&count)
    if count == 0 { return biz.ErrUserNotFound }
    return biz.ErrStorageInsufficient   // 413
}

这条 SQL 的 WHERE used_storage + delta <= total_storage 在 InnoDB 上行锁保护下原子求值——只有「加完不超总额」才更新,否则 RowsAffected == 0,直接返回 ErrStorageInsufficient检查和扣减在同一行锁内完成,并发天然安全

再叠一层分布式锁biz/storage.go:88updateStorageWithLock 在调用 UpdateUsedStorageAtomic 前先拿分布式锁(user:storage🔒{userID}):

lockKey := fmt.Sprintf(storageLockKey, userID)
if uc.locker != nil {
    if err := uc.locker.Lock(ctx, lockKey); err != nil { return err }
    defer func() { _ = uc.locker.Unlock(ctx, lockKey) }()
}
if err := uc.repo.UpdateUsedStorageAtomic(ctx, userID, delta); err != nil { return err }
if uc.cache != nil {
    _ = uc.cache.Delete(ctx, fmt.Sprintf(cacheKeyUserStorage, userID)) // 清缓存
}

为什么要「行锁 + 分布式锁」双保险?因为还有缓存层——UsedStorage 会被多级缓存。分布式锁保证同一用户的多次扣减在应用层也串行,避免「A 扣完清缓存、B 读旧缓存又扣」的窗口。

定时校准RecalibrateStoragebiz/storage.go:125)每天跑,用 SUM(size) 真实文件大小重新算 used_storage 并修正偏差(delta = actualUsed - used,再原子 UPDATE)。

sequenceDiagram
    participant Req as 上传请求
    participant Lock as 分布式锁
    participant DB as users 行锁
    Req->>Lock: Lock(user:storage🔒UID)
    Lock-->>Req: 获取成功
    Req->>DB: UPDATE used_storage = used_storage + ?
WHERE id=? AND used+? 不超过 total DB-->>Req: 行锁内原子判定 成功/超配额 Req->>Lock: Unlock Req->>DB: 删除 used_storage 缓存

⚠️ :减少(SubUsedStorage)分支(data/user.go:119)用的是 WHERE used_storage >= -delta,防止减成负数。如果返回 RowsAffected==0,要先 Count 区分「用户不存在」还是「会减成负」,本项目正确地返回了不同错误。别一股脑都返回 ErrStorageInsufficient,否则用户会被误导成「空间不足」而不是「文件不见了」。

⚠️ 坑(事务边界):本项目故意不用大事务包「写 file 表 + 加 used_storage」。原因:大事务会长时间持有锁、放大锁冲突、增加回滚成本。它选择「先插入 file 行(状态正常),再原子 UPDATE used_storage」两段式,配合行锁保证一致性。代价是「file 写成功但 used_storage 更新失败」时需补偿(本项目靠 RecalibrateStorage 兜底)。这是「最终一致 + 定时校准」对「强一致大事务」的典型取舍,面试时能讲清利弊很加分。

分布式锁的粒度与实现:锁 key 是 user:storage🔒{userID}——按用户粒度,而不是全局一把大锁。原因:不同用户的上传互相独立,锁到用户级才能最大化并发;若用全局锁,A 上传会卡住 B 上传,完全没必要。锁实现走 biz.Locker 接口(biz/storage.go:21),由 data/lock 包的具体 Redis 锁适配(红锁 / 带过期时间的 SET NX),biz 层不依赖具体实现,符合依赖倒置。

为什么有了行锁还要分布式锁? 这是两层不同维度的保护:MySQL 行锁解决「同一瞬间多条 SQL 写同一行」的原子性;分布式锁解决「应用层多步操作(扣减 + 清缓存 + 可能的预检查)整体串行」。而且本项目还有多级缓存(UsedStorage 会进 Redis / 本地 hot cache),如果不加应用层锁,可能出现「A 扣完清缓存、B 从旧缓存读到旧 used_storage 又去扣」的窗口。行锁管 DB 一致性,分布式锁管「DB + 缓存」组合操作的串行化。

RecalibrateStorage 为什么需要单独锁 storage:calibrate:lock 校准任务遍历所有活跃文件 SUM(size),期间如果正有上传在扣减 used_storage,两者并发相加可能算出错误 delta。所以校准拿一把独立的全局锁,和用户的扣减锁分开(用户锁是 per-user,校准锁是全局),保证「算偏差」和「日常扣减」不交错。校准后 delta != 0 才修正,避免无谓写。

一个更深的陷阱UpdateUsedStorageAtomicWHERE used_storage + ? <= total_storage 在 MySQL 里会触发行锁(因为要读后写同一行),但它是「短锁」——单条 UPDATE 瞬间完成即释放。而如果把逻辑写成「先 SELECTUPDATE」两条语句(哪怕在同事务里),在 REPEATABLE READ 下要用 SELECT ... FOR UPDATE 显式加锁,否则会出现「读到的旧值判断通过、更新时已被别人改过」的竞态。本项目把判断塞进 WHERE 让数据库一次性搞定,正是规避了这个坑。


十、N+1 查询:分享列表的性能地雷

Q12. ListShares 有什么性能隐患?怎么优化?

答:biz/share.go:185——查完分享列表后,循环里逐个 FindByID 去拿文件 / 文件夹信息

shares, nextCursor, err := uc.shareRepo.ListByUser(ctx, userID, cursor, pageSize)
result := make([]*ShareWithFileInfo, 0, len(shares))
for _, share := range shares {
    item := &ShareWithFileInfo{Share: share}
    if share.ItemType == "file" {
        file, err := uc.fileRepo.FindByID(ctx, share.ItemID)  // 每条分享 1 次查询
        if err == nil && file != nil {
            item.FileName = file.Name
            item.FileSize = file.Size
            item.FileType = file.Type
        }
    } else if share.ItemType == "folder" {
        folder, err := uc.folderRepo.FindByID(ctx, share.ItemID) // 每条分享 1 次查询
        // ...
    }
    result = append(result, item)
}

这就是经典的 N+1 问题:1 次查分享列表 + N 次查关联对象 = N+1 条 SQL。分享多的时候 DB 往返爆炸。

优化(批量 IN 查询):先收集所有 ItemID,按类型分两组,各用一次 WHERE id IN (?) 批量查出,再在内存里 map[uint64]*File 组装。把 N+1 降成 1+2(文件一批、文件夹一批)。

// 伪代码:先聚 ID,再批量查
fileIDs, folderIDs := []uint64{}, []uint64{}
for _, s := range shares {
    if s.ItemType == "file" { fileIDs = append(fileIDs, s.ItemID) } else { folderIDs = append(folderIDs, s.ItemID) }
}
fileMap := map[uint64]*File{}
if len(fileIDs) > 0 {
    files, _ := uc.fileRepo.FindByIDs(ctx, fileIDs)  // WHERE id IN (?)
    for _, f := range files { fileMap[f.ID] = f }
}
// 循环里直接 fileMap[share.ItemID] 取值,零额外 SQL
flowchart LR
    subgraph BAD["优化前 N+1"]
        B0["1 次 ListByUser"] --> B1["FindByID x N 次"]
    end
    subgraph GOOD["优化后 批量 IN"]
        G0["1 次 ListByUser"] --> G1["FindByIDs 文件 1 次"]
        G0 --> G2["FindByIDs 文件夹 1 次"]
        G1 --> G3["内存 map 组装"]
        G2 --> G3
    end

⚠️ FindByIDsWhere("id IN ?", ids)(见 data/file.go:56)时,若 ids 为空切片,某些 MySQL 驱动会生成 id IN () 语法错误。务必在调用前判断 len(ids) > 0 再查,空时直接返回空 map。

为什么 N+1 在 ORM 里特别隐蔽? 因为 for 循环里每次 FindByID 看起来只是「取个对象」,开发者容易忽略它背后是一次 DB 往返。ORM 的「惰性加载(lazy load)」更是 N+1 重灾区——访问 share.File.Name 时框架自动发一条 SQL,循环一百次就一百条。本项目的 ListShares 虽是手动循环,本质一样。识别 N+1 的通用心法:「先查列表、再在循环里逐条查关联」这个形状一出现,就要警惕。优化不只有批量 IN,还可用 预加载(GORM Preload——但 Preload 对「按类型分表关联(file / folder 两张表)」的场景不如手动批量 IN 灵活,所以本项目选了后者。

批量 IN 也有上限:MySQL 的 max_allowed_packet 和「IN 列表过长优化器退化」都要考虑。单次 IN 几千个 id 通常没问题,但若分享列表带出上万文件 id,应分批(每批 500~1000)查再合并。这也是为什么本项目 pageSize 默认 20——列表本身不大,IN 也随之很小,批量优化收益干净且无副作用。


自测题与动手练习

5 道自测题(闭卷自答)

  1. DSN 里的 parseTime=True 解决什么?如果不加,GORM 扫描 time.Time 字段会怎样?
  2. 本项目软删除为什么用 Status tinyint(0/1/2)而不是 GORM 的 DeletedAt?三态分别叫什么、怎么流转?
  3. files 表的复合索引 idx_user_parent_status_time 字段顺序是什么?列举两个能命中它、一个命中不了的典型查询。
  4. ListFilesHasMore 是怎么判断的?为什么 SQL 要 LIMIT pageSize+1 而不是 pageSize?为什么只缓存第一页?
  5. UpdateUsedStorageAtomic 如何防止并发超卖?WHERE used_storage + ? <= total_storage 这一行在并发下为什么安全?

3 个动手练习

  1. 复现超卖:写两个 goroutine 同时对同一 user_idAddUsedStorage(6GB),在 UpdateUsedStorageAtomic 里临时把 WHERE 条件去掉上限约束,观察 UsedStorage 是否超过 TotalStorage;恢复约束后再跑,验证被挡在 ErrStorageInsufficient
  2. EXPLAIN 验证索引:对 files 表执行 EXPLAIN SELECT * FROM files WHERE user_id=1 AND parent_id IS NULL AND status=0 ORDER BY created_at LIMIT 21;,确认 key 列是 idx_user_parent_status_timeExtraUsing index
  3. 消除 N+1:把 biz/share.go:185 的循环 FindByID 重构成批量 FindByIDs + map 组装,用 go test 或本地日志数 SQL 执行次数,验证从 N+1 降到 1+2。

面试速查卡(一表记住)

主题本项目做法面试一句话
DSNparseTime=True&loc=Local不开 parseTime 扫 time.Time 直接崩
连接池MaxOpen=200 / MaxIdle=50 / Life=30m / Idle=5m池是「并发吞吐的阀门 + 半开连接防腐」
建表AutoMigrate 仅开发用生产用 golang-migrate / Atlas 做版本化迁移
软删除Status 0/1/2 三态GORM DeletedAt 只能两态,表达不了回收站
复合索引5 个,user_id 放最左按查询维度建,吃最左前缀
并发注册先查 + 唯一索引兜底1062 → IsConflict → ErrUserAlreadyExists
分页keyset,cursor=created_at,LIMIT+1 判 HasMoreoffset 深翻页慢且不稳,keyset 不能随机跳页
存储扣减行锁原子 UPDATE + 分布式锁 + 校准WHERE used+?<=total 防超卖,不靠大事务
N+1ListShares 循环 FindByID批量 IN + map 组装降到常数级 SQL

上表建议背熟,面试官随便抽一个都能展开讲 3 分钟,就是这一节的分数。

本章小结

本文从 Kratos 云盘真实代码出发,串起了「数据库建模 → GORM → 分页」这条面试主线:

  • 初始化与连接池gorm.Open(mysql.Open(dsn)) + SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime,DSN 必带 parseTime=True&loc=LocalSkipDefaultTransactionPrepareStmt 是性能开关。
  • AutoMigrate 双刃剑:开发便利,生产应迁到 golang-migrate / Atlas 做版本化、可回滚迁移。
  • 建模:8 张核心表,文件 / 文件夹用 ParentID 自引用邻接表,file_folder_junctions 解决多对多归类。
  • 索引:5 个复合索引按查询维度落地,user_id 放最左吃最左前缀;唯一索引 Username 是并发注册的第二道防线,1062 → IsConflictErrUserAlreadyExists
  • 软删除状态机:自定义 Status 三态(0 正常 / 1 回收站 / 2 已删)表达中间态,每个查询必须显式带 status
  • 分页:offset 深翻页慢且不稳,keyset 用 created_at 游标 + LIMIT+1HasMore,只缓存第一页防过期。
  • 原子扣减:一条带行锁的 UPDATE used_storage = used_storage + ? WHERE used+? <= total 解决超卖,再叠分布式锁 + RecalibrateStorage 校准;用「行锁 + 原子 update」替代大事务是刻意的工程取舍。
  • N+1ListShares 循环 FindByID 是性能地雷,用批量 IN + map 组装降到常数级 SQL。

把这些点讲清、能画 ER 图 / 状态机 / 分页对比 / 扣减时序四张图,GORM 与数据库建模这一面基本就稳了。

复习提示:
  • 邻接表 vs 闭包表 vs CTE:自引用 ParentID 适合层级稳定的场景;频繁增删改子树时考虑转换 Materialized Path 或 Closure Table。
  • 深翻页性能陷阱:offset 越大 MySQL 扫描行数越多(即使只返回 20 条),keyset 分页用游标彻底解决这个问题。
  • 原子扣减 vs 大事务UPDATE used_storage = used_storage + ? WHERE used+? <= total 用单行行锁代替大事务,减少锁持有时间和死锁风险。
  • 软删除三态:正常(0) → 回收站(1) → 已删(2),用 Status 字段表达生命周期,比真删除更安全便于审计。
  • 下一篇讲文件存储——文件和数据库是云盘项目的两大核心资产,理解它们的存储模型对系统设计至关重要。
面试官
深度分页为什么不用 offset?keyset 分页的"游标"到底存的是什么?
候选人
问题本质:MySQL 的 LIMIT offset, size 在 offset 很大时会扫描并丢弃前面 offset 行,代价是 O(offset) 而非 O(size)。

关键集分页(Keyset Pagination)的核心思想:
① 记录上一页最后一条的排序字段值作为游标(如 created_at 和时间戳)
② 下一页查询改为 WHERE created_at < :cursor ORDER BY created_at DESC LIMIT size
③ 利用索引直接定位,时间复杂度 O(log n + size)

面试加分点:提到"游标必须是索引列",否则回退成全表扫描;多字段排序时用复合索引 (sort_col1, sort_col2)
为什么本项目用 LIMIT+1 判 HasMore:多查一条就能知道是否还有下一页,比 count(*) 查询更快且无精度问题。

最后一句临场建议:面试官问「你们怎么分页 / 怎么防超卖」时,别急着报方案,先反问「数据量和并发大概什么级别、要不要跳页、强一致还是最终一致」——能先厘清约束再给方案,比背八股更像一个真做过项目的工程师。本项目的很多设计(关默认事务、用行锁而非大事务、三态软删除)正是「先想清楚约束、再选最简可行方案」的结果,这也是高级工程师和初级工程师的分水岭。把这些权衡讲成你自己的判断,而不是标准答案,面试气场会完全不一样。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!