Gitea源码深度剖析-第4章 数据库层与 ORM 体系(xorm)
第 1 章 1.6.3 节留下了伏笔:Gitea 的数据库层不是一个裸的 xorm,而是在 models/db 包下的一整套封装------泛型 CRUD、经 context.Context 隐式传递的事务、ListOptions 分页、迁移体系与 fixtures 测试数据。本章把这套体系从连接串构造一路讲到 ER 关系图,所有代码均摘自本仓库源码,文件路径与函数名可逐一核对。
本章覆盖 8 个方面:ORM 选型与封装层(4.1)、连接与引擎构造(4.2)、多数据库与方言处理(4.3)、泛型 CRUD 层逐函数解析(4.4)、事务在 context 中的传递(4.5)、分页与查询辅助(4.6)、迁移体系(4.7)、数据模型组织与表设计惯例(4.8)、fixtures 测试数据体系(4.9)、事务与性能实践(4.10)、ER 级核心表关系图与关键结构体摘录(4.11)。
4.1 ORM 选型:为什么是 xorm,封装层在哪里
4.1.1 go.mod 里的唯一答案
打开 go.mod,与 ORM 相关的只有两行:
go
xorm.io/builder v0.3.13
xorm.io/xorm v1.4.3
而对整个仓库 grep gorm,结果是零匹配------没有 gorm 的 import,没有依赖,没有被注释掉的历史痕迹。Gitea 是一个从 Gogs fork 出来的项目(models/db/engine.go 文件头同时保留着 Copyright 2014 The Gogs Authors 与 Copyright 2018 The Gitea Authors 两行版权),Gogs 选择的就是 xorm,Gitea 继承了这一选择并持续重构封装层,而不是换 ORM。仓库内没有任何文档以"为什么不用 gorm"的问答形式给出答案,可验证的事实是:docs/guidelines-backend.md 的 Background 一节直接把前提写死------"database access goes through the XORM ORM",即 XORM 是后端开发的既定前提,而非每个 PR 可以重新讨论的选项。
选型的代价与收益在代码里是可见的。xorm 的 builder 包(xorm.io/builder v0.3.13)被 Gitea 大量直接使用------models/db/context.go 里所有泛型函数的条件参数类型都是 builder.Cond,这说明 Gitea 依赖的不是 xorm 的"自动迁移 + hook + 关联"全家桶,而是它提供的三块能力:结构体到 SQL 的映射(GonicMapper)、可组合的条件构造器(builder)、以及 Engine/Session 的连接与事务管理。第 1 章 1.1.2 节说过 Gitea 的技术选型"每一项选择都有明确的代价与收益",数据库层的取舍正是:接受 xorm 的全部行为(包括它的坑,见 4.5.4 的 Iterate 问题),换取从 Gogs 时代积累下来的全部模型代码与迁移历史(288 个迁移) 。换 ORM 意味着重写 models/ 下所有结构体 tag、重跑全部迁移验证,收益却不改变任何对外功能。
4.1.2 封装层的三层结构
Gitea 不让业务代码直接摸 *xorm.Engine。models/db/engine.go 定义了三个接口把 xorm 类型收窄成 Gitea 需要的子集:
go
// SQLSession represents a common interface for engine and session to execute SQLs
type SQLSession interface {
Count(...any) (int64, error)
Decr(column string, arg ...any) *xorm.Session
Delete(...any) (int64, error)
Truncate(...any) (int64, error)
Exec(...any) (sql.Result, error)
Find(any, ...any) error
FindAndCount(any, ...any) (int64, error)
Get(beans ...any) (bool, error)
ID(any) *xorm.Session
In(string, ...any) *xorm.Session
Incr(column string, arg ...any) *xorm.Session
Insert(...any) (int64, error)
Iterate(any, xorm.IterFunc) error
Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session
SQL(any, ...any) *xorm.Session
Where(any, ...any) *xorm.Session
// ... Asc/Desc/Limit/NoAutoTime/SumInt/Select/SetExpr/NotIn/OrderBy/
// Exist/Distinct/Query/Cols/Table/Context/QueryInterface/IsTableExist
}
// Engine represents a xorm engine
type Engine interface {
SQLSession
Sync(...any) error
Ping() error
}
// Session represents a xorm session interface
type Session interface {
Engine
And(query any, args ...any) *xorm.Session
Begin() error
Close() error
Commit() error
IsInTx() bool
Rollback() error
Engine() *xorm.Engine
}
// EngineMigration is a xorm engine interface used for migrations.
// It extends Engine with additional methods that are only available on the engine (not on the session)
// and are needed by the migration packages.
type EngineMigration interface {
Engine
Close() error
DB() *core.DB
DBMetas() ([]*schemas.Table, error)
Dialect() dialects.Dialect
DropTables(beans ...any) error
NewSession() *xorm.Session
SetMapper(mapper names.Mapper)
SyncWithOptions(opts xorm.SyncOptions, beans ...any) (*xorm.SyncResult, error)
TableInfo(bean any) (*schemas.Table, error)
TableName(bean any, includeSchema ...bool) string
}
文件末尾用编译期断言确认 xorm 类型满足这些接口:
go
var (
_ Engine = (*xorm.Engine)(nil)
_ Engine = (*xorm.Session)(nil)
_ Session = (*xorm.Session)(nil)
_ EngineMigration = (*xorm.Engine)(nil)
)
这层接口的价值在于依赖方向 与能力收窄 的双重作用。docs/guidelines-backend.md 规定的依赖方向是 cmd → routers → services → models → modules;泛型层函数签名里写的是 db.Engine 接口而非 *xorm.Engine,业务包因此不需要 import xorm.io/xorm,只需 import gitea.dev/models/db。EngineMigration 则是刻意把 SyncWithOptions、DBMetas、DropTables 这些"只有迁移和安装才需要"的能力单独隔离出来------4.7 节会看到 modelmigration 只拿到 EngineMigration,普通业务代码拿不到 Sync(Engine 接口里保留的 Sync 主要服务于单元测试建表,见 4.9.3)。
注意 Engine 与 Session 接口的差异设计:Session 额外暴露 Begin/Commit/Close/IsInTx/Rollback,这些是事务操作,只有事务管理代码(4.5 节的 WithTx/TxContext)需要;业务函数拿到的 Engine 类型无法误调 Commit。编译器在这里替你守住了"业务代码不能自己提交事务"的规矩。
4.1.3 模型注册:RegisterModel 与 registeredInitFuncs
每个模型包用 init() 向全局注册自己的表:
go
func init() {
db.RegisterModel(new(Repository))
}
models/db/engine.go 中的注册实现:
go
var (
xormEngine *xorm.Engine
registeredModels []any
registeredInitFuncs []func() error
)
// RegisterModel registers model, if initFuncs provided, it will be invoked after data model sync
func RegisterModel(bean any, initFunc ...func() error) {
registeredModels = append(registeredModels, bean)
if len(registeredInitFuncs) > 0 && initFunc[0] != nil {
registeredInitFuncs = append(registeredInitFuncs, initFunc[0])
}
}
这里有一个值得源码级观察的细节:注册条件是 len(registeredInitFuncs) > 0 && initFunc[0] != nil------registeredInitFuncs 初始为 nil,所以第一个带 initFunc 的注册永远不会把 initFunc 放进队列 (len > 0 为 false);而且全仓库 115 处 RegisterModel 调用全部只传 bean、从不传第二个参数,所以 registeredInitFuncs 实际恒为空切片,InitEngineWithMigration 末尾那段 for _, initFunc := range registeredInitFuncs 循环(4.7.4 节)目前是空转的。这属于"看起来有扩展点、实际无人使用"的历史遗留,理解它有助于读代码时不被误导。
registeredModels 则是实打实的核心数据:SyncAllTables、NamesToBean、单元测试建表都依赖它。注册表要求所有模型有主键------models/db/engine_test.go 的 TestPrimaryKeys 遍历 registeredModels 逐一断言,注释里引用了 issue #21086 与 #16802 作为"必须有主键"的历史教训。
ResetModels 把两个切片清空,供测试隔离使用:
go
func ResetModels() {
registeredModels, registeredInitFuncs = nil, nil
}
4.1.4 权威约定:docs/guidelines-backend.md 的三段数据库规矩
后端开发指南中有两段直接约束本章内容,值得完整引用。第一段是事务约定:
Database transactions
Operations that must roll back together should run inside
db.WithTx()(ordb.WithTx2()when a value must be returned), defined inmodels/db/context.go. Functions that participate in a transaction take acontext.Contextas their first parameter so the transaction can be propagated.PostgreSQL, MySQL and MSSQL (via
READ_COMMITTED_SNAPSHOT) read the last committed row version, so reads never wait for writers. Guard read-then-write logic with a conditionalUPDATEor a lock.
翻译成实现要点就是三条:同生共死的操作用 WithTx/WithTx2 包;参与事务的函数一律以 ctx context.Context 打头(这是 4.4 节所有泛型函数签名的由来);由于三种数据库的读不阻塞读(4.3 节解释各自的快照机制),读-改-写序列必须用条件 UPDATE 或锁保护 ------4.10.4 节的 claimJobForRunner 就是这条规矩的标准实现。
第二段是 XORM 的坑:
XORM gotchas
- Never call
x.Update(exemplar)without an explicitWHEREclause --- it updates every row in the table.- Partial table migrations must use
SyncWithOptions(IgnoreDrop...)rather than a plainSync.- When inserting rows with preset IDs, MSSQL requires
SET IDENTITY_INSERTto be enabled and PostgreSQL requires the sequence to be updated afterwards.
这三条在本章都会找到对应代码:第一条对应 4.4 节 DeleteByBean/Update 的使用规范;第二条对应 4.7 节 modelmigration/v29/v356.go 的 SyncWithOptions{IgnoreDropIndices, IgnoreConstrains};第三条对应 4.9.3 节 fixtures 加载时的 MSSQL IDENTITY_INSERT 处理,以及 4.7.5 节 models/db/sequence.go 的 FixBadSequences。
4.2 连接与引擎构造
4.2.1 从 app.ini 到连接串:models/db/conn.go
配置解析在 modules/setting/database.go 的 loadDBSetting,全部默认值如下表(左列为配置键,中列为默认值,右列为代码位置):
配置键([database] 段) |
默认值 | 说明 |
|---|---|---|
DB_TYPE |
无(必填) | mysql / postgres / mssql / sqlite3 |
SSL_MODE |
disable |
Postgres sslmode,MySQL 转换为 tls 参数 |
PATH |
AppDataPath/gitea.db |
SQLite 文件路径 |
SQLITE_TIMEOUT |
20000(ms) | 低于 5000 会被强制改回 DefaultSQLiteBusyTimeout |
SQLITE_JOURNAL_MODE |
空 | 透传给驱动 pragma |
MAX_IDLE_CONNS |
2 | 连接池空闲连接数 |
MAX_OPEN_CONNS |
0(不限) | 连接池最大连接数 |
CONN_MAX_LIFETIME |
MySQL 3s,其他 0 | 连接最大存活时间 |
ITERATE_BUFFER_SIZE |
50 | keyset 分页迭代批量(4.6.5 节) |
LOG_SQL |
false | 是否打印 SQL |
DB_RETRIES |
10 | 启动连库重试次数 |
DB_RETRY_BACKOFF |
3s | 重试退避 |
AUTO_MIGRATION |
true | 启动时自动迁移 |
SLOW_QUERY_THRESHOLD |
5s | 慢查询阈值 |
源码摘录(modules/setting/database.go):
go
const DefaultSQLiteBusyTimeout = 20 * 1000
var (
SupportedDatabaseTypes = []string{"mysql", "postgres", "mssql"}
DatabaseTypeNames = map[string]string{"mysql": "MySQL", "postgres": "PostgreSQL", "mssql": "MSSQL", DatabaseTypeSQLite3: "SQLite3"}
Database = struct {
Type DatabaseType
// ... Host/Name/User/Passwd/Schema/SSLMode/Path
SQLiteBusyTimeout int
SQLiteJournalMode string
LogSQL bool
CharsetCollation string
DBConnectRetries int
DBConnectBackoff time.Duration
MaxIdleConns int
MaxOpenConns int
ConnMaxLifetime time.Duration
IterateBufferSize int
AutoMigration bool
SlowQueryThreshold time.Duration
}{
IterateBufferSize: 50,
SlowQueryThreshold: 5 * time.Second,
}
)
注意 SupportedDatabaseTypes 初始只有三种,SQLite 是"追加"进去的------这与 4.3.3 节 SQLite 驱动的 build tag 注册机制直接呼应:只有编译进对应驱动,sqlite3 才会出现在可用类型列表里。
连接串构造在 models/db/conn.go(2026 年版权的新文件,说明这块近期被重构过):
go
func ConnStr(opts ConnOptions) (string, string, error) {
switch {
case opts.Type.IsMySQL():
// use unix socket or tcp socket
connType := util.Iif(strings.HasPrefix(opts.Host, "/"), "unix", "tcp")
// allow (Postgres-inspired) default value to work in MySQL
tls := util.Iif(opts.SSLMode == "disable", "false", opts.SSLMode)
// in case the database name is a partial connection string which contains "?" parameters
paramSep := util.Iif(strings.Contains(opts.Database, "?"), "&", "?")
connStr := fmt.Sprintf("%s:%s@%s(%s)/%s%sparseTime=true&tls=%s", opts.User, opts.Passwd, connType, opts.Host, opts.Database, paramSep, tls)
return "mysql", connStr, nil
case opts.Type.IsPostgreSQL():
connStr := makePgSQLConnStr(opts.Host, opts.User, opts.Passwd, opts.Database, opts.SSLMode)
driver := util.Iif(opts.Schema == "", "postgres", sqlDriverPostgresSchema)
registerPostgresSchemaDriver()
return driver, connStr, nil
case opts.Type.IsMSSQL():
host, port := parseMSSQLHostPort(opts.Host)
connStr := fmt.Sprintf("server=%s; port=%s; user id=%s; password=%s;", host, port, opts.User, opts.Passwd)
if opts.Database != "" {
connStr += "; database=" + opts.Database
}
return "mssql", connStr, nil
case opts.Type.IsSQLite3():
if opts.SQLitePath == "" {
return "", "", errors.New("sqlite3 database path cannot be empty")
}
if err := os.MkdirAll(filepath.Dir(opts.SQLitePath), os.ModePerm); err != nil {
return "", "", fmt.Errorf("failed to create directories: %w", err)
}
return makeSQLiteConnStr(SQLiteConnStrOptions{
FilePath: opts.SQLitePath,
JournalMode: opts.SQLiteJournalMode,
BusyTimeout: opts.SQLiteBusyTimeout,
})
}
return "", "", fmt.Errorf("unknown database type: %s", opts.Type)
}
几个容易被忽略但重要的分支细节:
- MySQL 分支 把宿主机以
/开头识别为 unix socket(connType := util.Iif(strings.HasPrefix(opts.Host, "/"), "unix", "tcp")),把 Postgres 风格的SSL_MODE=disable翻译成 MySQL 驱动能懂的tls=false,还容忍NAME里带?参数直接拼进连接串。这三处都是为了让同一套配置习惯跨数据库成立。 - Postgres 分支 根据
SCHEMA是否为空选择 driver 名:空 schema 用标准postgres,配置了 schema 则用postgresschema这个自定义 driver(models/db/driver_postgresschema.go,连接后执行set_config search_path),从而支持同实例多 schema 部署。 - MSSQL 分支 的
parseMSSQLHostPort默认端口是"0",源码注释坦承:"the default port '0' might be related to MSSQL's dynamic port, maybe it should be double-confirmed in the future"------Gitea 不猜 1433,而是把动态端口行为留给驱动处理。 - SQLite 分支 在构造连接串前先
os.MkdirAll创建目录------数据库文件所在的目录必须存在,这是安装向导(routers/install/install.go)能直接写路径的前提。
Postgres 连接串用 url.URL 构造(makePgSQLConnStr),正确处理 IPv6 方括号与 unix socket(strings.HasPrefix(host, "/") 时把 host 挪进 query 参数):
go
func parsePgSQLHostPort(info string) (host, port string) {
if h, p, err := net.SplitHostPort(info); err == nil {
host, port = h, p
} else {
// treat the "info" as "host", if it's an IPv6 address, remove the wrapper
host = info
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = host[1 : len(host)-1]
}
}
// set fallback values
if host == "" {
host = "127.0.0.1"
}
if port == "" {
port = "5432"
}
return host, port
}
4.2.2 newXORMEngine:创建引擎的最小步骤
models/db/engine_init.go:
go
// newXORMEngine returns a new XORM engine from the configuration
func newXORMEngine() (*xorm.Engine, error) {
connOpts := GlobalConnOptions()
driver, connStr, err := ConnStr(connOpts)
if err != nil {
return nil, err
}
engine, err := xorm.NewEngine(driver, connStr)
if err != nil {
return nil, err
}
switch {
case connOpts.Type.IsMySQL():
engine.Dialect().SetParams(map[string]string{"rowFormat": "DYNAMIC"})
case connOpts.Type.IsMSSQL():
engine.Dialect().SetParams(map[string]string{"DEFAULT_VARCHAR": "nvarchar"})
}
engine.SetSchema(connOpts.Schema)
return engine, nil
}
两处方言参数注入:MySQL 的 InnoDB ROW_FORMAT=DYNAMIC(大字段行用 off-page 存储,避免 VARCHAR 大索引限制);MSSQL 的 DEFAULT_VARCHAR=nvarchar(Gitea 内部统一用 nvarchar 存储字符串,避免 Unicode 截断)。SetSchema 对应 Postgres 的 schema、MSSQL 的 schema,其他数据库为空字符串。
文件顶部的 init() 注册 GonicMapper 的 lint 白名单:
go
func init() {
gonicNames := []string{"SSL", "UID"}
for _, name := range gonicNames {
names.LintGonicMapper[name] = true
}
}
names.GonicMapper{} 是 xorm 内置的"Go 驼峰转数据库下划线"映射器,它对 URL、URI 这类全大写缩写有内置列表;SSL、UID 是 Gitea 用到但 xorm 默认不认识的两个缩写(Star.UID、各种 SSLMode 字段),不注册就会被错误拆成 u_i_d 之类的列名。
4.2.3 InitEngine:日志、连接池、隔离级别、慢查询钩子
go
// InitEngine initializes the xorm.Engine and sets it as XORM's default context
func InitEngine(ctx context.Context) error {
xe, err := newXORMEngine()
if err != nil {
return fmt.Errorf("failed to init database engine: %w", err)
}
xe.SetMapper(names.GonicMapper{})
// WARNING: for serv command, MUST remove the output to os.stdout,
// so use log file to instead print to stdout.
xe.SetLogger(NewXORMLogger(setting.Database.LogSQL))
xe.ShowSQL(setting.Database.LogSQL)
xe.SetMaxOpenConns(setting.Database.MaxOpenConns)
xe.SetMaxIdleConns(setting.Database.MaxIdleConns)
xe.SetConnMaxLifetime(setting.Database.ConnMaxLifetime)
if setting.Database.Type.IsMySQL() {
// like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors
xe.SetDefaultTxOptions(&sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
if setting.Database.SlowQueryThreshold > 0 {
xe.AddHook(&EngineHook{
Threshold: setting.Database.SlowQueryThreshold,
Logger: log.GetLogger("xorm"),
})
}
SetDefaultEngine(ctx, xe)
return nil
}
逐项解释:
SetMapper(names.GonicMapper{}):全局列名映射。User.LowerName→lower_name,Team.AccessMode→ 除非被 struct tag 覆写(4.8.3 节的xorm:"'authorize'")。SetLogger(NewXORMLogger(...)):xorm 自带的 logger 只会写 stdout,而 Gitea 的serv/hook子进程必须把输出写进日志文件(源码注释的 WARNING 就是这个意思)。models/db/log.go的XORMLogBridge把 xorm 的Debug/Info/Warn/Error全部桥接进modules/log的xormlogger,并用stackLevel = 8保证调用栈回溯到真正发起查询的业务函数,而不是桥接层自己。- 连接池三参数 直通
database/sql的SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime。默认值MaxIdleConns=2、MaxOpenConns=0(不限)、MySQLConnMaxLifetime=3s(其他库 0)。MySQL 单独给 3 秒连接存活是常见的负载均衡/云数据库实践:定期回收长连接避免被中间设备掐断后出现"broken pipe on first query"。 - MySQL 隔离级别 :
sql.LevelReadCommitted。注释写明这是"like PostgreSQL and MSSQL, avoids MariaDB snapshot isolation errors"------MariaDB 的 REPEATABLE READ 快照隔离在某些 gap lock 场景会报错(如 "Cannot execute statement in a READ ONLY transaction" 或 deadlock 误报),统一降到 ReadCommitted 后行为可预期。这也正是 guidelines 那句"PostgreSQL, MySQL and MSSQL (via READ_COMMITTED_SNAPSHOT) read the last committed row version"的 MySQL 侧来源。 - 慢查询钩子 :
SlowQueryThreshold默认 5s,大于 0 就挂EngineHook(4.2.5 节详解)。
SetDefaultEngine/UnsetDefaultEngine 操作全局 xormEngine:
go
func SetDefaultEngine(ctx context.Context, eng *xorm.Engine) {
xormEngine = eng
xormEngine.SetDefaultContext(ctx)
}
// UnsetDefaultEngine closes and unsets the default engine
// We hope the SetDefaultEngine and UnsetDefaultEngine can be paired, but it's impossible now,
// there are many calls to InitEngine -> SetDefaultEngine directly to overwrite the `xormEngine` and `xormContext` without close
// Global database engine related functions are all racy and there is no graceful close right now.
func UnsetDefaultEngine() {
if xormEngine != nil {
_ = xormEngine.Close()
xormEngine = nil
}
}
这段自嘲式注释是 Gitea 官方对自身技术债的诚实记录:全局引擎的配对管理做不到,存在竞态,也没有优雅关闭。阅读 models/db 时要记住:xormEngine 是一个进程级可变全局变量 ,单元测试通过 SetDefaultEngine 换成测试引擎(4.9 节),安装流程也会重建它。
4.2.4 InitEngineWithMigration:完整初始化序列
go
// InitEngineWithMigration initializes a new xorm.Engine and sets it as the XORM's default context
// This function must never call .Sync() if the provided migration function fails.
// When called from the "doctor" command, the migration function is a version check
// that prevents the doctor from fixing anything in the database if the migration level
// is different from the expected value.
func InitEngineWithMigration(ctx context.Context, migrateFunc func(context.Context, EngineMigration) error) (err error) {
if err = InitEngine(ctx); err != nil {
return err
}
if err = xormEngine.Ping(); err != nil {
return err
}
preprocessDatabaseCollation(xormEngine)
if setting.Database.Type.IsMSSQL() {
enableMSSQLReadCommittedSnapshot(ctx, xormEngine)
}
// We have to run migrateFunc here in case the user is re-running installation on a previously created DB.
// If we do not then table schemas will be changed and there will be conflicts when the migrations run properly.
if err = migrateFunc(ctx, xormEngine); err != nil {
return fmt.Errorf("migrate: %w", err)
}
if err = SyncAllTables(); err != nil {
return fmt.Errorf("sync database struct error: %w", err)
}
for _, initFunc := range registeredInitFuncs {
if err := initFunc(); err != nil {
return fmt.Errorf("initFunc failed: %w", err)
}
}
return nil
}
执行顺序是:建引擎 → Ping → collation 预处理 → MSSQL 快照隔离开启 → migrateFunc(版本化迁移)→ SyncAllTables(结构体同步)→ registeredInitFuncs(目前恒为空,见 4.1.3)。
三个关键设计动机,全部来自注释本身:
- "must never call .Sync() if the provided migration function fails" ------迁移失败就不许
Sync。原因是Sync会按当前结构体定义修改表结构;如果旧版本数据还没迁移完就按新结构体同步,会制造出半新半旧的表,之后正式迁移会撞上冲突。所以顺序必须是 migrate 在前、Sync 在后。这条约束也解释了doctor命令复用这个函数时的行为:doctor 传入的migrateFunc只做版本检查,版本不对就直接返回错误,于是Sync(以及后续所有initFunc)都不会执行,doctor 不会在错误版本的数据库上做任何"修复"。 - collation 预处理必须在迁移前 (4.3.2 节详解
preprocessDatabaseCollation):字符集不一致会影响建表与迁移中的排序、唯一索引行为。 - MSSQL 的
enableMSSQLReadCommittedSnapshot必须在任何查询前执行:
go
// enableMSSQLReadCommittedSnapshot stops MSSQL reads waiting on writers, like PostgreSQL and MySQL
func enableMSSQLReadCommittedSnapshot(ctx context.Context, engine EngineMigration) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) // ALTER waits for all other connections to close
defer cancel()
if _, err := engine.Context(ctx).Exec("IF (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE database_id = DB_ID()) = 0 ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON"); err != nil {
log.Error("Unable to set READ_COMMITTED_SNAPSHOT=ON: %v", err)
}
}
ALTER DATABASE ... SET READ_COMMITTED_SNAPSHOT ON 要求所有其他连接断开,所以给 5 秒超时、失败只记 Error 不中断启动。开启后 MSSQL 的 READ_COMMITTED 才真正基于行版本快照------读不阻塞写、写不阻塞读,这正是 guidelines 里"Gitea 假设读不等写"的 MSSQL 侧保证。
调用链方面,routers/init.go:137 的 mustInitCtx(ctx, common.InitDBEngine) 是 Web 服务的入口,common.InitDBEngine(routers/common/db.go)负责重试:
go
// InitDBEngine In case of problems connecting to DB, retry connection. Eg, PGSQL in Docker Container on Synology
func InitDBEngine(ctx context.Context) (err error) {
log.Info("Beginning ORM engine initialization.")
for i := 0; i < setting.Database.DBConnectRetries; i++ {
select {
case <-ctx.Done():
return errors.New("Aborted due to shutdown:\nin retry ORM engine initialization")
default:
}
log.Info("ORM engine initialization attempt #%d/%d...", i+1, setting.Database.DBConnectRetries)
if err = db.InitEngineWithMigration(ctx, migrateWithSetting); err == nil {
break
} else if i == setting.Database.DBConnectRetries-1 {
return err
}
log.Error("ORM engine initialization attempt #%d/%d failed. Error: %v", i+1, setting.Database.DBConnectRetries, err)
log.Info("Backing off for %d seconds", int64(setting.Database.DBConnectBackoff/time.Second))
time.Sleep(setting.Database.DBConnectBackoff)
}
config.SetDynGetter(system_model.NewDatabaseDynKeyGetter())
return nil
}
默认 10 次、每次退避 3 秒,注释点名场景是"PGSQL in Docker Container on Synology"------容器编排里数据库比应用晚就绪。重试循环内先检查 ctx.Done() 保证进程关闭时能中止。成功后设置 config.SetDynGetter:Gitea 的动态配置(如 DISABLE_REGISTRATION)改存数据库 system_setting 表,这里把配置读取器切换到数据库实现。
migrateWithSetting 决定迁移策略:
go
func migrateWithSetting(ctx context.Context, x db.EngineMigration) error {
if setting.Database.AutoMigration {
return versioned_migration.Migrate(ctx, x)
}
if current, err := modelmigration.GetCurrentDBVersion(x); err != nil {
return err
} else if current < 0 {
// execute migrations when the database isn't initialized even if AutoMigration is false
return versioned_migration.Migrate(ctx, x)
} else if expected := modelmigration.ExpectedDBVersion(); current != expected {
log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+
`You can set "database.AUTO_MIGRATION" to true or migrate manually by running "gitea [--config /path/to/app.ini] migrate"`, current, expected)
}
return nil
}
三种分支:AUTO_MIGRATION=true(默认)正常跑迁移;数据库未初始化(current < 0,Version 表里没有记录)时即使关了自动迁移也要跑(否则装不上);已初始化且版本落后时直接 log.Fatal------宁可拒绝启动也不让新代码连旧结构的库 ,提示语给出两条路:打开 AUTO_MIGRATION 或手工执行 gitea migrate。
4.2.5 慢查询与 tracing:models/db/engine_hook.go
xorm 的 contexts.Hook 机制允许在每条 SQL 前后注入逻辑。Gitea 的 EngineHook 同时承担慢查询日志 与 gtprof span 采集两个职责:
go
type EngineHook struct {
Threshold time.Duration
Logger log.Logger
}
var _ contexts.Hook = (*EngineHook)(nil)
func (*EngineHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
if c.Ctx.Value(ContextKeyTestFixtures) != nil {
return c.Ctx, nil
}
ctx, _ := gtprof.GetTracer().Start(c.Ctx, gtprof.TraceSpanDatabase)
return ctx, nil
}
func (h *EngineHook) AfterProcess(c *contexts.ContextHook) error {
if c.Ctx.Value(ContextKeyTestFixtures) != nil {
return nil
}
span := gtprof.GetContextSpan(c.Ctx)
if span != nil {
// Do not record SQL parameters here:
// * It shouldn't expose the parameters because they contain sensitive information, end users need to report the trace details safely.
// * Some parameters contain quite long texts, waste memory and are difficult to display.
span.SetAttributeString(gtprof.TraceAttrDbSQL, c.SQL)
span.End()
} else {
setting.PanicInDevOrTesting("span in database engine hook is nil")
}
if c.ExecuteTime >= h.Threshold {
// 8 is the amount of skips passed to runtime.Caller, so that in the log the correct function
// is being displayed (the function that ultimately wants to execute the query in the code)
// instead of the function of the slow query hook being called.
h.Logger.Log(8, &log.Event{Level: log.WARN}, "[Slow SQL Query] %s %v - %v", c.SQL, c.Args, c.ExecuteTime)
}
return nil
}
三个设计点:
- fixtures 跳过 :
ContextKeyTestFixtures是contextKey{"test-fixtures"}(models/db/context.go),单测批量灌 fixtures 时几千条 INSERT 不该各开一个 span、也不该被记成慢查询,所以两个钩子入口都先判它(4.9.3 节会看到这个 key 是谁设置的)。 - span 只记 SQL 不记参数 :注释给出了两条理由------参数含敏感信息(最终用户上报 trace 时不能泄漏)、参数可能超长(浪费内存且难展示)。这是一个安全与可观测性的权衡,慢查询日志反而保留
c.Args(因为写进了本地日志文件,不出服务器)。 Log(8, ...)的 skip=8 :modules/log的Log(skip, ...)需要跳过固定层数的内部调用帧,让日志里显示的文件行号是真正发起查询的业务代码,而不是 hook 自己。models/db/log.go里stackLevel = 8是同一原理的常量化。
4.3 多数据库支持与方言处理
Gitea 同时支持 SQLite3、MySQL、PostgreSQL、MSSQL 四种数据库。方言差异没有被藏进 xorm 里了事,而是在 models/db 中以显式分支暴露出来------本节按"共享层做了什么"和"各库的特殊处理"两条线梳理。
4.3.1 四种数据库的差异总表
| 维度 | SQLite3 | MySQL/MariaDB | PostgreSQL | MSSQL |
|---|---|---|---|---|
| 驱动 | modernc(默认,无 CGO)/ mattn(build tag) | go-sql-driver/mysql | lib/pq | go-mssqldb |
| 连接串构造 | makeSQLiteConnStr(pragma 参数) |
Host 以 / 开头转 unix socket |
url.URL + sslmode + schema driver |
parseMSSQLHostPort |
| 事务隔离 | 由驱动 _txlock=immediate 保证写锁 |
显式 LevelReadCommitted |
默认 ReadCommitted | READ_COMMITTED_SNAPSHOT ON |
| 大小写不敏感 LIKE | util.ToLowerASCII(只折 ASCII) |
strings.ToLower + LOWER(col) |
同左 | 同左 |
| 自增 ID 插入 | 天然支持 | 天然支持 | 需重置序列 | SET IDENTITY_INSERT |
| 资源序号 upsert | UPDATE+INSERT fallback | ON DUPLICATE KEY |
ON CONFLICT ... RETURNING |
MERGE ... HOLDLOCK |
| 专用连接参数 | busy_timeout、journal_mode |
rowFormat=DYNAMIC |
search_path schema |
DEFAULT_VARCHAR=nvarchar |
下面挑源码中最能说明"为什么"的几处展开。
4.3.2 字符集与 collation:preprocessDatabaseCollation
MySQL/MSSQL 的排序规则(collation)决定了比较与唯一索引的行为。Gitea 偏好大小写敏感 的 collation(用户注册名、仓库名的唯一性必须区分大小写场景下的语义)。models/db/collation.go 的 CheckCollations 检测数据库与各列的 collation:
go
func CheckCollations(x EngineMigration) (*CheckCollationsResult, error) {
dbTables, err := x.DBMetas()
if err != nil {
return nil, err
}
res := &CheckCollationsResult{
ExistingTableNumber: len(dbTables),
CollationEquals: func(a, b string) bool { return a == b },
}
var candidateCollations []string
if x.Dialect().URI().DBType == schemas.MYSQL {
_, err = x.SQL("SELECT DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?", setting.Database.Name).Get(&res.DatabaseCollation)
if err != nil {
return nil, err
}
res.IsCollationCaseSensitive = func(s string) bool {
return s == "utf8mb4_bin" || strings.HasSuffix(s, "_as_cs")
}
candidateCollations = []string{"utf8mb4_0900_as_cs", "uca1400_as_cs", "utf8mb4_bin"}
res.AvailableCollation, err = findAvailableCollationsMySQL(x)
// ...
} else if x.Dialect().URI().DBType == schemas.MSSQL {
if _, err = x.SQL("SELECT DATABASEPROPERTYEX(DB_NAME(), 'Collation')").Get(&res.DatabaseCollation); err != nil {
return nil, err
}
res.IsCollationCaseSensitive = func(s string) bool {
return strings.HasSuffix(s, "_CS_AS")
}
candidateCollations = []string{"Latin1_General_CS_AS"}
res.AvailableCollation, err = findAvailableCollationsMSSQL(x)
// ...
} else {
return nil, nil //nolint:nilnil // return nil for unsupported database types
}
// ...候选集选择、逐列比对...
}
MySQL 侧的候选集 utf8mb4_0900_as_cs(8.0 默认)、uca1400_as_cs(MariaDB 10.6+)、utf8mb4_bin(最保守兜底),findAvailableCollationsMySQL 用 SHOW COLLATION WHERE (Collation = 'utf8mb4_bin') OR (Collation LIKE '%\_as\_cs%') 查可用项;MSSQL 侧查 sys.fn_helpcollations() 里 %[_]CS[_]AS%。Postgres/SQLite 不做检查(Postgres 的 C/en_US.UTF-8 与 SQLite 的 BINARY 默认都敏感),直接返回 nil。
preprocessDatabaseCollation(在 4.2.4 的初始化序列中被调用)的行为分三步,注释都写得很清楚:
go
// preprocessDatabaseCollation checks database & table column collation, and alter the database collation if needed
func preprocessDatabaseCollation(x EngineMigration) {
r, err := CheckCollations(x)
if err != nil {
log.Error("Failed to check database collation: %v", err)
}
if r == nil {
return // no check result means the database doesn't need to do such check/process (the moment ....)
}
// try to alter database collation to expected if the database is empty, it might fail in some cases (and it isn't necessary to succeed)
// at the moment, there is no "altering" solution for MSSQL, site admin should manually change the database collation
if !r.CollationEquals(r.DatabaseCollation, r.ExpectedCollation) && r.ExistingTableNumber == 0 {
if err = alterDatabaseCollation(x, r.ExpectedCollation); err != nil {
log.Error("Failed to change database collation to %q: %v", r.ExpectedCollation, err)
} else {
_, _ = x.Exec("SELECT 1") // after "altering", MSSQL's session becomes invalid, so make a simple query to "refresh" the session
// ...重新 CheckCollations...
}
}
// check column collation, and show warning/error to end users -- no need to fatal, do not block the startup
if !r.IsCollationCaseSensitive(r.DatabaseCollation) {
log.Warn("Current database is using a case-insensitive collation %q, although Gitea could work with it, there might be some rare cases which don't work as expected.", r.DatabaseCollation)
}
if len(r.InconsistentCollationColumns) > 0 {
log.Error("There are %d table columns using inconsistent collation, they should use %q. Please go to admin panel Self Check page", len(r.InconsistentCollationColumns), r.ExpectedCollation)
}
}
设计动机值得强调:只在空库时自动 ALTER collation (有表时改库级 collation 不影响已建列,还会带来不可控风险),失败也只记日志不阻断启动;对已有库只做"警告/报错并指引到 Self Check 页面",因为 collation 修复必须由站点管理员权衡。alterDatabaseCollation 里还藏着一个 MSSQL 特性注释:ALTER 之后会话失效,必须补一条 SELECT 1 刷新会话。
4.3.3 SQLite 驱动:modernc 默认与 mattn 可选
models/db/driver_sqlite_modernc.go(build tag //go:build !sqlite_mattn):
go
// modernc driver is chosen as the default one (compared to mattn, ncruces)
// * mattn was used as default, but it requires CGO
// * the CI times are almost the same for these three (race detector must be disabled)
// * modernc increases the binary size about 2MB, ncruces increases about 7MB
// * compiling time: modernc is slightly slower than mattn, ncruces is the slowest
func init() {
// this driver contains huge amount of Golang code, so it is much slower when "-race" check is enabled.
registerSQLiteConnStrMaker(makeSQLiteConnStrModerncCCGO)
sql.Register(sqlDriverSQLite3, &sqlite.Driver{})
}
func makeSQLiteConnStrModerncCCGO(opts SQLiteConnStrOptions) (string, string, error) {
var params []string
// TODO: there is a changed behavior from mattn driver:
// * mattn driver can wait for pretty long time for concurrent accesses (not limited by the busy timeout)
// * but other drivers will report something like "database is locked (5) (SQLITE_BUSY)" if the timeout is reached
// Maybe we need to relax the busy timeout to a reasonable long time in the future
params = append(params, fmt.Sprintf("_pragma=busy_timeout(%d)", opts.BusyTimeout))
params = append(params, "_txlock=immediate")
if opts.JournalMode != "" {
params = append(params, fmt.Sprintf("_pragma=journal_mode(%s)", opts.JournalMode))
}
connStr := fmt.Sprintf("file:%s?%s", opts.FilePath, strings.Join(params, "&"))
return sqlDriverSQLite3, connStr, nil
}
选择逻辑是一份写在注释里的实测报告:mattn 需要 CGO、modernc 只增约 2MB 二进制、ncruces 增 7MB 且编译最慢,CI 时间三者接近,所以默认 modernc,sqlite_mattn build tag 可切回(models/db/driver_sqlite_mattn.go)。
注册机制在 conn.go:
go
var makeSQLiteConnStr = func(opts SQLiteConnStrOptions) (string, string, error) {
return "", "", errors.New(`this Gitea binary was not built with SQLite3 support, get an official release or rebuild with correct "-tags"`)
}
func registerSQLiteConnStrMaker(fn func(opts SQLiteConnStrOptions) (string, string, error)) {
if slices.Contains(setting.SupportedDatabaseTypes, setting.DatabaseTypeSQLite3) {
panic("another sqlite3 driver has been registered")
}
setting.SupportedDatabaseTypes = append(setting.SupportedDatabaseTypes, setting.DatabaseTypeSQLite3)
makeSQLiteConnStr = fn
}
默认的 makeSQLiteConnStr 是一个报错函数------没有编译任何 SQLite 驱动的二进制,会明确提示"rebuild with correct -tags"而不是运行时 panic ;驱动文件的 init() 通过 registerSQLiteConnStrMaker 换掉它并把 sqlite3 追加进 SupportedDatabaseTypes(这正是 4.2.1 节说 SupportedDatabaseTypes 初始只有三种的原因),重复注册直接 panic(防止错误的 build 组合)。
连接参数上,_txlock=immediate 让事务开始就拿写锁,避免 SQLite 典型的"升级读锁到写锁时 SQLITE_BUSY"死锁;busy_timeout 默认 20 秒(配置项 SQLITE_TIMEOUT 低于 5000ms 会被强制改回默认,注释解释"the default value was 500 (0.5s), to avoid breaking existing users, make sure the timeout is long enough (at least, 5 seconds)")。TODO 注释还记录了 modernc 与 mattn 的行为差异:mattn 等锁可以超过 busy timeout 很久,而 modernc 到点即报 SQLITE_BUSY------Gitea 选择用更长的默认超时兜底。
4.3.4 大小写不敏感查询:models/db/common.go
go
// BuildCaseInsensitiveLike returns a case-insensitive LIKE condition for the given key and value.
// Cast the search value and the database column value to the same case for case-insensitive matching.
// * SQLite: only cast ASCII chars because it doesn't handle complete Unicode case folding
// * Other databases: use database's string function, assuming that they are able to handle complete Unicode case folding correctly
func BuildCaseInsensitiveLike(key, value string) builder.Cond {
// ToLowerASCII is about 7% faster than ToUpperASCII (according to Golang's benchmark)
if setting.Database.Type.IsSQLite3() {
return builder.Like{"LOWER(" + key + ")", util.ToLowerASCII(value)}
}
return builder.Like{"LOWER(" + key + ")", strings.ToLower(value)}
}
// BuildCaseInsensitiveIn returns a condition to check if the given value is in the given values case-insensitively.
func BuildCaseInsensitiveIn(key string, values []string) builder.Cond {
incaseValues := make([]string, len(values))
caseCast := strings.ToLower
if setting.Database.Type.IsSQLite3() {
caseCast = util.ToLowerASCII
}
for i, value := range values {
incaseValues[i] = caseCast(value)
}
return builder.In("LOWER("+key+")", incaseValues)
}
两边同时 LOWER() 而不是只降值:因为某些列的 collation 可能区分大小写,单边降值会破坏索引的一致性假设,双边降值至少保证比较语义正确。SQLite 分支用 ToLowerASCII 而非 strings.ToLower,注释给出原因:"SQLite doesn't handle complete Unicode case folding"------SQLite 的 LOWER() 只处理 ASCII,Go 的 strings.ToLower 却会做 Unicode 映射,两边对同一字符串的"小写"不一致会导致查询漏匹配;统一限制到 ASCII 折叠使两边行为一致。顺带的 benchmark 注释(ToLowerASCII 比 ToUpperASCII 快约 7%)是典型的 Gitea 式细节。
BuilderDialect() 把 setting.Database.Type 翻译成 builder.MYSQL/SQLITE/POSTGRES/MSSQL 常量,供需要构造方言相关表达式的调用方使用(例如迁移测试里校验 SQL 文本)。
4.3.5 资源序号:同一语义的四种原子实现
Gitea 用 ResourceIndex 表为"每个仓库一个自增序号"(issue 编号、release 编号)提供计数器:
go
// ResourceIndex represents a resource index which could be used as issue/release and others
// We can create different tables i.e. issue_index, release_index, etc.
type ResourceIndex struct {
GroupID int64 `xorm:"pk"`
MaxIndex int64 `xorm:"index"`
}
GetNextResourceIndex(models/db/index.go)必须是原子的------两个用户同时创建 issue 不能拿到同一编号。四种数据库各有一种原子 upsert,源码按方言分支:
go
// GetNextResourceIndex generates a resource index, it must run in the same transaction where the resource is created
func GetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) {
switch {
case setting.Database.Type.IsPostgreSQL():
return postgresGetNextResourceIndex(ctx, tableName, groupID)
case setting.Database.Type.IsMySQL():
return mysqlGetNextResourceIndex(ctx, tableName, groupID)
case setting.Database.Type.IsMSSQL():
return mssqlGetNextResourceIndex(ctx, tableName, groupID)
}
// ...SQLite 走 UPDATE + INSERT fallback...
}
Postgres 一条语句拿值:
go
func postgresGetNextResourceIndex(ctx context.Context, tableName string, groupID int64) (int64, error) {
res, err := GetEngine(ctx).Query(fmt.Sprintf("INSERT INTO %s (group_id, max_index) "+
"VALUES (?,1) ON CONFLICT (group_id) DO UPDATE SET max_index = %s.max_index+1 RETURNING max_index",
tableName, tableName), groupID)
if len(res) == 0 {
return 0, ErrGetResourceIndexFailed
}
return strconv.ParseInt(string(res[0]["max_index"]), 10, 64)
}
MySQL 没有 RETURNING,只能 upsert 后再 SELECT;MSSQL 用 MERGE INTO ... WITH (HOLDLOCK)(HOLDLOCK 是 serializable 范围锁,防 MERGE 的经典竞态);SQLite 没有 upsert 语法(老版本),走最保守的路径:
go
// try to update the max_index to next value, and acquire the write-lock for the record
res, err := e.Exec(fmt.Sprintf("UPDATE %s SET max_index=max_index+1 WHERE group_id=?", tableName), groupID)
// affected == 0 时先 INSERT (group_id, 0) 再 UPDATE,还要求 affected != 0,
// 否则返回 "impossible error when GetNextResourceIndex, insert and update both succeeded but no record is updated"
函数头注释"it must run in the same transaction where the resource is created"是一条硬约束:取号必须与资源创建同事务 ,否则资源插入失败回滚会留下号码空洞(可接受),但号码发放与资源创建分离会造成并发下号码与资源错配------models/issues/issue_index.go 的 RecalculateIssueIndexForRepo 就是在这个约束下工作的执行者(其迁移入口在 services/migrations/gitea_uploader.go)。
SyncMaxResourceIndex 用于修复/校准(先 UPDATE WHERE max_index < ?,影响 0 行则 INSERT 再核对),在仓库迁移导入、doctor 一致性检查里使用。models/db/sequence.go 的 CountBadSequences/FixBadSequences(见 4.7.5)是它的姊妹函数------处理 Postgres 序列损坏。
4.4 泛型 CRUD 层:models/db/context.go 逐函数解析
这是全书第 1 章 1.6.3 节预告的"数据库泛型层"本体。文件不长(约 320 行),但几乎每一行都有设计意图。先看总表,再逐个拆实现。
| 函数 | 签名要点 | 语义与边界 |
|---|---|---|
GetEngine(ctx) |
返回 Engine |
有则取 context 中的事务 session(并做安全检查),无则 xormEngine.Context(ctx) 新建 |
WithTx(ctx, fn) |
func(ctx) error |
已在事务→复用;否则 txWithNoCheck 新建事务 |
WithTx2[T](ctx, fn) |
func(ctx) (T, error) |
WithTx 的带返回值版本 |
TxContext(ctx) |
(ctx, Committer, error) |
需要手动控制 Commit 时用;复用时返回 halfCommitter |
Insert(ctx, beans...) |
error |
直通 GetEngine(ctx).Insert |
Exec(ctx, sqlAndArgs...) |
(sql.Result, error) |
裸 SQL 出口 |
Get[T](ctx, cond) |
( *T, bool, error) |
cond.IsValid() 为 false 直接 panic |
GetByID[T](ctx, id) |
同上 | ID(id) 主键查询 |
Exist[T](ctx, cond) / ExistByID[T] |
(bool, error) |
同样校验 cond 有效性 |
DeleteByID[T] / DeleteByIDs[T] |
带 NoAutoTime |
删除不更新 updated 时间戳 |
Delete[T](ctx, opts) |
(int64, error) |
opts 为 nil 或 cond 无效→panic |
DeleteByBean(ctx, bean) |
(int64, error) |
按 bean 非零字段匹配删除(xorm 惯用法) |
FindIDs(ctx, tableName, idCol, cond) |
([]int64, error) |
可查外键列(如满足条件的 repo_id) |
DecrByIDs(ctx, ids, col, bean) |
error |
原子减一且不更新时间戳 |
DeleteBeans / TruncateBeans |
error |
批量删 / 清空表 |
CountByBean(ctx, bean) |
(int64, error) |
按非零字段计数 |
InTransaction(ctx) |
bool |
判断 ctx 是否已挂事务 session |
4.4.1 GetEngine:engine 的取用规则
go
// GetEngine gets an existing db Engine/Statement or creates a new Session
func GetEngine(ctx context.Context) Engine {
if engine, ok := ctx.Value(contextKeyEngine).(Engine); ok {
// if reusing the existing session, need to do "contextSafetyCheck" because the Iterate creates a "autoResetStatement=false" session
contextSafetyCheck(engine)
return engine
}
// no need to do "contextSafetyCheck" because it's a new Session
return xormEngine.Context(ctx)
}
规则很简单:context 里有就用里面的(事务 session 或上次创建的 session),没有就从全局引擎派生一个新 session 。xormEngine.Context(ctx) 返回一个绑定该 context 的 *xorm.Session(xorm 会用 context 做查询取消等),它不是事务。安全检查的机制放在 4.5.4 节单独讲。
4.4.2 Get[T] / GetByID[T] / Exist[T]
go
func Get[T any](ctx context.Context, cond builder.Cond) (object *T, exist bool, err error) {
if !cond.IsValid() {
panic("cond is invalid in db.Get(ctx, cond). This should not be possible.")
}
var bean T
has, err := GetEngine(ctx).Where(cond).NoAutoCondition().Get(&bean)
if err != nil {
return nil, false, err
} else if !has {
return nil, false, nil
}
return &bean, true, nil
}
两个关键点:
cond.IsValid()检查 :xorm 的builder.Cond有一种"无效/空条件"状态(如builder.Eq{}空 map),如果把它喂给Where,xorm 可能生成WHERE 1=1甚至丢掉 WHERE------Get就变成全表第一条记录。这在业务上是"查询条件拼装出了 bug",所以选择 panic 而不是返回错误:空条件是编程错误,不是运行时错误 ,测试阶段就该炸。Exist[T]和Delete[T]同样的检查,错误信息都写着 "This should not be possible."。NoAutoCondition():xorm 默认行为是"用 bean 的非零字段自动作为查询条件"(Get(&bean)且 bean.ID=5 会生成WHERE id=5)。泛型层已经显式给了Where(cond),再让 xorm 自动拼条件会产生WHERE cond AND zero_fields(var bean T的零值字段理论上不参与,但显式关掉是防御性写法,也是全仓库统一约定)。
三元返回 (object, exist, err) 是 Gitea 处理"没查到"的标准形式------不返回 ErrNotExist,由调用方决定没查到是错误还是正常分支(models/auth/session.go 的 ReadSession 就是靠 exist 决定插入还是返回)。需要错误语义的地方通常再包一层,如 models/issues/issue.go 的 GetIssueByIndex 之类会转成 ErrIssueNotExist(其 Unwrap 返回 util.ErrNotExist,可用 errors.Is 判断)。
GetByID[T] 是同一形态的主键特化:
go
func GetByID[T any](ctx context.Context, id int64) (object *T, exist bool, err error) {
var bean T
has, err := GetEngine(ctx).ID(id).NoAutoCondition().Get(&bean)
// ...
}
4.4.3 删除族:NoAutoTime 与各种条件形态
go
// DeleteByID deletes the given bean with the given ID
func DeleteByID[T any](ctx context.Context, id int64) (int64, error) {
var bean T
return GetEngine(ctx).ID(id).NoAutoCondition().NoAutoTime().Delete(&bean)
}
func DeleteByIDs[T any](ctx context.Context, ids ...int64) error {
if len(ids) == 0 {
return nil
}
var bean T
_, err := GetEngine(ctx).In("id", ids).NoAutoCondition().NoAutoTime().Delete(&bean)
return err
}
func Delete[T any](ctx context.Context, opts FindOptions) (int64, error) {
if opts == nil || !opts.ToConds().IsValid() {
panic("opts are empty or invalid in db.Delete(ctx, opts). This should not be possible.")
}
var bean T
return GetEngine(ctx).Where(opts.ToConds()).NoAutoCondition().NoAutoTime().Delete(&bean)
}
NoAutoTime():xorm 在 Update/Delete 时默认会把updated字段刷新为当前时间;删除一行数据时更新"已删行"的时间戳毫无意义且浪费一次计算,所以删除族一律关掉。Delete[T](ctx, opts)接收FindOptions而不是裸 cond ------这是接口复用的巧妙处:查询用的 options(4.6 节的FindTopicOptions等)天然携带ToConds(),于是"列出这批"与"删掉这批"用同一个 options 类型,不会出现查询条件与删除条件不一致的 bug。nil 与无效 cond 同样 panic。DeleteByBean保留 xorm 原生语义(bean 非零字段即 WHERE),用于简单场景,如models/repo/star.go的db.DeleteByBean(ctx, &Star{UID: doer.ID, RepoID: repo.ID})。DeleteBeans与TruncateBeans的差异 :前者按条件Delete(逐个 bean),后者Truncate(清空整表);后者用于测试清理与维护脚本。
FindIDs 展示了泛型层对"任意表任意外键列"的查询支持:
go
// FindIDs finds the IDs for the given table name satisfying the given condition
// By passing a different value than "id" for "idCol", you can query for foreign IDs, i.e. the repo IDs which satisfy the condition
func FindIDs(ctx context.Context, tableName, idCol string, cond builder.Cond) ([]int64, error) {
ids := make([]int64, 0, 10)
if err := GetEngine(ctx).Table(tableName).
Cols(idCol).
Where(cond).
Find(&ids); err != nil {
return nil, err
}
return ids, nil
}
Cols(idCol) 只取一列(如 repo_id),Find(&ids) 直接扫描进 []int64------不需要为每个"查一列 ID"的需求定义临时结构体。
DecrByIDs 是原子计数器的泛型封装:
go
// DecrByIDs decreases the given column for entities of the "bean" type with one of the given ids by one
// Timestamps of the entities won't be updated
func DecrByIDs(ctx context.Context, ids []int64, decrCol string, bean any) error {
if len(ids) == 0 {
return nil
}
_, err := GetEngine(ctx).Decr(decrCol).In("id", ids).NoAutoCondition().NoAutoTime().Update(bean)
return err
}
空 ids 短路是必须的------xorm 的 In("id", 空切片) 行为等价于"无此条件",不短路会变成 UPDATE ... WHERE id IN ()(非法 SQL)或更糟的全表更新。Update 前面有 In("id", ids) 兜住了 WHERE,规避了 4.1.4 节 gotcha 第一条的"无 WHERE 更新全表"。
4.4.4 Insert 与 Exec:只有两个裸出口
go
// Insert inserts records into database
func Insert(ctx context.Context, beans ...any) error {
_, err := GetEngine(ctx).Insert(beans...)
return err
}
// Exec executes a sql with args
func Exec(ctx context.Context, sqlAndArgs ...any) (sql.Result, error) {
return GetEngine(ctx).Exec(sqlAndArgs...)
}
Insert 丢弃影响行数只返回 error(插入成功必然 ≥1 行,返回值没有信息量);Exec 是唯一的裸 SQL 出口,sqlAndArgs... 支持 "UPDATE x SET a=a+1 WHERE id=?" 加参数的形态。4.10.1 节的 StarRepo 会看到 Insert + Exec 组合在同一事务里维护计数列的写法------Gitea 用计数列(num_stars、num_issues)换查询性能,代价是每次写都要同步维护,且必须用原生 UPDATE 的 num_stars = num_stars + 1 形式做原子增减,绝不能先读结构体再整体 Update(会覆盖并发更新)。
4.4.5 InTransaction 与 getTransactionSession
go
// InTransaction returns true if the engine is in a transaction otherwise return false
func InTransaction(ctx context.Context) bool {
return getTransactionSession(ctx) != nil
}
func getTransactionSession(ctx context.Context) *xorm.Session {
e, _ := ctx.Value(contextKeyEngine).(Engine)
if sess, ok := e.(*xorm.Session); ok && sess.IsInTx() {
return sess
}
return nil
}
类型断言到具体 *xorm.Session 并调 IsInTx()------光有 engine 不算事务,必须是"xorm session 且处于 tx 中" 。InTransaction 的用途是防御性 guard:services/repository/delete.go 的 DeleteRepositoryDirectly 开头就是
go
if db.InTransaction(ctx) {
return errors.New("DeleteRepositoryDirectly must not be called within a transaction, it deletes storage once its own transaction commits")
}
原因写在错误消息里:这个函数要在自己的事务提交后 删除磁盘存储(git 目录、日志文件)------磁盘操作不可回滚,如果它被嵌套进外层事务,外层回滚数据库后磁盘文件已经没了,数据与存储不一致。4.10.1 节的 CreateTaskForRunner 也用同样 guard 禁止嵌套。这种"明确拒绝在事务中执行"的模式,是事务隐式传递(下一节)的必要补丁:事务通过 context 隐式传播很方便,但也意味着函数可能在你没预料的事务里被调用,对副作用敏感的函数必须自己检查。
4.5 事务在 context 中的传递
这是第 1 章 1.6.3 节明确预告"第 4 章会讲透"的机制,也是 Gitea 数据库层最有设计含量的部分。核心问题:如何让几十层调用栈上的每个函数都能加入同一个事务,而不用把 session 参数一路透传?
4.5.1 contextKey:把 session 挂进 context
go
type contextKey struct{ key string }
var (
contextKeyEngine = contextKey{"engine"}
ContextKeyTestFixtures = contextKey{"test-fixtures"}
)
func withContextEngine(ctx context.Context, e Engine) context.Context {
return context.WithValue(ctx, contextKeyEngine, e)
}
用自定义的未导出结构体 contextKey 而不是裸字符串作 key------这是 Go 的标准防碰撞实践(go vet 的 contextkey 检查禁止用 string 作 key,避免不同包写相同字符串互相覆盖)。ContextKeyTestFixtures 故意导出,因为 4.9.3 节的 fixtures 加载器(models/unittest 包)需要设置它,而 EngineHook(4.2.5)需要读取它。
存放的值类型是 Engine 接口,实际存的几乎总是 *xorm.Session。于是 context 里可以有两种 engine:
| ctx 中的 engine 状态 | 含义 | GetEngine 返回后的行为 |
|---|---|---|
无 contextKeyEngine 值 |
普通请求上下文 | xormEngine.Context(ctx) 每次派生新 session(无事务) |
*xorm.Session 且 IsInTx()==true |
处于事务中 | 返回该 session,所有查询进同一事务 |
*xorm.Session 且不在 tx |
GetEngine 曾被调用创建的 session |
复用(contextSafetyCheck 后返回) |
4.5.2 WithTx / WithTx2:日常事务入口
go
// WithTx represents executing database operations on a transaction, if the transaction exist,
// this function will reuse it otherwise will create a new one and close it when finished.
func WithTx(parentCtx context.Context, f func(ctx context.Context) error) error {
if sess := getTransactionSession(parentCtx); sess != nil {
err := f(withContextEngine(parentCtx, sess))
if err != nil {
// rollback immediately, in case the caller ignores returned error and tries to commit the transaction.
_ = sess.Close()
}
return err
}
return txWithNoCheck(parentCtx, f)
}
// WithTx2 is similar to WithTx, but it has two return values: result and error.
func WithTx2[T any](parentCtx context.Context, f func(ctx context.Context) (T, error)) (ret T, errRet error) {
errRet = WithTx(parentCtx, func(ctx context.Context) (errInner error) {
ret, errInner = f(ctx)
return errInner
})
return ret, errRet
}
func txWithNoCheck(parentCtx context.Context, f func(ctx context.Context) error) error {
sess := xormEngine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
if err := f(withContextEngine(parentCtx, sess)); err != nil {
return err
}
return sess.Commit()
}
三条路径的行为差异是理解重点:
- 父 context 已在事务中(复用路径) :直接把同一个
sess挂进新的子 ctx 执行f。注意错误分支的sess.Close()和注释:"rollback immediately, in case the caller ignores returned error and tries to commit the transaction"------子函数出错就立刻回滚整个父事务 ,而不是只返回错误。因为调用方可能忽略错误继续 Commit(历史代码里真实发生过),提前 Close 能保证脏数据不会被提交。Close()一个 in-tx 的 xorm session 若未 Commit,行为是回滚。 - 不在事务中(新建路径) :
txWithNoCheck走NewSession → Begin → f → Commit,defer sess.Close()保证任何返回路径都释放 session(已 Commit 的 Close 只是释放连接)。f 返回错误时不 Commit,defer 的 Close 完成回滚。 WithTx2是纯粹的泛型包装:把func(ctx)(T,error)适配进WithTx的func(ctx) error形态,错误时ret返回 T 的零值。
关键语义:嵌套 WithTx 不会创建 savepoint,而是共享同一个事务。 复用路径里 f 拿到的 ctx 挂的是同一个 session------内层 WithTx 提交时(见 4.5.3 的 halfCommitter 或这里直接不提交)什么都不发生,外层 Commit 才真正生效;内层出错则立刻 Close 掉共享 session(整个外层事务被回滚)。grep 全仓库没有 SAVEPOINT 字样------Gitea 的"嵌套事务"就是共享事务 + 内层失败即整体失败,没有部分回滚能力。这是刻意的简化:业务代码不需要理解 savepoint 的可见性规则,只要记住"内层 WithTx 失败 = 整个外层失败"。
用 models/repo/topic.go 的 AddTopic 看真实用法(WithTx2 三连):
go
// AddTopic adds a topic name to a repository (if it does not already have it)
func AddTopic(ctx context.Context, repoID int64, topicName string) (*Topic, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Topic, error) {
topic, err := GetRepoTopicByName(ctx, repoID, topicName)
if err != nil {
return nil, err
}
if topic != nil {
// Repo already have topic
return topic, nil
}
topic, err = addTopicByNameToRepo(ctx, repoID, topicName)
if err != nil {
return nil, err
}
// ...
})
}
addTopicByNameToRepo 内部做了"查 topic → 不存在则插入 / 存在则 repo_count++ → 插入 RepoTopic 关联行"三步------计数列与关联行必须原子 (否则 topic 计数会漂移),这正是 WithTx2 的典型使用场景。而且 AddTopic 可以被外层(如给仓库批量加 topic 的函数)再包一层 WithTx,内层自动复用外层事务。
4.5.3 TxContext 与 halfCommitter:手动控制的事务
TxContext 返回显式的 Committer,供"需要在函数返回后继续持有事务"或"多处分支决定提交与否"的旧式代码使用。它的复用分支引入了本章最重要的类型:
go
// Committer represents an interface to Commit or Close the Context
type Committer interface {
Commit() error
Close() error
}
// halfCommitter is a wrapper of Committer.
// It can be closed early, but can't be committed early, it is useful for reusing a transaction.
type halfCommitter struct {
committer Committer
committed bool
}
func (c *halfCommitter) Commit() error {
c.committed = true
// should do nothing, and the parent committer will commit later
return nil
}
func (c *halfCommitter) Close() error {
if c.committed {
// it's "commit and close", should do nothing, and the parent committer will commit later
return nil
}
// it's "rollback and close", let the parent committer rollback right now
return c.committer.Close()
}
// TxContext represents a transaction Context,
// it will reuse the existing transaction in the parent context or create a new one.
// Some tips to use:
//
// 1 It's always recommended to use `WithTx` in new code instead of `TxContext`, since `WithTx` will handle the transaction automatically.
// 2. To maintain the old code which uses `TxContext`:
// a. Always call `Close()` before returning regardless of whether `Commit()` has been called.
// b. Always call `Commit()` before returning if there are no errors, even if the code did not change any data.
// c. Remember the `Committer` will be a halfCommitter when a transaction is being reused.
// So calling `Commit()` will do nothing, but calling `Close()` without calling `Commit()` will rollback the transaction.
// And all operations submitted by the caller stack will be rollbacked as well, not only the operations in the current function.
// d. It doesn't mean rollback is forbidden, but always do it only when there is an error, and you do want to rollback.
func TxContext(parentCtx context.Context) (context.Context, Committer, error) {
if sess := getTransactionSession(parentCtx); sess != nil {
return withContextEngine(parentCtx, sess), &halfCommitter{committer: sess}, nil
}
sess := xormEngine.NewSession()
if err := sess.Begin(); err != nil {
_ = sess.Close()
return nil, nil, err
}
return withContextEngine(parentCtx, sess), sess, nil
}
halfCommitter 的状态机是这段代码的精髓,名字的含义是"半提交者":它可以被提前 Close,但不能被提前 Commit。
| 调用序列(子函数拿到的 committer) | halfCommitter 行为 | 父 committer 最终动作 |
|---|---|---|
Commit() → Close() |
Commit 只置 committed=true;Close 见 committed 直接返回 |
父稍后 Commit(子的操作随父提交) |
出错未 Commit → Close() |
Close 调 c.committer.Close()(即父 session Close) |
整个父事务立即回滚 |
Close() → 之后又 Commit() |
Close 已回滚;Commit 只置标志 | 无操作(父已回滚) |
models/db/context_committer_test.go 用 MockCommitter 把这三种序列钉死成了测试(// it's not db_test, because this file is for testing the private type halfCommitter------测试放在包内以访问未导出类型):
go
t.Run("commit and close", func(t *testing.T) {
mockCommitter := NewMockCommitter("commit", "close")
testWithCommitter(mockCommitter, func(committer Committer) error {
defer committer.Close()
return committer.Commit()
})
mockCommitter.Assert(t)
})
t.Run("rollback and close", func(t *testing.T) {
mockCommitter := NewMockCommitter("close", "close")
testWithCommitter(mockCommitter, func(committer Committer) error {
defer committer.Close()
if true {
return errors.New("error")
}
return committer.Commit()
})
mockCommitter.Assert(t)
})
t.Run("close and commit", func(t *testing.T) {
mockCommitter := NewMockCommitter("close", "close")
testWithCommitter(mockCommitter, func(committer Committer) error {
committer.Close()
committer.Commit()
return errors.New("error")
})
mockCommitter.Assert(t)
})
(注:第三个子测试的期望是 ["close","close"]------先 Close 已回滚,父测试框架的 if err == nil { Commit } 因 err 非 nil 不会再 Commit,最终只记录两次 Close,与注释 "rollback" 语义一致。)
四条 tips 注释(原文 1、2a-2d)是官方给维护者的使用守则,翻译成要点:
- 新代码用
WithTx,别用TxContext(WithTx 自动处理提交回滚)。 - 维护旧 TxContext 代码:无论是否 Commit 都必须 Close (释放连接);无错误就必须 Commit,哪怕没改数据(保持与调用栈约定一致)。
- 复用时拿到的是 halfCommitter:Commit 是 no-op;不 Commit 就 Close 会回滚,而且回滚的是调用栈上所有层的操作,不只本函数的。
- 回滚不是被禁止,而是只在真想回滚的错误路径上做。
第 3 条 tip 是全章最重要的警告:TxContext 复用场景下,一个内层函数"只是 Close 没 Commit"就会把外层所有工作一笔勾销------这正是 Gitea 强制推荐 WithTx 的原因(WithTx 的复用分支根本没有 Commit 概念,错误才 Close)。
models/db/context_test.go 的 TestTxContext 验证了复用语义------内层 TxContext(ctx) 拿到的 engine 与外层 db.GetEngine(ctx) 是同一个对象(assert.Equal(t, engine, db.GetEngine(ctx))),内层 Commit 后外层仍能继续 Commit:
go
{ // reuse the transaction created by TxContext and commit it
ctx, committer, err := db.TxContext(t.Context())
engine := db.GetEngine(ctx)
assert.NoError(t, err)
assert.True(t, db.InTransaction(ctx))
{
ctx, committer, err := db.TxContext(ctx)
assert.NoError(t, err)
assert.True(t, db.InTransaction(ctx))
assert.Equal(t, engine, db.GetEngine(ctx))
assert.NoError(t, committer.Commit())
}
assert.NoError(t, committer.Commit())
}
4.5.4 contextSafetyCheck:防住 xorm Iterate 的 session 污染
这是 Gitea 对 xorm 一个真实 bug 的工程化防御。先看问题:models/db/context_test.go 的 Show-XORM-Bug 子测试把 bug 复现过程写成了注释:
go
t.Run("Show-XORM-Bug", func(t *testing.T) {
actualCount := 0
// here: db.GetEngine(t.Context()) is a new *Session created from *Engine
_ = db.WithTx(t.Context(), func(ctx context.Context) error {
_ = db.GetEngine(ctx).Iterate(&TestModel1{}, func(i int, bean any) error {
// here: db.GetEngine(ctx) is always the unclosed "Iterate" *Session with autoResetStatement=false,
// and the internal states (including "cond" and others) are always there and not be reset in this callback.
m1, ok := bean.(*TestModel1)
require.True(t, ok)
assert.EqualValues(t, i+1, m1.ID)
// here: XORM bug, it fails because the SQL becomes "WHERE id=-1", "WHERE id=-1 AND id=-2", "WHERE id=-1 AND id=-3" ...
// and it conflicts with the "Iterate"'s internal states.
// has, err := db.GetEngine(ctx).Get(&TestModel2{ID: -m1.ID})
actualCount++
return nil
})
return nil
})
assert.Equal(t, testCount, actualCount)
})
问题链条:xorm 的 Iterate 内部会创建一个 autoResetStatement=false 的 session 执行逐行回调(这样 Iterate 自身的 WHERE/LIMIT 不会被每行重置);而 Gitea 的 GetEngine(ctx) 在事务中会返回"ctx 里存的那个 session"------正是 Iterate 用的这个不重置 session。回调里若再调 db.GetEngine(ctx).Get(...),新查询的条件会叠加 在 Iterate 未清空的条件上,生成 WHERE id=-1 AND id=-2 AND id=-3... 的错误 SQL。
Gitea 的防御是 contextSafetyCheck,机制相当硬核------用一次真实调用把 xorm 内部帧的程序计数器(PC)抓出来当指纹:
go
var (
contextSafetyOnce sync.Once
contextSafetyDeniedFuncPCs []uintptr
)
func contextSafetyCheck(e Engine) {
if setting.IsProd && !setting.IsInTesting {
return
}
if e == nil {
return
}
// Only do this check for non-end-users. If the problem could be fixed in the future, this code could be removed.
contextSafetyOnce.Do(func() {
// try to figure out the bad functions to deny
type m struct{}
_ = e.SQL("SELECT 1").Iterate(&m{}, func(int, any) error {
callers := make([]uintptr, 32)
callerNum := runtime.Callers(1, callers)
for i := range callerNum {
if funcName := runtime.FuncForPC(callers[i]).Name(); funcName == "xorm.io/xorm.(*Session).Iterate" {
contextSafetyDeniedFuncPCs = append(contextSafetyDeniedFuncPCs, callers[i])
}
}
return nil
})
if len(contextSafetyDeniedFuncPCs) != 1 {
panic(errors.New("unable to determine the functions to deny"))
}
})
// it should be very fast: xxxx ns/op
callers := make([]uintptr, 32)
callerNum := runtime.Callers(3, callers) // skip 3: runtime.Callers, contextSafetyCheck, GetEngine
for i := range callerNum {
if slices.Contains(contextSafetyDeniedFuncPCs, callers[i]) {
panic(errors.New("using session context in an iterator would cause corrupted results"))
}
}
}
逐步拆解:
- 一次性指纹采集(
sync.Once) :主动执行一次e.SQL("SELECT 1").Iterate(...),在回调里runtime.Callers抓当前调用栈,找出函数名为xorm.io/xorm.(*Session).Iterate的 PC 值存进contextSafetyDeniedFuncPCs。PC 是"函数内某条指令的地址",比函数名匹配快得多------注释宣称 "it should be very fast: xxxx ns/op"。若抓到的 PC 不是恰好 1 个,说明 xorm 内部实现变了(比如有了多个 Iterate 入口),直接 panic 拒绝启动,防止防御失效被静默绕过。 - 每次取用检查 :
runtime.Callers(3, ...)跳过runtime.Callers、contextSafetyCheck、GetEngine三帧,在剩余调用栈里查有无被拒 PC。命中即panic("using session context in an iterator would cause corrupted results")。 - 只在非生产环境启用 :
if setting.IsProd && !setting.IsInTesting { return }------注释 "Only do this check for non-end-users. If the problem could be fixed in the future, this code could be removed." 表明这是开发/测试期的 bug 探测器:开发者会在测试里立刻发现误用,生产环境不为它付出栈回溯开销(虽然极小)。真实生产中如果真有人在 Iterate 回调里用 ctx,会静默产生错误 SQL------Gitea 选择用测试兜底而不是给每个请求加成本。
DenyBadUsage 子测试验证防御本身:
go
t.Run("DenyBadUsage", func(t *testing.T) {
assert.PanicsWithError(t, "using session context in an iterator would cause corrupted results", func() {
_ = db.WithTx(t.Context(), func(ctx context.Context) error {
return db.GetEngine(ctx).Iterate(&TestModel1{}, func(i int, bean any) error {
_ = db.GetEngine(ctx)
return nil
})
})
})
})
结论与替代方案 :既然 Iterate 回调里不能用 GetEngine(ctx),需要在迭代中查别的表怎么办?用 models/db/iterate.go 的自实现 keyset 迭代(4.6.5 节)------它不用 xorm 的 Iterate,改用 Find 分批拉取,回调拿到的是普通 ctx,完全安全。这是"绕开上游缺陷"的完整闭环:识别缺陷(测试注释)→ 防御误用(contextSafetyCheck)→ 提供正确替代(db.Iterate)。
4.6 分页与查询辅助:models/db/list.go
4.6.1 ListOptions 与默认值钳制
go
const (
// DefaultMaxInSize represents default variables number on IN () in SQL
DefaultMaxInSize = 50
defaultFindSliceSize = 10
)
// Paginator is the base for different ListOptions types
type Paginator interface {
GetSkipTake() (skip, take int)
IsListAll() bool
}
// SetSessionPagination sets pagination for a database session
func SetSessionPagination(sess Engine, p Paginator) {
skip, take := p.GetSkipTake()
sess.Limit(take, skip)
}
// ListOptions options to paginate results
type ListOptions struct {
PageSize int
Page int // start from 1
ListAll bool // if true, then PageSize and Page will not be taken
}
var ListOptionsAll = ListOptions{ListAll: true}
var _ Paginator = &ListOptions{}
// GetSkipTake returns the skip and take values
func (opts *ListOptions) GetSkipTake() (skip, take int) {
opts.SetDefaultValues()
return (opts.Page - 1) * opts.PageSize, opts.PageSize
}
// SetDefaultValues sets default values
func (opts *ListOptions) SetDefaultValues() {
if opts.PageSize <= 0 {
opts.PageSize = setting.API.DefaultPagingNum
}
if opts.PageSize > setting.API.MaxResponseItems {
opts.PageSize = setting.API.MaxResponseItems
}
if opts.Page <= 0 {
opts.Page = 1
}
}
设计要点:
- 钳制方向 :
PageSize <= 0补默认值setting.API.DefaultPagingNum(30,modules/setting/api.go的结构体初值);PageSize > MaxResponseItems(50)强制压到 50。上限钳制是防滥用的关键 ------API 调用方传PageSize=100000想一次拖全表,会被无声地压到 50。这个 50 是全局 API 响应上限,不是数据库层的。 ListAll标志让同一个 options 类型既能分页也能"全量拉取"(如内部脚本、导出场景),ListOptionsAll是预置的全量常量。Paginator接口 +SetSessionPagination把分页逻辑与 options 具体类型解耦:任何实现GetSkipTake的类型(含AbsoluteListOptions)都能直接给 session 设分页。GetStargazers(4.10.1)就是db.SetSessionPagination(sess, &opts)的用法。
AbsoluteListOptions 是"skip/take 直给"的形态(不分页码语义,用于游标式或已算好偏移的场景),构造时同样钳制:
go
// NewAbsoluteListOptions creates a list option with applied limits
func NewAbsoluteListOptions(skip, take int) *AbsoluteListOptions {
if skip < 0 {
skip = 0
}
if take <= 0 {
take = setting.API.DefaultPagingNum
}
if take > setting.API.MaxResponseItems {
take = setting.API.MaxResponseItems
}
return &AbsoluteListOptions{skip, take}
}
4.6.2 FindOptions 接口:查询条件的统一抽象
go
// FindOptions represents a find options
type FindOptions interface {
GetPage() int
GetPageSize() int
IsListAll() bool
ToConds() builder.Cond
ToOrders() string
}
type JoinFunc func(sess Engine) error
type FindOptionsJoin interface {
ToJoins() []JoinFunc
}
五个方法把一次列表查询拆成:分页三要素(Page/PageSize/ListAll,ListOptions 内嵌即可满足前三个)、条件(ToConds)、排序(ToOrders)。可选的 FindOptionsJoin 接口用类型断言渐进增强 ------Find[T] 里 if joinOpt, ok := opts.(FindOptionsJoin); ok 才处理 JOIN,普通 options 不用实现这个方法。
真实实现示例(models/actions/run_list.go)展示了接口方法的写法与其中的性能注释:
go
func (opts FindRunOptions) ToJoins() []db.JoinFunc {
if opts.OwnerID > 0 {
return []db.JoinFunc{func(sess db.Engine) error {
sess.Join("INNER", "repository", "repository.id = action_run.repo_id AND repository.owner_id = ?", opts.OwnerID)
return nil
}}
}
return nil
}
func (opts FindRunOptions) ToOrders() string {
// When scoped to a repo, sort by `index`: it reuses the unique
// `repo_index` (repo_id, index) index, so the query seeks repo_id and
// walks index descending instead of filesorting all matching rows.
// Within a repo `index` is co-monotonic with `id`, so the order is the same.
if opts.RepoID > 0 {
return "`action_run`.`index` DESC"
}
// `index` is scoped per repo, so it is meaningless across repos. With no
// RepoID, sort by the global, PK-indexed `id` for a deterministic order.
return "`action_run`.`id` DESC"
}
ToOrders 的注释是索引驱动排序决策的教科书案例:在单仓库范围内按 index 排序可以复用 repo_index(repo_id, index) 联合唯一索引 ------数据库先 seek 到 repo_id 再沿 index 逆序走索引,避免 filesort;跨仓库时 index 无全局意义,退回主键 id 保证确定性顺序。JoinFunc 的签名 func(sess Engine) error 允许 JOIN 构造本身返回错误(比如先做子查询再 join 的场景)。
4.6.3 Find[T]:分页、JOIN、预分配
go
// Find represents a common find function which accept an options interface
func Find[T any](ctx context.Context, opts FindOptions) ([]*T, error) {
sess := GetEngine(ctx).Where(opts.ToConds())
if joinOpt, ok := opts.(FindOptionsJoin); ok {
for _, joinFunc := range joinOpt.ToJoins() {
if err := joinFunc(sess); err != nil {
return nil, err
}
}
}
sess.OrderBy(opts.ToOrders())
page, pageSize := opts.GetPage(), opts.GetPageSize()
if !opts.IsListAll() && pageSize > 0 {
if page == 0 {
page = 1
}
sess.Limit(pageSize, (page-1)*pageSize)
}
findPageSize := defaultFindSliceSize
if pageSize > 0 {
findPageSize = pageSize
}
objects := make([]*T, 0, findPageSize)
if err := sess.Find(&objects); err != nil {
return nil, err
}
return objects, nil
}
两个细节值得注意:
page == 0补 1 :ListOptions.Page的注释说 "start from 1",但调用方可能给 0(尤其GetPage()直接透传结构体时),这里兜底避免Limit(size, -size)生成非法偏移。- 预分配容量
make([]*T, 0, findPageSize):默认 10(defaultFindSliceSize),有 pageSize 就按 pageSize 预分配------Find内部 xorm 会按行 append,预先按期望行数分配省掉多次扩容拷贝。小值 10 是"无分页时也可能只返回少量行"的保守估计。
排序用 opts.ToOrders() 字符串直传 OrderBy------ToOrders 返回的必须是 SQL 片段(如 ```action_run.index` DESC``),这是注入风险点,因此约定:orders 只能由代码常量拼成,绝不能包含用户输入(各实现里确实都只返回常量或白名单拼接)。
4.6.4 Count[T] 与 FindAndCount[T]
go
// Count represents a common count function which accept an options interface
func Count[T any](ctx context.Context, opts FindOptions) (int64, error) {
sess := GetEngine(ctx)
if opts != nil {
sess.Where(opts.ToConds())
if joinOpt, ok := opts.(FindOptionsJoin); ok {
for _, joinFunc := range joinOpt.ToJoins() {
if err := joinFunc(sess); err != nil {
return 0, err
}
}
}
}
var object T
return sess.Count(&object)
}
// FindAndCount represents a common findandcount function which accept an options interface
func FindAndCount[T any](ctx context.Context, opts FindOptions) ([]*T, int64, error) {
sess := GetEngine(ctx).Where(opts.ToConds())
page, pageSize := opts.GetPage(), opts.GetPageSize()
if !opts.IsListAll() && pageSize > 0 && page >= 1 {
sess.Limit(pageSize, (page-1)*pageSize)
}
if joinOpt, ok := opts.(FindOptionsJoin); ok {
for _, joinFunc := range joinOpt.ToJoins() {
if err := joinFunc(sess); err != nil {
return nil, 0, err
}
}
}
sess.OrderBy(opts.ToOrders())
findPageSize := defaultFindSliceSize
if pageSize > 0 {
findPageSize = pageSize
}
objects := make([]*T, 0, findPageSize)
cnt, err := sess.FindAndCount(&objects)
if err != nil {
return nil, 0, err
}
return objects, cnt, nil
}
Count[T]的 opts 可为 nil ------opts != nil分支意味着"不带任何条件的总计数",这是与Find(opts 必须非 nil,否则opts.ToConds()nil 解引用 panic)的刻意差异:计数接口常被用来查全表总数。- JOIN 必须同样参与 Count :
Count也遍历ToJoins(),否则 JOIN 引入的过滤(如按 owner 限定 run)在 count 里丢失,页码与总数会对不上。 FindAndCount走 xorm 的FindAndCount:xorm 内部会把"当前 LIMIT 条件"剥离后单独跑一次COUNT(*)(xorm 实现里会临时去掉 limit 做 count),所以返回的cnt是符合过滤条件的总行数 而非本页行数------这正是分页控件需要的。注意FindAndCount的 page 判断是page >= 1(Find是page == 0补 1),两者对page=0的处理路径略有不同:FindAndCount在 page=0 时干脆不分页(page >= 1不满足,不设 Limit)但IsListAll为 false......准确说这是个小的边界不一致,阅读时按"page 应从 1 开始"的约定理解即可。
调用方典型形态是"列表页一次拿数据+总数":FindAndCount[T](ctx, opts) 一条链完成,避免手写两次查询造成条件漂移。
4.6.5 keyset 迭代:models/db/iterate.go
分页翻到深处(LIMIT 1000000, 50)在大表上是 O(N) 扫描。Gitea 的全表遍历改用 keyset pagination(按主键游标推进):
go
func iterateTableByColumn[Bean any](ctx context.Context, colName string, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
table, err := xormEngine.TableInfo(new(Bean))
if err != nil {
return err
}
var col *schemas.Column
if colName == "" {
if len(table.PrimaryKeys) != 1 {
return fmt.Errorf("table %s has %d primary keys, only the table with exactly one primary key can be iterated", table.Name, len(table.PrimaryKeys))
}
colName = table.PrimaryKeys[0]
}
col = table.GetColumn(colName)
batchSize := setting.Database.IterateBufferSize
var lastColValue any
for {
if ctx.Err() != nil {
return ctx.Err()
}
beans := make([]*Bean, 0, batchSize)
query := GetEngine(ctx).Table(table.Name).Asc(colName)
batchCond := cond
if lastColValue != nil {
batchCond = builder.And(cond, builder.Gt{col.Name: lastColValue})
}
if batchCond != nil {
query = query.Where(batchCond)
}
if err := query.Limit(batchSize).Find(&beans); err != nil {
return err
}
if len(beans) == 0 {
return nil
}
reflectVal, err := col.ValueOf(beans[len(beans)-1])
if err != nil {
return err
}
lastColValue = reflectVal.Interface()
for _, bean := range beans {
if err := f(ctx, bean); err != nil {
return err
}
}
}
}
func Iterate[Bean any](ctx context.Context, cond builder.Cond, f func(ctx context.Context, bean *Bean) error) error {
return iterateTableByColumn(ctx, "", cond, f)
}
机制拆解:
- 游标条件 :每批查
WHERE 原条件 AND col > lastColValue ORDER BY col ASC LIMIT batchSize------builder.Gt{col.Name: lastColValue}是 keyset 的核心,每批都从上一批末尾继续,走索引 seek 而非 OFFSET 跳过,任意深度都是 O(batchSize·logN)。 - 单主键约束 :不传
colName时要求len(table.PrimaryKeys) == 1,否则报错------多主键(复合主键表如RepoTopic)无法用单列游标推进,宁可明确失败。 - 批大小
setting.Database.IterateBufferSize(默认 50)------配置化让大表运维可以调大减少往返。 - ctx 取消 :每批循环开头检查
ctx.Err(),进程关闭或请求取消时能在下一批前退出,长遍历(如 doctor 全库检查)不会拖住关停。 - 回调拿到普通 ctx (不是 Iterate 内部 session)------正如 4.5.4 所说,这里根本没用 xorm 的
Iterate,回调里随便GetEngine(ctx)查别的表都不会污染条件。这是 Gitea 给出的"正确迭代姿势" ,services/doctor/dbconsistency.go的全库孤儿检查就建立在它上面。
IterateByColumn 允许指定非主键的有序列(如按 created_unix 批量处理),同一套机制。
4.7 迁移体系:从 xorm AutoMigration 到 versioned migration
Gitea 的 schema 演进分两条腿走路:建表/加列这类"结构同步"交给 xorm 的 Sync ,数据重写与复杂结构变更交给版本化的 migration 序列 。这两条腿由 routers/common/db.go 的 migrateWithSetting 统一调度,而整个体系最特别的地方在于------它把"迁移代码引用的结构体必须冻结"上升成了 lint 级别的硬约束(MIGRATION-STRUCT-FROZEN)。
4.7.1 版本表与迁移序列的自校验
迁移的进度记录是一张极简的表,modelmigration/migrations.go:62-66:
go
// Version describes the version table. Should have only one row with id==1
type Version struct {
ID int64 `xorm:"pk autoincr"`
Version int64 // DB version is "the last migration's idNumber" + 1
}
全库只有 id==1 一行,Version 字段存的是"最后执行过的迁移 idNumber + 1"。迁移本体是一个单调追加的切片,newMigration(idNumber, 描述, 函数) 三元组从 70 号开始(minDBVersion = 70,对应 Gitea 1.5.3),到 28.x 的 357 号(v29.NormalizeLegacyTeamAuthorize),源码里 288 条记录(70...357 连续无空洞)按版本分段以注释锚定(// Gitea 1.22.0-rc0 ends at database version 294 这类)。序列的正确性不是靠人肉维护,而是 calcDBVersion 在每次调用时用两个 panic 现场校验(migrations.go:458-467):
go
func calcDBVersion(migrations []*migration) int64 {
dbVer := int64(minDBVersion + len(migrations))
if migrations[0].idNumber != minDBVersion {
panic("migrations should start at minDBVersion")
}
if dbVer != migrations[len(migrations)-1].idNumber+1 {
panic("migrations are not in order")
}
return dbVer
}
两条不变量:首条必须是 minDBVersion 、总版本 = 首条 + 条数 = 末条 idNumber + 1 ------即 idNumber 连续无空洞。任何人插入迁移时手滑跳号或重号,进程启动即崩,而不是静默漏跑。ExpectedDBVersion() 就是这个值;GetCurrentDBVersion 则先 x.Sync(new(Version)) 保证版本表存在再 x.Get(&Version{ID:1}),查不到返回 -1 表示"库还没初始化"。
4.7.2 Migrate 的四道闸门
Migrate(migrations.go:507-569)的执行序列里藏着四种边界情况的处理,逐一看:
go
func Migrate(ctx context.Context, x base.EngineMigration) error {
migrations := prepareMigrationTasks()
maxDBVer := calcDBVersion(migrations)
// Set a new clean the default mapper to GonicMapper as that is the default for Gitea.
x.SetMapper(names.GonicMapper{})
if err := x.Sync(new(Version)); err != nil {
return fmt.Errorf("sync: %w", err)
}
...
闸门一:fresh install 跳过全部迁移 。版本行不存在时(migrations.go:521-529)不逐条跑,而是直接 Insert 一条 Version = maxDBVer:
go
} else if !has {
// If the version record does not exist, it is a fresh installation, and we can skip all migrations.
// XORM model framework will create all tables when initializing.
currentVersion.ID = 0
currentVersion.Version = maxDBVer
if _, err = x.Insert(currentVersion); err != nil {
新库的表结构由后续的 SyncAllTables 按当前模型直接生成,288 条历史迁移对新库毫无意义------这个设计让"全新安装"和"从 1.5.3 升级"共用同一套代码路径。
闸门二:过老版本拒绝 ,curDBVer < minDBVersion 时 log.Fatal 提示先升到 1.6.4(migrations.go:533-537)------因为 70 号之前的迁移已被"retire"(列表头部注释明确写了退役规则:从顶部移除并同步更新 minDBVersion)。
闸门三:降级保护 ,maxDBVer < curDBVer(用新库跑旧程序)时直接 Fatal 退出,且非生产环境的提示里额外给出一条逃生口(migrations.go:540-548):
go
msg += "\nGitea will exit to keep your database safe and unchanged. Please use the correct Gitea release, do not change the migration version manually (incorrect manual operation may lose data)."
if !setting.IsProd {
msg += fmt.Sprintf("\nIf you are in development and really know what you're doing, you can force changing the migration version by executing: UPDATE version SET version=%d WHERE id=1;", maxDBVer)
}
log.Fatal("Migration Error: %s", msg)
开发环境允许手改版本号强冲,生产环境连提示都不给------"不要手动改 migration version"的理由写得很直白:改错了丢数据。
闸门四:git 依赖 。git.InitSimple() 在迁移循环之前(migrations.go:551-553),因为不少历史迁移要动 git 目录(如重命名仓库路径、修 release sha1),迁移不是纯 SQL 操作。随后的循环每跑完一条就立刻回写版本:
go
for _, m := range getPendingMigrations(curDBVer, migrations) {
log.Info("Migration[%d]: %s", m.idNumber, m.description)
// Reset the mapper between each migration - migrations are not supposed to depend on each other
x.SetMapper(names.GonicMapper{})
if err = m.Migrate(ctx, x); err != nil {
return fmt.Errorf("migration[%d]: %s failed: %w", m.idNumber, m.description, err)
}
currentVersion.Version = migrationIDNumberToDBVersion(m.idNumber)
if _, err = x.ID(1).Update(currentVersion); err != nil {
return err
}
}
两个细节值得注意:逐条提交进度 ------中途失败重跑时 getPendingMigrations(curDBVer, ...) 用 migrations[curDBVer-minDBVersion:] 切片精确续跑,已执行的不会重复;每条迁移前重置 GonicMapper ------注释写明"迁移之间不应相互依赖",防止上一条迁移改了表名映射污染下一条。注意这里没有把整个循环包进一个大事务:迁移常常包含 DDL,MySQL 的隐式提交会让"事务包裹"产生虚假的安全感,逐条版本回写反而是与 DDL 现实匹配的设计。
4.7.3 三层入口:启动自动迁移、强制手动迁移、禁用迁移
调用方有三个。第一层是服务启动 :routers/common/db.go 的 InitDBEngine 带重试地调 db.InitEngineWithMigration(ctx, migrateWithSetting)(DBConnectRetries 次、间隔 DBConnectBackoff,兼容 Docker 里 PGSQL 晚起的场景),其中 migrateWithSetting 是策略开关:
go
func migrateWithSetting(ctx context.Context, x db.EngineMigration) error {
if setting.Database.AutoMigration {
return versioned_migration.Migrate(ctx, x)
}
if current, err := modelmigration.GetCurrentDBVersion(x); err != nil {
return err
} else if current < 0 {
// execute migrations when the database isn't initialized even if AutoMigration is false
return versioned_migration.Migrate(ctx, x)
} else if expected := modelmigration.ExpectedDBVersion(); current != expected {
log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+
`You can set "database.AUTO_MIGRATION" to true or migrate manually by running "gitea [--config /path/to/app.ini] migrate"`, current, expected)
}
return nil
}
三分支语义:AutoMigration=true(默认)直接迁移;即使关了开关,未初始化的库(current < 0)仍会迁移 ------否则全新部署装不起来;已初始化但版本不匹配则 log.Fatal,把用户推向手动命令。多实例部署下并发启动同时迁移会打架,所以真正的迁移入口 services/versioned_migration/migration.go 先抢全局锁:
go
func Migrate(ctx context.Context, x db.EngineMigration) error {
// only one instance can do the migration at the same time if there are multiple instances
release, err := globallock.Lock(ctx, "gitea_versioned_migration")
if err != nil {
return err
}
defer release()
return modelmigration.Migrate(ctx, x)
}
第二层是 gitea migrate 命令 (cmd/migrate.go):db.InitEngineWithMigration(context.Background(), versioned_migration.Migrate),绕过 migrateWithSetting 的开关判断强制执行 ------这正是上面 Fatal 消息里给用户指的路,也用于"先迁移库、再跑 gitea admin create user"的运维序列(命令 Description 原文写明了这个用途)。第三层是 EnsureUpToDate :版本不等时报错并附上 gitea migrate 提示,供只读检查类入口使用。
4.7.4 MIGRATION-STRUCT-FROZEN:用 lint 冻结迁移里的结构体
这是 Gitea 迁移体系最容易被忽略、却最体现工程纪律的一条设计。.golangci.yml 的 depguard 规则(58-65 行)直接禁止迁移包依赖业务模型:
yaml
migrations:
...
- pkg: gitea.dev/models
desc: "migrations must not depend on the models package. HINT: MIGRATION-STRUCT-FROZEN"
- pkg: gitea.dev/modules/structs
desc: "migrations must not depend on modules/structs. HINT: MIGRATION-STRUCT-FROZEN"
为什么?modelmigration/base/shared.go:11-19 的注释给出了完整论证:
go
// HINT: MIGRATION-STRUCT-FROZEN: the structs used in migrations should not be affected by the changes happen in the future,
// because the details can be different in different releases.
// e.g. if one migration uses "User" model, it works in the early releases,
// then one day, when the User model changes, the existing migration will break because it will use the new (incorrect) User model,
// it should only use the old User model.
//
// Related: "models", "modules/structs", git repo directory layout on the disk, etc.
//
// If changes happen, the old migrations need to use a snapshot struct/function (copy the code and freeze)
问题的本质是时间维度 :迁移代码今天写、明天跑,但业务 struct 会一直演化。如果 1.12 的某条迁移直接 import models/user 用 User 结构体,等 28.x 的 User 加了列、改了列名,老迁移在给老库升级时拿到的就是错误的结构体------历史迁移必须看到"当年的 schema"。所以规则是:迁移需要 struct 时,在迁移包内复制一份冻结 。实例就在 modelmigration/v28/v348.go(重建 email_hash 表以换用 SHA256 头像哈希):
go
func RecreateEmailHashTable(_ context.Context, x base.EngineMigration) error {
// the rows are unreachable MD5 hashes and their UNIQUE email index would reject the SHA256 replacements
if err := x.DropTables("email_hash"); err != nil {
return err
}
type EmailHash struct {
Hash string `xorm:"pk varchar(64)"`
Email string `xorm:"UNIQUE(email_hashtype) NOT NULL"`
HashType string `xorm:"UNIQUE(email_hashtype) NOT NULL varchar(16)"`
}
return x.Sync(new(EmailHash))
}
EmailHash 是包内临时 struct,不是 models/auth 里的那份------将来那份怎么改都影响不到这条迁移。注意 depguard 只封了 gitea.dev/models 和 modules/structs,models/db 这个纯泛型工具包是放行的(base/shared.go:7 的 //nolint:depguard // allow to access db in migration 记录了这个例外------迁移总得有地方执行 SQL)。base 包同时提供 ResourceIndex 类型别名和 LocalCodeGitRepo 这类随版本变化的辅助函数的冻结副本,同属一个思想。
4.7.5 SyncAllTables 与 xorm AutoMigration 的边界
versioned migration 管"数据和复杂变更",建表与加列 则由 xorm 的 Sync 管。4.2.4 节提过 InitEngineWithMigration 的序列:先按注册的模型 SyncAllTables(),再执行 migrateFn。models/db/engine.go:123-129:
go
// SyncAllTables sync the schemas of all tables, is required by unit test code
func SyncAllTables() error {
_, err := xormEngine.StoreEngine("InnoDB").SyncWithOptions(xorm.SyncOptions{
WarnIfDatabaseColumnMissed: true,
}, registeredModels...)
return err
}
三个选择各有来由:StoreEngine("InnoDB") 让 MySQL 上新建的表显式指定 InnoDB(而不是依赖服务器默认引擎),否则建出来的可能是 MyISAM------不支持事务,直接破坏 4.5 节的整套事务语义;WarnIfDatabaseColumnMissed: true 在模型字段映射不到数据库列时告警而非静默;而 guidelines-backend.md 的规矩是绝不用裸 Sync ------需要丢列/改类型的场合必须 SyncWithOptions 显式带上 IgnoreDropWarning: true,把"删列"这个破坏性动作做成代码里的可见标记。gitea doctor 还提供了 gitea doctor recreate-table 配套修复:PostgreSQL 上 recreate-table 会留下孤儿序列,models/db/sequence.go 的 CountBadSequences/FixBadSequences 用 tmp_recreate__%_id_seq% 模式扫描 information_schema.sequences,把残留序列改名回 <table>_id_seq 并 setval(MAX(id)+1)------这正是历史迁移 175 号 FixPostgresIDSequencesBrokenBy recreate-table 处理过的同类问题的常驻版。
AutoMigration 的适用边界由此清晰:Sync 只做幂等的、单调的结构对齐(建表、加列、加索引),一切涉及删列、改类型、数据回填的操作都必须写成 versioned migration------因为后者要能在任意旧版本的库上重放,而前者只对"结构落后于模型"的库起作用。
4.7.6 一次升级的完整时序
把 4.2.4 与本节串起来,服务启动时数据库侧发生的完整序列是:
最后一步 config.SetDynGetter(system_model.NewDatabaseDynKeyGetter()) 值得一句:Gitea 把部分运行时配置(如反垃圾间隔)存在数据库里,引擎就绪后才接上动态 getter------数据库层是配置系统的依赖,这解释了为什么 routers/init.go 用 mustInitCtx 把 InitDBEngine 放在所有业务初始化之前。
4.8 数据模型组织:models/ 各包的表设计惯例
models/ 下每个业务域一个包(user、repo、issues、organization、perm/access、actions......),每个包内 struct 即一张表,init() 里 db.RegisterModel 完成注册。表设计的惯例高度统一,读懂这些 tag 惯例,就能从 struct 定义直接读出表结构与索引设计。
4.8.1 xorm tag 速查:从 struct 读出 DDL
以 models/user/user.go:95-179 的 User 为样本逐类解释:
go
type User struct {
ID int64 `xorm:"pk autoincr"`
LowerName string `xorm:"UNIQUE NOT NULL"`
Name string `xorm:"UNIQUE NOT NULL"`
...
Email string `xorm:"NOT NULL"`
PasswdHashAlgo string `xorm:"NOT NULL DEFAULT 'argon2'"`
MustChangePassword bool `xorm:"NOT NULL DEFAULT false"`
...
Rands string `xorm:"VARCHAR(32)"`
Language string `xorm:"VARCHAR(5)"`
...
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
...
IsActive bool `xorm:"INDEX"`
...
Avatar string `xorm:"VARCHAR(2048) NOT NULL"`
...
ExtDoerData ExtDoerData `xorm:"-"`
}
| tag 片段 | 生成的 DDL 语义 | 源码中的配套约定 |
|---|---|---|
pk autoincr |
自增主键 | 全仓库主键统一叫 id,GetMaxID 直接 SELECT MAX(id)(engine.go:181-184) |
UNIQUE / UNIQUE(s) |
唯一索引;带名字时建命名唯一索引 | 复合唯一用共享名,如 Repository 的 UNIQUE(s) 覆盖 (owner_id, lower_name)------一个 owner 下重名唯一 |
INDEX / index |
普通索引 | 外键列、状态过滤列必建(IsActive、IsPrivate、IsClosed) |
NOT NULL DEFAULT x |
非空 + 默认值 | 新加列的默认值必须显式,避免旧数据 NULL(迁移 142 号 SetIsArchivedToFalse 就是补历史欠账) |
VARCHAR(n) / LONGTEXT / TEXT |
列类型覆盖 | 不写则由 Go 类型推导;长文本列必须显式指定(MySQL TEXT 不能直接建索引、行内存储受限) |
created / updated |
xorm 自动时间戳 | 写入时自动填 created_unix/updated_unix,但删除/更新路径需注意 NoAutoTime()(4.4.3 节) |
xorm:"-" |
不映射数据库列 | 见 4.8.2,这是 Gitea 用得最多的一类 |
xorm:"'authorize'" |
列名覆写 | 见 4.8.3 |
timeutil.TimeStamp 是 int64 的类型别名,所以所有 *_unix 列在数据库里都是整型时间戳------跨方言一致、可直接比较排序,代价是不能用数据库的日期函数(权衡结果是日期逻辑全部上移到 Go 侧,如 4.6.2 节 FindRunOptions.ToOrders 对 created_unix 排序的索引利用)。
4.8.2 xorm:"-" 的三种用途:虚拟字段、懒加载标记、嵌套对象
Issue(models/issues/issue.go:54-111)是 xorm:"-" 用法的集大成者,摘录其核心段:
go
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"`
Repo *repo_model.Repository `xorm:"-"`
Index int64 `xorm:"UNIQUE(repo_index)"` // Index in one repository.
PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"`
...
Labels []*Label `xorm:"-"`
isLabelsLoaded bool `xorm:"-"`
MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"`
isMilestoneLoaded bool `xorm:"-"`
...
AssigneeID int64 `xorm:"-"`
Assignee *user_model.User `xorm:"-"`
isAssigneeLoaded bool `xorm:"-"`
IsClosed bool `xorm:"INDEX"`
IsRead bool `xorm:"-"`
IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not.
PullRequest *PullRequest `xorm:"-"`
...
PinOrder int `xorm:"-"` // 0 means not loaded, -1 means loaded but not pinned
...
Comments CommentList `xorm:"-"`
Reactions ReactionList `xorm:"-"`
Assignees []*user_model.User `xorm:"-"`
ShowRole RoleDescriptor `xorm:"-"`
}
三类模式清晰可见:
- 关联对象指针 + 对应 ID 列 :
Repo/RepoID、Poster/PosterID、Milestone/MilestoneID。ID 落库,对象不落库------对象由LoadXxx方法按需批量加载(4.10.3 节的 N+1 规避正是建立在这个模式上)。注意AssigneeID也是xorm:"-":assignee 是多对多(issue_assignees表),没有单列可存。 - 加载状态布尔 :
isLabelsLoaded/isMilestoneLoaded/isAssigneeLoaded全部xorm:"-",它们把"懒加载是否已发生"编码进内存对象,避免重复查询。PinOrder更妙------用值本身编码三态:0未加载、-1加载过但未置顶、>0置顶序号(LoadPinOrder,issue_list.go:484-515,加载后把未置顶项写成-1完成状态流转)。 - 纯视图字段 :
RenderedContent template.HTML、ShowRole RoleDescriptor------服务端渲染一次即弃,绝不入库。
Repository(models/repo/repo.go:153-221)同构,且多出冗余计数列 模式:NumStars/NumForks/NumIssues/NumClosedIssues 全部落库,而 NumOpenIssues/NumOpenPulls/NumOpenMilestones/NumOpenActionRuns 标 xorm:"-"------打开数 = 总数 − 关闭数,可以在 Go 侧算,不占列也不用 COUNT(*)。写路径必须维护这些计数器,4.10.1 节 StarRepo 的 num_stars = num_stars + 1 就是实例:冗余计数的读性能收益,换来的是写路径上"计数与明细必须同事务"的强约束 ------两者一旦分离,num_stars 就会漂移(迁移 143 号 RecalculateStars 就是历史漂移的修复)。
4.8.3 列名覆写、复合唯一与类型别名
三个容易被忽略的惯例:
列名覆写 :Team.AccessMode perm.AccessMode \xorm:"'authorize'"`(models/organization/team.go:81)------mode在 SQL 里是多处方言的保留字风险,且语义上这里是"团队对单元的授权模式",于是列名固定为authorize而字段名保持 Go 侧的AccessMode。单引号包裹是 xorm 的转义写法。这带来一个实战教训:**查询时必须用列名 authorize而非字段名**,否则报"unknown column"------28.x 的迁移 357 号NormalizeLegacyTeamAuthorize` 处理的正是这张表的历史数据规范问题。
复合唯一索引的跨表共享名 :Issue.RepoID 与 Issue.Index 都写 UNIQUE(repo_index),Repository.OwnerID/LowerName 都写 UNIQUE(s)------同一命名的 tag 会被 xorm 合并成一个复合唯一索引 (repo_id, index)、(owner_id, lower_name)。这直接表达了业务不变量:"issue 编号只在仓库内唯一"、"仓库名只在 owner 下唯一",由数据库兜底,应用层的检查只是提前报好错误。
类型定义继承 DDL :type IssueIndex db.ResourceIndex(issue.go:119)------ResourceIndex 的定义在 models/db/index.go:17-20:{GroupID int64 \xorm:"pk"`, MaxIndex int64 `xorm:"index"`},类型定义让 issue_index 表与各类资源索引表的结构完全一致,GetNextResourceIndex(4.3.5 节的四方言原子取号)无需为每个资源类型重写。type IssueIndex db.ResourceIndex在 Go 里是**定义新类型**(不是=` 别名),方法集独立但字段布局与 tag 原样继承------schema 复用靠类型定义,行为复用靠函数泛型。
4.8.4 注册模式与"无全局软删除"
每个包的 init() 模式高度一致:
go
func init() {
db.RegisterModel(new(Repository))
}
它有两个消费者:SyncAllTables(建表)与 NamesToBean(gitea doctor/测试按表名反查 bean)。4.1.3 节揭示过 RegisterModel 的 initFunc 参数实际是死代码(115 处调用全部只传 bean)------注册机制只承担"登记待建表的 bean"这一职责。
软删除 :Gitea 没有 全局软删除机制------没有 deleted 魔法字段,Delete[T] 就是真 DELETE(4.4.3 节)。个别业务表自带软删除标志:tracked_time.deleted(4.10.3 节 loadTotalTrackedTimes 的 Where("deleted = ?", false))、action.is_deleted、notification.status 等,都是逐表自实现,查询时必须手工带条件。这与"每个 DELETE 都必须有 WHERE"(4.1.4 节 guidelines 规矩)共同构成删除语义:要么真删并靠事务保证级联,要么显式标志并在每个查询点过滤------不存在中间态的框架魔法。
4.9 fixtures 测试数据体系
models 层的单测不 mock------它们跑在真实数据库上,靠 models/fixtures/ 下的 YAML 种子数据。这套体系在 28.x 被重写成了直接生成参数化 INSERT 的 loader,值得注意的工程细节不少。
4.9.1 文件即表:约定与加载入口
models/fixtures/ 下 78 个 .yml 文件,文件名(去扩展名)就是表名 ------FixturesFileFullPaths 里一行代码定下这个约定(fixtures_loader.go:204-205):
go
tableName, _, _ := strings.Cut(filepath.Base(file), ".")
fixtureItems[tableName] = &FixtureItem{fileFullPath: fileFillPath, tableName: tableName}
YAML 结构是"对象数组,键即列名",models/fixtures/access.yml 是典型样本:
yaml
-
id: 1
user_id: 2
repo_id: 3
mode: 4
-
id: 2
user_id: 2
repo_id: 5
mode: 4
...
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly
末尾这条注释是团队规矩的物化:公共 fixtures 只放"测试脚手架"(如几个基础用户、仓库),每个用例的专属数据由用例自己造------否则 78 张表的种子数据互相纠缠,改一处崩一片。
NewFixturesLoader 初始化时按数据库类型绑定两组方言函数(fixtures_loader.go:217-230)------标识符引用符(SQLite/PG 双引号、MySQL 反引号、MSSQL 方括号)与参数占位符(PG 是 $1,$2,...,其余 ?)------这就是"同一套 fixtures 跑四种数据库"的机制。随后它把所有已注册 bean 的表 预置进 tableSyncMap 并标脏:
go
// If a model is not imported in a package (no bean is registered), the table won't exist in database.
// So only use tables of registered models (beans).
xormBeans, _ := db.NamesToBean()
for _, bean := range xormBeans {
beanTableName := x.TableName(bean)
f.tableSyncMap.Store(trimTableNameQuotes(beanTableName), false)
}
4.9.2 生成参数化 INSERT:注入免疫与方言适配
prepareFixtureItem 把 YAML 行转成参数化 SQL 而非拼接字面量(fixtures_loader.go:92-114):
go
var sqlBuf []byte
var sqlArguments []any
for _, row := range rows {
sqlBuf = append(sqlBuf, fmt.Sprintf("INSERT INTO %s (", fixture.tableNameQuoted)...)
for k, v := range row {
sqlBuf = append(sqlBuf, f.quoteObject(k)...)
sqlBuf = append(sqlBuf, ","...)
sqlArguments = append(sqlArguments, v)
}
...
for range row {
sqlBuf = append(sqlBuf, f.paramPlaceholder(paramIdx)...)
...
}
fixture.sqlInserts = append(fixture.sqlInserts, string(sqlBuf))
fixture.sqlInsertArgs = append(fixture.sqlInsertArgs, slices.Clone(sqlArguments))
值全部走 sqlArguments 绑定变量------fixtures 里可能出现 O'Brien 这类带引号的名字,参数化后四方言都不会炸,顺带免疫注入。生成结果缓存在 FixtureItem 里 (prepareFixtureItem 只在首次调用执行,loadFixtures 以 tableNameQuoted == "" 判断),每轮测试重载不必重复解析 YAML。
数据本身还要过一道预处理(fixtures_loader.go:54-67):
go
func (f *fixturesLoaderInternal) preprocessFixtureRow(row []map[string]any) (err error) {
for _, m := range row {
for k, v := range m {
if s, ok := v.(string); ok {
if strings.HasPrefix(s, "0x") {
if m[k], err = hex.DecodeString(s[2:]); err != nil {
return err
}
}
}
}
}
return nil
}
0x 前缀的字符串按 hex 解码成 []byte------二进制列(如 SHA256 哈希、加密 token)在 YAML 里以 0x... 文本表示,加载时还原为字节。
4.9.3 加载的事务、identity 与脏表追踪
loadFixtures 处理两个方言陷阱(fixtures_loader.go:118-144):
go
_, err = tx.Exec("DELETE FROM " + fixture.tableNameQuoted) // sqlite3 doesn't support truncate
...
if fixture.mssqlHasIdentityColumn {
_, err = tx.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s ON", fixture.tableNameQuoted))
...
defer func() { _, err = tx.Exec(fmt.Sprintf("SET IDENTITY_INSERT %s OFF", fixture.tableNameQuoted)) }()
}
for i := range fixture.sqlInserts {
_, err = tx.Exec(fixture.sqlInserts[i], fixture.sqlInsertArgs[i]...)
}
先 DELETE 再 INSERT (而非 TRUNCATE,SQLite 不支持)实现幂等重载;MSSQL 的 IDENTITY_INSERT 是必须项------fixture 显式指定 id 列的值,而 SQL Server 默认禁止给 identity 列赋值,所以先探测(sys.identity_columns 查询,fixtures_loader.go:45-52)再开关。guidelines 4.1.4 节提到的 "MSSQL IDENTITY_INSERT" 规矩在测试侧同样成立。
顶层 Load() 把所有表的重载包进单个事务 ,并且用 db.ContextKeyTestFixtures 标记 ctx(fixtures_loader.go:147-153)------这个 key 正是 4.2.5 节 EngineHook 识别的测试信号,让慢查询/分析钩子跳过 fixtures 灌数的海量 SQL。tableSyncMap 的三态(不存在/脏/已同步)支撑 MarkTableChanged:用例改了某表数据后标脏,下一轮 Load 只重载脏表,其余表在最后被统一 DELETE FROM 清空(fixtures_loader.go:168-176)------"除了本轮显式加载的,别的表全清空" ,测试之间零数据残留。ResetTestDatabase 还约束测试库文件名必须含 -test.db,防止误删生产库。
4.10 事务与性能实践
本节收敛到"业务代码该怎么写"------四类可直接照抄的模式。
4.10.1 WithTx 三件套:计数与明细同事务
StarRepo(models/repo/star.go:29-69)是教科书式的原子写,4.8.2 节引过它的骨架,这里看完整结构:db.WithTx 包住"读现状 → 分支写明细 → 同步冗余计数"全流程------Insert(Star) 与两条 num_stars + 1 的 Exec 任一失败,整个事务回滚,star 行和两个计数器要么全变要么全不变。注意闭包参数遮蔽:func(ctx context.Context) error 的 ctx 已经是携带事务 session 的新 ctx(4.5.1 节的 contextKey),所以闭包内所有 db.Insert/db.Exec/db.Exist 自动走同一事务;而 IsStaring、user_model.IsUserBlockedBy 这些被调函数的签名第一个参数就是 ctx------事务的传递不靠层层传 session,靠 ctx 一路传下去,这正是第 1 章 1.6.3 节埋的伏笔。
同一事务内的计数更新用 Exec 原子自增(SET num_stars = num_stars + 1)而非"读出 → +1 → 写回"------后者在并发下丢更新,前者由数据库行锁保证。反面参考是 ClearRepoStars(star.go:94-104):它没有 WithTx,因为它是一次性把仓库 star 清零的管理操作,三条语句不构成业务原子性需求(仓库即将转私/删除),"事务不是越多越好"也是惯例的一部分。
禁止嵌套的 guard 是第二件套。DeleteRepositoryDirectly(services/repository/delete.go:53-56)开头三行:
go
func DeleteRepositoryDirectly(ctx context.Context, repoID int64, ignoreOrgTeams ...bool) error {
if db.InTransaction(ctx) {
return errors.New("DeleteRepositoryDirectly must not be called within a transaction, it deletes storage once its own transaction commits")
}
理由写在错误消息里:这个函数的数据库事务提交后还要删磁盘上的 git 目录 ,磁盘操作不可回滚------如果它被嵌进外层事务,外层随后回滚,数据库行还在、磁盘已被删,仓库就变成指向空目录的"幽灵"。所以它主动拒绝复用父事务,自己开 TxContext 并在 Commit 成功后才删存储。CreateTaskForRunner(actions)同款 guard,防的是"任务行入队但外层回滚导致幽灵任务"。判断标准:事务边界之后是否有不可回滚的副作用(磁盘、网络、消息)------有,就禁止嵌套。
4.10.2 批量插入:MaxBatchInsertSize = 999 / 列数
models/db/engine.go:160-167:
go
// MaxBatchInsertSize returns the table's max batch insert size
func MaxBatchInsertSize(bean any) int {
t, err := xormEngine.TableInfo(bean)
if err != nil {
return 50
}
return 999 / len(t.ColumnsSeq())
}
常数 999 是按占位符总数 算的,不是行数------MySQL 的 max_allowed_packet 限制与多方言对单条 INSERT 参数量的容忍度决定了"总参数 ≤ 999"这个保守值,所以列越宽、批越小;TableInfo 失败退化为 50 行的兜底。调用方如 services/migrations/gitea_uploader.go(批量写 issue/评论)和 services/doctor/repository.go 都按此分块。配合 4.6 节的 DefaultMaxInSize = 50(读侧 IN (...) 分块),Gitea 的批量哲学一致:不追求单条 SQL 极限,追求在所有支持的方言上稳定工作。
批量的另一面是 Insert 的切片形态:db.Insert(ctx, steps) 直接传 []*ActionTaskStep(4.10.4 节 claimJobForRunner 摘录里 e.Insert(steps))------xorm 生成 INSERT INTO ... VALUES (...), (...), ... 多值插入,同样受占位符预算约束,调用方按 MaxBatchInsertSize 切片。
4.10.3 N+1 规避:LoadAttributes 批量加载范式
ORM 里最隐蔽的性能杀手 N+1(查 20 条 issue、渲染时再各查 20 次 poster),Gitea 用"列表类型 + 显式批量 Load "根治。models/issues/issue_list.go 的 LoadAttributes(issue_list.go:518-552)串起八个批量加载器:LoadRepositories、LoadPosters、LoadLabels、LoadMilestones、LoadProjects、LoadAssignees、LoadPullRequests、loadTotalTrackedTimes------列表页控制器拿到 IssueList 后调一次,8 次查询替换 N×8 次。
最简单的 LoadPosters(issue_list.go:72-92)展示了范式三步:
go
func (issues IssueList) LoadPosters(ctx context.Context) error {
if len(issues) == 0 {
return nil
}
posterIDs := container.FilterSlice(issues, func(issue *Issue) (int64, bool) {
return issue.PosterID, issue.Poster == nil && issue.PosterID > 0
})
posterMaps, err := user_model.GetUsersMapByIDs(ctx, posterIDs)
...
for _, issue := range issues {
if issue.Poster == nil {
issue.Poster = user_model.GetPossibleUserFromMap(issue.PosterID, posterMaps)
}
}
收集 ID(container.FilterSlice 顺带去重------同一 poster 多条 issue 只查一次,且跳过已加载的)→ 一次 map 查询 → 内存回填 。进阶版 LoadRepositories(issue_list.go:43-54)在此基础上加了 DefaultMaxInSize 分块循环(IN 列表超过 50 个 ID 就切片多轮),应对"50 条 issue 来自 50 个不同仓库"的极端页。LoadLabels 用 xorm:"extends" 结构 + Rows() 流式扫描 JOIN 结果;loadTotalTrackedTimes 干脆把聚合下推(SELECT issue_id, sum(time) ... GROUP BY issue_id)------批量加载的极致是把 N 次查询压成 1 次带 GROUP BY 的查询。
配套的三态字段闭环在 4.8.2 节讲过:isLabelsLoaded 防重复加载,PinOrder 的 0/-1/正数编码未加载/加载无值/有值。控制器侧的纪律是列表渲染前必须 LoadAttributes ------模板里 {{.Issue.Poster.Name}} 能取到值,全靠这一行;漏了不会报错,只会 nil panic 或触发隐藏查询。
4.10.4 条件 UPDATE 抢占:乐观锁的无锁实现
多 runner 并发抢任务是"读-改-写"竞态的典型场景,claimJobForRunner(models/actions/task.go:331-412)给出标准解法。它没有 SELECT ... FOR UPDATE,而是在事务里把"检查"编码进 UPDATE 的 WHERE:
go
job.TaskID = task.ID
n, err := UpdateRunJob(ctx, job, builder.And(builder.Eq{"task_id": 0}, builder.Eq{"status": StatusWaiting}))
if err != nil {
return err
}
if n != 1 {
// Another runner claimed this job between our scan and this update;
// signal the outer loop to move on without treating this as an error.
return errJobAlreadyClaimed
}
WHERE task_id = 0 AND status = 'waiting'------只有仍是"无人认领的等待态"的行才会被更新;两个 runner 同时跑,数据库行锁让其中一个必然影响 0 行 ,n != 1 即知输赢。输家用 errJobAlreadyClaimed 这个哨兵 error 穿透事务(errors.Is 判定后转成 (nil, false, nil),外层换下一个候选 job,不算错误)。这条路径同时满足 guidelines 的"读不阻塞写"规矩(4.1.4 节):全程没有锁等待,输家零阻塞。同事务内还插入了 ActionTask 与 ActionTaskStep 切片------抢占成功与任务记录创建原子完成;抢占失败则连同 task 插入一起回滚,不留垃圾行。
4.10.5 读写之外:进程内缓存的两层
数据库前还有两层进程内缓存,modules/cache 提供:
全局字符串缓存 (cache.go:85-98)------GetString(key, getFunc) 模式:miss 时执行 getFunc 回源并 Put,TTL 来自 setting.CacheService.TTL(TTL=0 直接回源,缓存可整体关停)。它的典型用户是"数据不变、算一次很贵"的场景。两个工程细节:SafeCacheKey(cache.go:127-148)处理 memcached 的 250 字符与可见字符限制------key 合法就直拼(:s- 前缀),否则 sha256 哈希(:h- 前缀),超长问题只在注释里提醒调用方自查;SlowCacheThreshold = 30ms(cache.go:43-49)带 issue 链接 #33190------健康检查里回源超 30ms 即报慢,缓存系统的健康指标是"回源有多快",因为回源快说明基本没回源。
请求级 context 缓存 (context.go:20-43)------WithCacheContext(ctx) 在请求入口塞进一个 5 分钟 TTL 的 EphemeralCache,后续 GetWithContextCache 同请求内同 key 只回源一次。注释对 TTL 的解释很直白:"请求级缓存活过 5 分钟,大概率是被滥用了"。而函数上方的 FIXME 是全文最值得抄下来的警告:
go
// FIXME: in some cases, the "context cache" should not be used, because it has uncontrollable behaviors
// For example, these calls:
// * GetWithContextCache(TargetID) -> OtherCodeCreateModel(TargetID) -> GetWithContextCache(TargetID)
// Will cause the second call is not able to get the correct created target.
// UNLESS it is certain that the target won't be changed during the request, DO NOT use it.
同请求内"先读后写再读"会读到陈旧缓存 ------缓存失效发生在请求之外(写路径必须显式 cache.Remove),这是用不变量换性能的经典代价。
4.11 ER 级核心表关系与结构体对照
4.11.1 核心表关系图
下图覆盖权限判定与协作主链路涉及的表(mermaid erDiagram,基数关系全部由本章引用过的 struct 外键与唯一索引推得):
读图要点:权限是三层叠加 ------owner 直属(repository.owner_id,不入 access 表,见下)、显式授权(access 复合唯一 (user_id, repo_id),4.11.2 的 struct 注释)、组织间接授权(team → team_repo/team_unit,经 refreshAccesses 物化回 access)。organization 没有独立表 ------Organization 通过 func (Organization) TableName() string { return "user" }(models/organization/org.go:85-87)与 User 共表(OrgFromUser 直接做类型转换 (*Organization)(user)),所以图中所有 org 关系都落在 user 表的行上。issue 与 pull_request 是同表异体 :issue.is_pull 区分,PR 的扩展数据在 pull_request 表以 issue_id 一对一挂接------所以 Issue.PullRequest 是 xorm:"-" 懒加载。多对多全部中间表化 :label↔issue(issue_label)、user↔org(org_user)、user↔repo 协作(collaboration)、team↔repo(team_repo),中间表各自带唯一约束(如 TeamRepo 的 UNIQUE(s) 覆盖 (team_id, repo_id))防重复。
4.11.2 Access:权限物化表的完整注释
前文多处引用 access 表,这里给出 struct 与它的判定逻辑(models/perm/access/access.go:22-72):
go
// Access represents the highest access level of a user to the repository. The only access type
// that is not in this table is the real owner of a repository. In case of an organization
// repository, the members of the owners team are in this table.
type Access struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(s)"`
RepoID int64 `xorm:"UNIQUE(s)"`
Mode perm.AccessMode
}
注释交代了设计:owner 不在此表 (owner 权限由 repo.owner_id == user.id 在代码里直接判定,modeByOwnerAndAccess 的 userID == repo.OwnerID → AccessModeOwner 短路),组织 Owners 团队的成员在 此表------即"物化的是除 owner 之外的一切显式最高权限"。modeByOwnerAndAccess 的判定次序(access.go:38-72):可见性给 Read 起点 → owner 短路 Owner → db.Get[Access] 查物化记录取 Mode------读路径最多一次索引查询 ,代价由 refreshAccesses(access.go:88-130)在权限变更时的差量维护买单:读出现有记录与目标 accessMap 做三路 diff(toInsert/toUpdate/toDelete),只动必要的行,并在非私有非组织仓库上调高 minModeToKeep 削减无意义记录------读性能与写成本的置换,整个表就是这个置换的载体。这些逻辑只在协作者/团队/仓库属性变更时触发,不在请求路径上。
4.11.3 结构体摘录索引
五个核心结构体的原文摘录已在本章各节给出,此处汇总定位,便于对照阅读:
| 结构体 | 位置 | 摘录于 | 该摘录承载的知识点 |
|---|---|---|---|
User |
models/user/user.go:95-179 |
4.8.1 | tag 速查表样本:pk/UNIQUE/INDEX/DEFAULT/时间戳/xorm:"-" |
Repository |
models/repo/repo.go:153-221 |
4.8.2 | 冗余计数列 + 开放数虚拟列模式 |
Issue |
models/issues/issue.go:54-111 |
4.8.2 | xorm:"-" 三类用途、复合唯一 repo_index、三态 PinOrder |
Team |
models/organization/team.go:75-95 |
4.8.3 | 列名覆写 xorm:"'authorize'" |
Access |
models/perm/access/access.go:25-30 |
4.11.2 | 权限物化、owner 排除语义、差量维护 |
横向规律上,所有 struct 都满足 ID 主键 + UNIQUE 业务键 + INDEX 外键 + *_unix 时间戳 的四件套,xorm:"-" 承载一切内存态------这让 xorm 的 Sync 能可靠地从代码推导 DDL(4.7.5 节),也让 NamesToBean、fixtures、doctor 等按表名工作的工具零配置运转。整套泛型 CRUD(4.4)、事务传递(4.5)、分页(4.6)之所以能对全仓库几百个 struct 通用,正是因为表设计惯例先统一了。
本章小结 :xorm 在 Gitea 里不是被直接使用的库,而是被三层封装收窄过的基础设施------接口收窄(Engine/Session/SQLSession,4.1.2)、ctx 收窄(事务隐式传递 + contextSafetyCheck,4.5)、约束收窄(guidelines 的 WHERE/Sync 规矩与 MIGRATION-STRUCT-FROZEN,4.1.4/4.7.4)。理解这三层,第 1 章留下的"db 泛型层与事务传递"伏笔就闭环了:下一章看这些事务如何被 service 层编排成完整的业务流程。