Gitea源码深度剖析-第10章 异步基础设施:队列、缓存、Cron 调度、限流与全局锁
第 1 章 1.6.4 节在讲解 InitWebInstalled 启动顺序时,曾经短暂地停在三个调用上——cache.Init()、各业务模块里的 queue.CreateUniqueQueue(...)、以及排在最后一行的 cron.Init(ctx)——当时承诺"队列与缓存留到第 10 章讲透"。本章兑现这个承诺:把 Gitea 进程里所有"不直接服务于某个 HTTP 请求"的执行路径一次讲清楚,包括五块彼此独立又互相配合的基础设施:
| 模块 | 职责 | 源码位置 |
|---|---|---|
| 队列 | 把耗时、可重试、允许延迟的工作从请求路径上剥离 | modules/queue/ |
| 缓存 | 给昂贵的只读结果(提交计数、头像、权限)加一层可丢失的存储 | modules/cache/ |
| Cron | 按时间表触发全实例性的维护任务 | services/cron/ |
| 全局锁 | 跨 goroutine(可选跨实例)地互斥"同一件事" | modules/globallock/ |
| 限流/防抖 | 过载时快速失败:QoS 丢弃、并发信号量、批量防抖 | routers/common/qos.go 等散点(10.11) |
在进入细节之前,先做两件"Never assume, verify before claim"的校准工作。任务给定的若干前提在本仓库(module gitea.dev,28.x)中并不成立,本文一律按 grep 验证后的真实情况书写,这里集中列出,后文不再重复声明:
modules/cron不存在。Cron 调度只有一个包:services/cron/(内部文件cron.go、tasks.go、setting.go、tasks_basic.go、tasks_extended.go、tasks_actions.go)。全局唯一入口是cron.Init(ctx)。queue.CreateQueue/GetQueue不存在。真实的创建入口只有CreateSimpleQueue[T]与CreateUniqueQueue[T]两个泛型函数(modules/queue/manager.go:93/98);查询入口是Manager.GetManagedQueue(qid),按整数 qid 取,没有按名字查询的 API。- "notifications"、"api"、"prpatch" 这些队列名不存在。真实队列名以 10.6 节的实测清单为准(例如站内通知叫
notification-service,PR 变更检查叫pr_patch_checker)。 PersistableChannelQueue结构体不存在。getNewQueueFn的 default 分支注释写明level(leveldb,levelqueue,persistable-channel)全部落到 level 后端,旧版"channel + LevelDB 混合持久化队列"在当前版本由 level 实现承担(见 10.2.3)。HideNightly不存在(大小写不敏感全仓库 grep 无匹配)。任务调度没有"隐藏夜间任务"的概念,只有每个任务自己的Schedule字符串。SlowCacheThreshold不在 doctor 里。services/doctor的检查清单没有任何 cache/queue 项;该阈值的真实消费点是 admin 面板的TestCache与SelfCheck(见 10.8.6)。- 缓存接口叫
StringCache,没有Iterator。接口定义在modules/cache/string_cache.go,含Ping/Get/Put/Delete/IsExist/PutJSON/GetJSON/ChiCache八个方法。 - Cron 多实例防重锁是 globallock,不是 DB 行锁。
services/cron/tasks.go的RunWithUser用globallock.TryLock(ctx, "cron_task:"+name),锁的语义完全由modules/globallock承担(见 10.9.4)。
本章源码地图(下文所有摘录的坐标):
modules/queue/ queue.go base.go config.go manager.go workerqueue.go
workergroup.go backoff.go base_channel.go
base_levelqueue_common.go base_levelqueue.go
base_levelqueue_unique.go base_redis.go base_dummy.go
modules/setting/ queue.go cron.go cache.go global_lock.go
modules/cache/ cache.go string_cache.go context.go ephemeral.go
cache_redis.go cache_twoqueue.go
modules/globallock/ globallock.go locker.go memory_locker.go redis_locker.go
services/cron/ cron.go tasks.go setting.go tasks_basic.go
tasks_extended.go tasks_actions.go
routers/init.go InitWebInstalled(启动顺序)
custom/conf/ app.example.ini(配置示例)
10.1 队列体系全景:三层结构与七条术语
10.1.1 包文档就是设计文档
modules/queue/queue.go 的包注释是整个队列子系统最权威的说明,它定义了后文反复出现的七个术语。摘录如下(省略与术语无关的 import 行):
// Package queue implements a specialized concurrent queue system for Gitea.
//
// Terminology:
//
// 1. Item:
// - An item can be a simple value, such as an integer, or a more complex structure that has multiple fields.
// Usually a item serves as a task or a message. Sets of items will be sent to a queue handler to be processed.
// - It's represented as a JSON-marshaled binary slice in the queue
// - Since the item is marshaled by JSON, and JSON doesn't have stable key-order/type support,
// so the decoded handler item may not be the same as the original "pushed" one if you use map/any types,
//
// 2. Batch:
// - A collection of items that are grouped together for processing. Each worker receives a batch of items.
//
// 3. Worker:
// - Individual unit of execution designed to process items from the queue. It's a goroutine that calls the Handler.
// - Workers will get new items through a channel (WorkerPoolQueue is responsible for the distribution).
// - Workers operate in parallel. The default value of max workers is determined by the setting system.
//
// 4. Handler (represented by HandlerFuncT type):
// - It's the function responsible for processing items. Each active worker will call it.
// - If an item or some items are not successfully processed, the handler could return them as "unhandled items".
// In such scenarios, the queue system ensures these unhandled items are returned to the base queue after a brief delay.
// This mechanism is particularly beneficial in cases where the processing entity (like a document indexer) is
// temporarily unavailable. It ensures that no item is skipped or lost due to transient failures in the processing
// mechanism.
//
// 5. Base queue:
// - Represents the underlying storage mechanism for the queue. There are several implementations:
// - Channel: Uses Go's native channel constructs to manage the queue, suitable for in-memory queuing.
// - LevelDB: Especially useful in persistent queues for single instances.
// - Redis: Suitable for clusters, where we may have multiple nodes.
// - Dummy: This is special, it's not a real queue, it's a immediate no-op queue, which is useful for tests.
// - They all have the same abstraction, the same interface, and they are tested by the same testing code.
//
// 6. WorkerPoolQueue:
// - It's responsible to glue all together, using the "base queue" to provide "worker pool" functionality. It creates
// new workers if needed and can flush the queue, running all the items synchronously till it finishes.
// - Its "Push" function doesn't block forever, it will return an error if the queue is full after the timeout.
//
// 7. Manager:
// - The purpose of it is to serve as a centralized manager for multiple WorkerPoolQueue instances. Whenever we want
// to create a new queue, flush, or get a specific queue, we could use it.
//
// A queue can be "simple" or "unique". A unique queue will try to avoid duplicate items.
// Unique queue's "Has" function can be used to check whether an item is already in the queue,
// although it's not 100% reliable due to the lack of proper transaction support.
// Simple queue's "Has" function always returns "has=false".
几条值得停下来咀嚼的设计动机:
- Item 走 JSON 序列化。队列里存的是
[]byte,不是 Go 对象。包文档自己点破后果:用map/any做 item 类型时,出队 decode 回来的键序、数值类型可能和入队时不一样。所以真实业务队列的 item 都是结构体或基础类型(int64的仓库 ID、string的 PR ID、issueNotificationOpts结构体)。 - Handler 的"回执"设计:
func(...T) (unhandled []T)。处理失败的项不是丢掉,也不是在 handler 内部死循环重试,而是以unhandled名义交还给队列系统,由系统延迟一秒后重新入队。这一条直接支撑了 Gitea 里最脆弱的异步环节——索引器(见 10.7.3)。 - "不 100% 可靠"的去重是明写的契约:unique queue 的
Has是尽力而为,因为 channel/LevelDB/Redis 三者都没有跨"列表 + 集合"的事务。10.2.4 会看到 Redis 版本甚至把"宁可重复、不可丢失"写进了注释。 - Push 不永久阻塞:队列满时等
pushBlockTime(5 秒)后返回错误,绝不把调用方(往往是 HTTP 请求 goroutine)挂死。
对应到类型定义,整个包的公共出口只有三个(modules/queue/queue.go:66-68):
type HandlerFuncT[T any] func(...T) (unhandled []T)
var ErrAlreadyInQueue = util.NewAlreadyExistErrorf("already in queue")
10.1.2 三层结构
队列系统是清晰的三明治结构,自下而上:
- baseQueue(
base.go定义接口,四个实现):只负责"字节进、字节出",外加去重与长度查询; - WorkerPoolQueue[T](
workerqueue.go):泛型包装,负责序列化、worker 池、批量派发、flush、关停;这是业务代码唯一拿到手的对象; - Manager(
manager.go):进程级注册表,登记所有被创建出来的队列,供 admin 页面与gitea manager flush-queues枚举操作。
flowchart TB
subgraph callers["业务调用方(HTTP / cron / hook goroutine)"]
P1["prPatchCheckerQueue.Push(prID)"]
P2["issueQueue.Push(opts)"]
P3["hookQueue.Push(taskID)"]
end
subgraph mgr["Manager(modules/queue/manager.go)"]
M["Queues map[int64]ManagedWorkerPoolQueue<br/>qidCounter 自增分配 qid<br/>GetManager() / FlushAll() / AddManagedQueue()"]
end
subgraph wpq["WorkerPoolQueue[T](workerqueue.go / workergroup.go)"]
RUN["doRun() 主循环<br/>popItemChan ← baseQueue.PopItem<br/>batchBuffer 合并 → doDispatchBatchToWorker"]
BC["batchChan chan []T"]
WK["worker × N(doStartNewWorker)<br/>doWorkerHandle → safeHandler<br/>unhandled → 延迟回队"]
FL["flushChan(测试/admin flush-queues)"]
end
subgraph base["baseQueue 四实现(base.go 接口)"]
CH["baseChannel<br/>chan []byte + Set 去重<br/>内存、进程内"]
LV["baseLevelQueue(+unique)<br/>gitea.com/lunny/levelqueue<br/>LevelDB 持久化"]
RD["baseRedis(+unique)<br/>RPush/LPop + SAdd/SRem<br/>跨实例共享"]
DM["baseDummy<br/>空操作/立即执行<br/>仅测试"]
end
P1 & P2 & P3 --> M
M -.创建/登记.-> RUN
RUN --> BC --> WK
RUN --> FL
RUN -->|PopItem| CH
RUN -->|PopItem| LV
RUN -->|PopItem| RD
RUN -->|PushItem 直通| DM
三层之间的胶水是两个构造函数。业务侧永远只碰这一对(manager.go:92-100):
// CreateSimpleQueue creates a simple queue from global setting config provider by name
func CreateSimpleQueue[T any](ctx context.Context, name string, handler HandlerFuncT[T]) *WorkerPoolQueue[T] {
return createWorkerPoolQueue(ctx, name, setting.CfgProvider, handler, false)
}
// CreateUniqueQueue creates a unique queue from global setting config provider by name
func CreateUniqueQueue[T any](ctx context.Context, name string, handler HandlerFuncT[T]) *WorkerPoolQueue[T] {
return createWorkerPoolQueue(ctx, name, setting.CfgProvider, handler, true)
}
注意两者都不返回 error,而是失败时 log.Error 后返回 nil——这是 Gitea 一贯的取舍:队列配置错误属于启动期故障,调用方惯例是立刻检查 nil 并 log.Fatal/return errors.New(10.7 有五个真实例子)。createWorkerPoolQueue 的完整逻辑(manager.go:102-115):
func createWorkerPoolQueue[T any](ctx context.Context, name string, cfgProvider setting.ConfigProvider, handler HandlerFuncT[T], unique bool) *WorkerPoolQueue[T] {
queueSetting, err := setting.GetQueueSettings(cfgProvider, name)
if err != nil {
log.Error("Failed to get queue settings for %q: %v", name, err)
return nil
}
w, err := NewWorkerPoolQueueWithContext(ctx, name, queueSetting, handler, unique)
if err != nil {
log.Error("Failed to create queue %q: %v", name, err)
return nil
}
GetManager().AddManagedQueue(w)
return w
}
三步:读配置(10.5)、建队列、登记到 Manager。登记这一步让 admin 的队列页(routers/web/admin/queue.go)和 gitea manager flush-queues 能枚举到它——Manager.FlushAll 就是对每个登记队列调 FlushWithContext 并用 errors.Join 汇总错误(manager.go:81-90)。
10.2 baseQueue:四种存储后端的真实实现
10.2.1 接口与两个公共机关
base.go 是所有后端共享的骨架,接口只有六个方法:
var pushBlockTime = 5 * time.Second
type baseQueue interface {
PushItem(ctx context.Context, data []byte) error
PopItem(ctx context.Context) ([]byte, error)
HasItem(ctx context.Context, data []byte) (bool, error)
Len(ctx context.Context) (int, error)
Close() error
RemoveAll(ctx context.Context) error
}
pushBlockTime = 5 * time.Second 是全后端统一的"队列满"判定时长:PushItem 最多阻塞 5 秒,超时返回 context.DeadlineExceeded。业务层(见 10.7.3 的索引器)把这个错误翻译成"队列满,请调大 LENGTH"。
第二个机关是 popItemByChan:doRun 主循环是单 goroutine 的 select,而后端的 PopItem 是阻塞调用(LevelDB/Redis 还会退避等待),两者不能直接混用。于是用一个桥接 goroutine 把 PopItem 转成 channel 语义:
func popItemByChan(ctx context.Context, popItemFn func(ctx context.Context) ([]byte, error)) (chanItem chan []byte, chanErr chan error) {
chanItem = make(chan []byte)
chanErr = make(chan error)
go func() {
for {
it, err := popItemFn(ctx)
if err != nil {
close(chanItem)
chanErr <- err
return
}
if it == nil {
close(chanItem)
close(chanErr)
return
}
chanItem <- it
}
}()
return chanItem, chanErr
}
chanItem 关闭代表"队列被关闭了",chanErr 收到错误代表"出队出错了",主循环对两者分而治之(10.3.3)。第三个机关 baseQueueNotifiable 是 Redis 后端专用的唤醒信号:容量为 1 的 notifySignal channel,notifyPushItem 用非阻塞 select 投递,保证"有新数据"这个事实只记一个位而不堆积:
func (n *baseQueueNotifiable) notifyPushItem() {
select {
case n.notifySignal <- struct{}{}:
default:
}
}
10.2.2 baseChannel:内存实现与去重细节
base_channel.go 用一个带缓冲的 channel 加一个 container.Set[string] 做 unique 去重,去重的键就是序列化后的字节:
func (q *baseChannel) PushItem(ctx context.Context, data []byte) error {
if q.c == nil {
return errChannelClosed
}
if q.isUnique {
q.mu.Lock()
has := q.set.Contains(string(data))
q.mu.Unlock()
if has {
return ErrAlreadyInQueue
}
}
select {
case q.c <- data:
if q.isUnique {
q.mu.Lock()
q.set.Add(string(data))
q.mu.Unlock()
}
return nil
case <-time.After(pushBlockTime):
return context.DeadlineExceeded
case <-ctx.Done():
return ctx.Err()
}
}
细节一:先查 set、成功入 channel 之后才 Add 进 set。两步之间没有事务,理论上并发 Push 同一 item 可能双入——但去重本来就是"尽力而为"(包文档原话),且 mu 覆盖了各自临界区,实际窗口极小。
细节二:case <-time.After(pushBlockTime): return context.DeadlineExceeded 就是"队列满"错误的诞生地。channel 缓冲长度是配置项 LENGTH(默认 100000),装满后 Push 方等 5 秒。
细节三:PopItem 取出数据时同步从 set 中 Remove,所以 item 被 worker 消费后可以再次入队——unique 只防"在队列中重复",不防"重复执行"(这是 cron globallock 的活儿,见 10.9.4)。
HasItem 对 simple 队列恒返回 false, nil,兑现包文档的承诺。RemoveAll 把 channel 抽干并清空 set,供 admin 页面"清空队列"使用。
10.2.3 baseLevelQueue:LevelDB 持久化的两副面孔
LevelDB 后端分三个文件:base_levelqueue_common.go(公共部分,依赖 gitea.com/lunny/levelqueue v0.4.2)、base_levelqueue.go(simple)、base_levelqueue_unique.go(unique)。getNewQueueFn 的 default 分支把所有未匹配的类型名都路由到这里:
func getNewQueueFn(t string) (string, func(cfg *BaseConfig, unique bool) (baseQueue, error)) {
switch t {
case "dummy", "immediate":
return t, newBaseDummy
case "channel":
return t, newBaseChannelGeneric
case "redis":
return t, newBaseRedisGeneric
default: // level(leveldb,levelqueue,persistable-channel)
return "level", newBaseLevelQueueGeneric
}
}
这段 switch 承载了一个重要的历史迁移事实:配置里的 persistable-channel(旧版"channel 优先 + LevelDB 兜底"的混合队列)、leveldb、levelqueue 三个名字如今全部归一到 level。也就是说,当前版本不存在 channel/LevelDB 双写队列,持久化语义完全由 LevelDB 单后端承担。baseQueueType 被规范成 "level" 后,admin 队列页显示的 Type 也是它。
持久化带来的直接收益是重启恢复:PrepareLevelDB(common 文件)按 Datadir + QueueFullName 打开库,失败时最多重试 10 次(每次间隔 1 秒,应对文件锁未释放的竞态)。队列内容落盘,进程重启后 doRun 从 PopItem 继续读,未消费的 item 原地复活——10.6.6 的"重启恢复语义"就建立在这之上。
unique 版本的额外约束在 base_levelqueue_unique.go:LevelDB 没有 Redis 那样的原生 Set 结构,去重靠"查集合键是否已存在",查写两步用一把 muBase 互斥,文件头注释明确写着 UniqueQueue is not thread-safe——线程安全完全由 base 层自己加锁兜住,WorkerPoolQueue 只需按 unique 标志选对实现。
simple 版本则更简单:HasItem 直接返回 false(连查都不查),因为没有集合键可查。数据结构上,simple 用 levelqueue 的普通队列,unique 用它带成员集合的变体。
10.2.4 baseRedis:集群后端与"宁可重复不可丢失"
base_redis.go 是唯一面向多实例部署的后端,队列本体是 Redis List、去重集合是 Redis Set,键名来自 BaseConfig(10.5.4):
func (q *baseRedis) PushItem(ctx context.Context, data []byte) error {
_, err := backoffCall(ctx, backoffOptionsDefault(noNotifyChan, time.After(pushBlockTime)), func() (retry bool, ret any, err error) {
q.mu.Lock()
defer q.mu.Unlock()
cnt, err := q.client.LLen(ctx, q.cfg.QueueFullName).Result()
if err != nil {
return false, nil, err
}
if int(cnt) >= q.cfg.Length {
return true, nil, nil
}
if q.isUnique {
added, err := q.client.SAdd(ctx, q.cfg.SetFullName, data).Result()
if err != nil {
return false, nil, err
}
if added == 0 {
return false, nil, ErrAlreadyInQueue
}
}
retry, err = false, q.client.RPush(ctx, q.cfg.QueueFullName, data).Err()
if err == nil {
q.notifyPushItem()
}
return retry, nil, err
})
return err
}
逐段解读:
- 满队判定:
LLen >= cfg.Length时不报错而是retry=true,交给backoffCall按 50ms→2s 指数退避重试,整个重试窗口的上限就是time.After(pushBlockTime)——5 秒后backoffCall返回context.DeadlineExceeded(backoff.go:53-54的case <-opts.end)。所以"Redis 队列满"和"channel 队列满"返回同一个错误值,业务层无需区分。 - 先 SAdd 再 RPush:两步非原子。若 SAdd 成功、RPush 失败,set 里残留一个"幻影成员",之后同 item 永远被判
ErrAlreadyInQueue而再也进不了队列——这是没有事务的代价,也正是包文档说去重"not 100% reliable"的另一处体现。 - 成功后
notifyPushItem():唤醒可能正在backoffCall空转等待的PopItem。
PopItem 对称地用 LPop + SRem,且注释承认"数据已经出队,就算 SRem 失败也无能为力"——残留 set 成员会让该 item 无法再次入队,这是宁可少不可重的出队方向。而清空队列的方向恰好相反(RemoveAll,137-139 行):
c1 := q.client.Del(ctx, q.cfg.QueueFullName)
// the "set" must be cleared after the "list" because there is no transaction.
// it's better to have duplicate items than losing items.
c2 := q.client.Del(ctx, q.cfg.SetFullName)
先删 list 再删 set:如果进程在两步之间崩溃,set 残留 → 后续同 item 被误判"已在队列",表现为任务丢失;反之若先删 set,崩溃残留 list → 旧成员还在 set 里检查不到,表现为任务重复。作者的选择是:重复比丢失好。这句话是理解 Gitea 队列一致性哲学的钥匙——队列里跑的都是可重放的副作用(发通知、推 webhook、建索引),重复一次顶多浪费,丢失一次用户就看不到了。
连接建立时同样有容错(32-49 行):nosql.GetManager().GetRedisClient(cfg.ConnStr) 拿连接池,然后 Ping 最多重试 10 次、每次睡 1 秒——对应容器编排里 Redis 晚于 Gitea 启动的场景;10 秒后仍不通则建队失败,进而触发调用方的 log.Fatal。
10.2.5 baseDummy:测试专用的"假队列"
base_dummy.go 的所有操作都是空实现,PushItem 直接返回 nil。它有两个正式入口:配置 TYPE=dummy/immediate,以及 handler 为 nil 时强制启用(workerqueue.go:218-221):
if handler == nil {
log.Debug("Use dummy queue for %q because handler is nil and caller doesn't want to process the queue items", name)
queueSetting.Type = "dummy"
}
但 dummy 在 WorkerPoolQueue.Push 里有一个特殊旁路,它同时是全文件最长的一条 FIXME(workerqueue.go:167-176):
func (q *WorkerPoolQueue[T]) Push(data T) error {
if q.isBaseQueueDummy() && q.safeHandler != nil {
// FIXME: the "immediate" queue is only for testing, but it really causes problems because its behavior is different from a real queue.
// Even if tests pass, it doesn't mean that there is no bug in code.
if data, ok := q.unmarshal(q.marshal(data)); ok {
q.safeHandler(data)
}
}
return q.baseQueue.PushItem(q.ctxRun, q.marshal(data))
}
Push 时同步调用 handler(先 marshal 再 unmarshal 一次,刻意复刻真实队列的序列化往返,防止业务类型缺 JSON 标签这类 bug 被测试漏掉)。这个"立即执行"语义会带来两个连锁反应:其一,services/pull/check.go:111-114 在 testing 模式下必须绕开 globallock,否则 PR 检查会在同一 goroutine 里"自己等自己"造成死锁(10.9.5 详述);其二,FIXME 作者自己警告"测试通过不代表没 bug",因为异步时序在 immediate 队列下根本不存在。
baseQueueNotifiable 的 noNotifyChan(一个永远不会有数据的 nil channel)就是给不需要唤醒信号的调用点占位用的——nil channel 在 select 里永久阻塞,语义刚好是"永不唤醒"。
10.3 WorkerPoolQueue 与 worker pool:100ms 合并、动态扩容与优雅关停
10.3.1 结构与构造
WorkerPoolQueue[T] 的字段(workerqueue.go:22-45)值得完整过一遍,它们后面都会出场:
type WorkerPoolQueue[T any] struct {
ctxRun context.Context
ctxRunCancel process.FinishedFunc
shutdownDone chan struct{}
shutdownTimeout atomic.Int64 // in case some buggy handlers (workers) would hang forever, "shutdown" should finish in predictable time
origHandler HandlerFuncT[T]
safeHandler HandlerFuncT[T]
baseQueueType string
baseConfig *BaseConfig
baseQueue baseQueue
batchChan chan []T
flushChan chan flushType
isFlushing atomic.Bool
batchLength int
workerNum int
workerMaxNum int
workerActiveNum int
workerNumMu sync.Mutex
}
三个上下文变量成对出现:ctxRun 是整个队列的生命周期(由 process.GetManager().AddTypedContext 创建,进程管理器里可见"Queue: xxx"条目),shutdownTimeout 是关停宽限(默认 2 秒),batchChan 是 worker 与分发循环之间唯一的交接通道。workerNum/workerActiveNum/workerMaxNum 三个计数共用一把 workerNumMu,admin 队列页上"workers / active workers / max workers"三列直接来自这三个 getter。
构造函数(NewWorkerPoolQueueWithContext,217-259 行)里最值得讲的是 safeHandler 包装:
w.origHandler = handler
w.safeHandler = func(t ...T) (unhandled []T) {
defer func() {
// FIXME: there is no ctx support in the handler, so process manager is unable to restore the labels
// so here we explicitly set the "queue ctx" labels again after the handler is done
pprof.SetGoroutineLabels(w.ctxRun)
err := recover()
if err != nil {
log.Error("Recovered from panic in queue %q handler: %v\n%s", name, err, log.Stack(2))
}
}()
if w.origHandler != nil {
return w.origHandler(t...)
}
return nil
}
两层保护:recover() 把业务 handler 的 panic 拦截在 worker goroutine 内(一个 webhook handler 的 panic 不会带走整个进程);pprof.SetGoroutineLabels 恢复 goroutine 标签,让 gitea processes 输出里这些 goroutine 仍归属"Queue: webhook_sender"。panic 后返回 nil(视为全部处理成功、不回队)——因为无法判断半处理状态,盲目回队可能造成无限 panic 循环。
10.3.2 常量表:队列的行为参数全在这八行
workergroup.go 顶部的常量是队列行为的全部调校参数:
var (
noNotifyChan chan struct{}
infiniteTimerC chan time.Time
batchDebounceDuration = 100 * time.Millisecond
workerIdleDuration = 1 * time.Second
shutdownDefaultTimeout = 2 * time.Second
unhandledItemRequeueDuration atomic.Int64 // to avoid data race during test
)
func init() {
unhandledItemRequeueDuration.Store(int64(time.Second))
}
| 常量 | 值 | 作用 | 出场位置 |
|---|---|---|---|
batchDebounceDuration | 100ms | 攒不满一个 batch 时的最长等待 | 10.3.3 |
workerIdleDuration | 1s | worker 空闲多久后退出(但保留最后一个) | 10.3.3 |
shutdownDefaultTimeout | 2s | 关停时等 worker 收尾的默认宽限 | 10.3.5 |
unhandledItemRequeueDuration | 1s | 整批失败后的退避时长 | 10.3.4 |
pushBlockTime(base.go) | 5s | Push 队列满超时 | 10.2 |
backoffBegin/backoffUpper(backoff.go) | 50ms/2s | Redis pop/push 的指数退避区间 | 10.2.4 |
infiniteTimerC 是一个"永不触发"的 timer channel(time.After 到遥远未来),用作 select 的禁用分支——doRun 里 batchDispatchC <-chan time.Time = infiniteTimerC 表示"当前没有待触发的 debounce 定时器"。noNotifyChan 则是永不收到通知的 nil channel(见 10.2.5)。
10.3.3 doRun 主循环:批合并与 worker 弹性伸缩
doRun(workergroup.go:289-374)是每个队列的心脏,由 Run() 调用、在 graceful.RunWithCancel 的 goroutine 里终身运行。摘录主循环(省略日志行):
func (q *WorkerPoolQueue[T]) doRun() {
pprof.SetGoroutineLabels(q.ctxRun)
// ... defer 里的关停排空逻辑见 10.3.5 ...
wg := &workerGroup[T]{q: q}
wg.doPrepareWorkerContext()
wg.popItemChan, wg.popItemErr = popItemByChan(q.ctxRun, q.baseQueue.PopItem)
var batchDispatchC <-chan time.Time = infiniteTimerC
for {
select {
case flush := <-q.flushChan:
q.doDispatchBatchToWorker(wg, skipFlushChan)
q.doFlush(wg, flush)
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
case data, dataOk := <-wg.popItemChan:
if !dataOk {
return
}
if v, jsonOk := q.unmarshal(data); !jsonOk {
testRecorder.Record("pop:corrupted:%s", data) // in rare cases the levelqueue(leveldb) might be corrupted
continue
} else {
wg.batchBuffer = append(wg.batchBuffer, v)
}
if len(wg.batchBuffer) >= q.batchLength {
q.doDispatchBatchToWorker(wg, q.flushChan)
} else if batchDispatchC == infiniteTimerC {
batchDispatchC = time.After(batchDebounceDuration)
} // else: batchDispatchC is already a debounce timer, it will be triggered soon
case <-batchDispatchC:
batchDispatchC = infiniteTimerC
q.doDispatchBatchToWorker(wg, q.flushChan)
case err := <-wg.popItemErr:
if !q.isCtxRunCanceled() {
log.Error("Failed to pop item from queue %q (doRun): %v", q.GetName(), err)
}
return
}
}
}
批合并(batching)策略:从 base queue 弹出的 item 先进 wg.batchBuffer,满足其一即派发:
- 攒够
batchLength(配置BATCH_LENGTH,默认 20)——立即派发,背靠背吞吐; - 攒不够——启动一个 100ms 的 debounce 定时器(
batchDebounceDuration),到点强制派发。这就是"低负载时通知最多延迟 100ms、高负载时每批 20 条"的行为来源,两个方向的上界都明确。
注意 debounce 只启动一次(batchDispatchC == infiniteTimerC 判断),后续弹出的 item 搭已有时钟的便车,不会反复重置——批间隔不会因为持续有数据而无限后移(这是与"最后写入后 100ms"式 debounce 的关键差别)。
worker 弹性伸缩在 doDispatchBatchToWorker(50-90 行):
full := false
select {
case q.batchChan <- batch:
default:
full = true
}
q.workerNumMu.Lock()
noWorker := q.workerNum == 0
if full || noWorker {
if q.workerNum < q.workerMaxNum || noWorker && q.workerMaxNum <= 0 {
q.workerNum++
q.doStartNewWorker(wg)
}
}
q.workerNumMu.Unlock()
if full {
select {
case q.batchChan <- batch:
case flush := <-flushChan:
q.doWorkerHandle(batch)
q.doFlush(wg, flush)
case <-q.ctxRun.Done():
wg.batchBuffer = batch // return the batch to buffer, the "doRun" function will handle it
}
}
batchChan 是无缓冲 channel,派发是"尝试塞给空闲 worker":塞得进说明有 worker 正等着,塞不进(full)或当前零 worker(noWorker)就考虑起新 worker——上限 workerMaxNum(配置 MAX_WORKERS,默认 NumCPU/2 夹在 1..10),workerMaxNum <= 0 视为无上限(代码里 noWorker && q.workerMaxNum <= 0 分支,admin 测试队列就把它设成负值再用 SetWorkerMaxNumber(5) 调整)。塞不进且不能起新 worker 时,第二段 select 阻塞等待:worker 腾出手就递过去,同时不忘监听 flush 与关停。作者在 65-69 行留下 TODO 承认 workerNum 存在数据竞态窗口,但判断"只要新 worker 很快会被启动就不必大改"。
worker 的生与死在 doStartNewWorker(157-194 行),核心是一个空闲检测 ticker:
t := time.NewTicker(workerIdleDuration)
// ...
for keepWorking {
select {
case <-wp.ctxWorker.Done():
stopWorking()
case batch, ok := <-q.batchChan:
if !ok {
stopWorking()
continue
}
q.doWorkerHandle(batch)
// reset the idle ticker, and drain the tick after reset in case a tick is already triggered
resetIdleTicker(t, workerIdleDuration) // key code for TestWorkerPoolQueueWorkerIdleReset
case <-t.C:
q.workerNumMu.Lock()
keepWorking = q.workerNum <= 1 // keep the last worker running
if !keepWorking {
q.workerNum--
}
q.workerNumMu.Unlock()
}
}
worker 每处理完一批就重置 1 秒空闲计时(resetIdleTicker 还会排掉已经入队的旧 tick,避免误杀);连续空闲 1 秒则退出,但 keepWorking = q.workerNum <= 1——永远保留最后一个 worker,保证队列始终有人接活,不会出现"0 worker 阻塞在派发、无人能被唤醒"的死局。
10.3.4 doWorkerHandle:失败回执、整批退避与回队
worker 拿到批后的处理(workergroup.go:94-127),这是 Handler 语义的落地处:
func (q *WorkerPoolQueue[T]) doWorkerHandle(batch []T) {
q.workerNumMu.Lock()
q.workerActiveNum++
q.workerNumMu.Unlock()
defer func() {
q.workerNumMu.Lock()
q.workerActiveNum--
q.workerNumMu.Unlock()
}()
unhandled := q.safeHandler(batch...)
// if none of the items were handled, it should back-off for a few seconds
// in this case the handler (eg: document indexer) may have encountered some errors/failures
if len(unhandled) == len(batch) && unhandledItemRequeueDuration.Load() != 0 {
if q.isFlushing.Load() {
return // do not requeue items when flushing, since all items failed, requeue them will continue failing.
}
log.Error("Queue %q failed to handle batch of %d items, backoff for a few seconds", q.GetName(), len(batch))
select {
case <-q.ctxRun.Done():
case <-time.After(time.Duration(unhandledItemRequeueDuration.Load())):
}
}
for _, item := range unhandled {
if err := q.Push(item); err != nil {
if !q.basePushForShutdown(item) {
log.Error("Failed to requeue item for queue %q when calling handler: %v", q.GetName(), err)
}
}
}
}
三条规则:
workerActiveNum计数随处理起落,admin 页的 active workers 列就是它;- 整批失败才退避:只有
len(unhandled) == len(batch)(一条都没处理成功)才睡unhandledItemRequeueDuration(1 秒)——这是"索引器整个宕了"的场景,立刻回队只会空转刷日志;部分失败则立即回队,因为多半是单条数据的问题。退避期间 flush 中的失败批不回队(isFlushing分支),否则 flush 永远等不到空队列; - 回队走
q.Push,若 Push 失败(比如队列满/关停),basePushForShutdown兜底——绕过 worker pool 直接写 base queue(10.3.5)。
注意退避用的是"睡 1 秒"而非 backoff.go 的指数退避——backoffCall 只服务 Redis 后端的 I/O 重试,两者层次不同:前者是业务级失败(handler 返回 unhandled),后者是传输级失败(Redis 命令没打进去)。
10.3.5 关停序列:与 graceful 的三段式配合
graceful manager(modules/graceful/context.go)的关停是三段式:ShutdownContext 取消(给正常收尾机会)→ HammerContext(可用但应在尽快退出的阶段)→ TerminateContext(强杀)。队列与它的配合靠 RunWithCancel——manager.go:67-74 的 RunWithCancel(rc) 把 rc.Cancel 注册进 RunAtShutdown,即 ShutdownContext 一取消,队列的 Cancel()(= ctxRunCancel)立刻被调用。
doRun 的 defer(299-335 行)是关停的全部排空逻辑,摘录:
defer func() {
q.ctxRunCancel()
// drain all data on the fly
// since the queue is shutting down, the items can't be dispatched to workers because the context is canceled
// it can't call doWorkerHandle either, because there is no chance to push unhandled items back to the queue
var unhandled []T
close(q.batchChan)
for batch := range q.batchChan {
unhandled = append(unhandled, batch...)
}
unhandled = append(unhandled, wg.batchBuffer...)
for data := range wg.popItemChan {
if v, ok := q.unmarshal(data); ok {
unhandled = append(unhandled, v)
}
}
shutdownTimeout := time.Duration(q.shutdownTimeout.Load())
if shutdownTimeout != 0 {
// if there is a shutdown context, try to push the items back to the base queue
q.basePushForShutdown(unhandled...)
workerDone := make(chan struct{})
// the only way to wait for the workers, because the handlers do not have context to wait for
go func() { wg.wg.Wait(); close(workerDone) }()
select {
case <-workerDone:
case <-time.After(shutdownTimeout):
log.Error("Queue %q is shutting down, but workers are still running after timeout", q.GetName())
}
} else {
// if there is no shutdown context, just call the handler to try to handle the items. if the handler fails again, the items are lost
q.safeHandler(unhandled...)
}
close(q.shutdownDone)
}()
逐行拆解这个"优雅"到底优雅在哪:
- 三路收集在途数据:
batchChan里已派发未被取走的批(close后 range 抽干)、batchBuffer里攒了但没派发的、popItemChan里弹出但还没进 buffer 的——一个 item 都不许蒸发; shutdownTimeout != 0(默认 2s)时把这些 item 直接写回 base queue(basePushForShutdown),LevelDB/Redis 后端上等于落盘,下次启动接着跑;channel 后端上退回内存缓冲。同时等所有 worker 收尾,最多等shutdownTimeout,超时记 error——这就是ShutdownWait(timeout)(workerqueue.go:194-198,写入 timeout 后 cancel 并阻塞等shutdownDone)承诺的"predictable time";shutdownTimeout == 0的分支是ShutdownWait(0)的语义:不写回,直接当批调用 handler 处理一遍,失败即丢——这是调用方主动选择的"宁可丢不可拖"(10.7.4 索引器超时路径用的就是它)。
basePushForShutdown(132-146 行)再给一次机会:写 base queue 时单独开 context.WithTimeout(背景ctx, shutdownTimeout)——注意刻意不用已取消的 ctxRun,否则写回必然立刻失败;注释也承认"如果还有错误,队列无能为力,只能丢掉这些 item"。
10.3.6 Flush:注释明说"仅用于测试"
FlushWithContext 是 gitea manager flush-queues 与集成测试的底层机制,语义是"把队列里所有 item 同步消费完"。workerqueue.go:99-137 负责把 flush 请求投进 flushChan 并等待回执(flushType.c 关闭代表完成),超时用与 Push 相同的 5 秒外沿机制。真正的执行在 doFlush(workergroup.go:198-275),关键段:
// drain the popItem channel
emptyCounter := 0
for {
select {
case <-q.ctxRun.Done():
log.Debug("Queue %q is shutting down", q.GetName())
return
case data, dataOk := <-wg.popItemChan:
if !dataOk {
return
}
emptyCounter = 0
if v, jsonOk := q.unmarshal(data); !jsonOk {
continue
} else {
q.doWorkerHandle([]T{v})
}
case err := <-wg.popItemErr:
if !q.isCtxRunCanceled() {
log.Error("Failed to pop item from queue %q (doFlush): %v", q.GetName(), err)
}
return
case <-time.After(20 * time.Millisecond):
// There is no reliable way to make sure all queue items are consumed by the Flush, there always might be some items stored in some buffers/temp variables.
// If we run Gitea in a cluster, we can even not guarantee all items are consumed in a deterministic instance.
// Luckily, the "Flush" trick is only used in tests, so far so good.
if cnt, _ := q.baseQueue.Len(q.ctxRun); cnt == 0 && len(wg.popItemChan) == 0 {
emptyCounter++
}
if emptyCounter >= 2 {
return
}
}
}
三个要点:
- 超时 < 0 的语义是丢弃(198 行之前
flush.timeout < 0分支直接抽干所有通道并返回),对应FlushAll负超时"discard all items"的文档; - 判空靠 20ms 空轮询连续两次(
emptyCounter >= 2)——因为 base queue 长度归零与"所有 item 都进了 handler"之间没有原子性,必须等两个静默窗口才算数; - 源码注释自己承认不可靠:"Flush trick is only used in tests"。它不为集群设计(多实例各有各的队列消费者,谁也说不准 item 在谁手里),生产用途只有运维手动清队列(此时 items 是否 100% 消费完没人较真)。
flush 前 doRun 先调 doDispatchBatchToWorker(wg, skipFlushChan)(传入那个"永远读不到 flush"的占位 channel),保证"flush 期间至少有一个 worker 活着",否则 doFlush 抽干出来的数据无人可派。isFlushing 标志则如 10.3.4 所述,抑制 flush 期间的失败回队。
10.4 队列的使用姿势:真实业务调用与错误处理分类
10.4.1 消费者的标准启动三件套
每个队列消费者在自己的 Init() 里做三件事:建队列(失败即 log.Fatal 或返回 error)、go RunWithCancel(q) 启动消费、必要时补一条重启恢复路径。以最简单的 modules/indexer/stats/queue.go(全文 49 行)为范本:
// statsQueue represents a queue to handle repository stats updates
var statsQueue *queue.WorkerPoolQueue[int64]
// handle passed PR IDs and test the PRs
func handler(items ...int64) []int64 {
for _, opts := range items {
if err := indexer.Index(opts); err != nil {
if !setting.IsInTesting {
log.Error("stats queue indexer.Index(%d) failed: %v", opts, err)
}
}
}
return nil
}
func initStatsQueue() error {
statsQueue = queue.CreateUniqueQueue(graceful.GetManager().ShutdownContext(), "repo_stats_update", handler)
if statsQueue == nil {
return errors.New("unable to create repo_stats_update queue")
}
go graceful.GetManager().RunWithCancel(statsQueue)
return nil
}
这个模板里的每个成分都有来历:
- ctx 传
graceful.GetManager().ShutdownContext():队列生命周期从属于关停信号,CreateXxxQueue内部用它派生ctxRun; - handler 返回
nil:表示"全处理完了,不回队"。indexer.Index失败只记日志——仓库统计索引丢了不致命,下一次 push 或check_repo_statscron 还会再触发; go RunWithCancel(statsQueue):WorkerPoolQueue实现了RunCanceler接口(Run()/Cancel()),RunWithCancel让Cancel挂进 RunAtShutdown(10.3.5);go关键字让 Init 不阻塞。
10.4.2 生产侧:PR 检查队列的三段错误处理
生产者侧最讲究的是 services/pull/check.go:68-73:
func realAddPullRequestToCheckQueue(prID int64) {
err := prPatchCheckerQueue.Push(strconv.FormatInt(prID, 10))
if err != nil && !errors.Is(err, queue.ErrAlreadyInQueue) {
log.Error("Error adding %v to the pull requests check queue: %v", prID, err)
}
}
三类错误三种态度,这是全仓库统一的模式:
| Push 错误 | 含义 | 处理 |
|---|---|---|
nil | 入队成功 | 无 |
queue.ErrAlreadyInQueue | unique 队列里已有同 item | 正常情况,直接吞掉(重复推送本就该被去重) |
context.DeadlineExceeded | 队列满,等了 5 秒 | 记 error 日志;索引器场景另有提示与重试(10.4.3) |
| 其他(连接失败等) | 后端故障 | log.Error |
同类模式再举三处,均为实测:modules/indexer/stats/queue.go:42(if err != queue.ErrAlreadyInQueue { return err })、services/webhook/deliver.go:106-112(enqueueHookTask 把 ErrAlreadyInQueue 归一成 nil)、services/repository/push.go 的 PushUpdates。而 services/uinotification/notify.go 里所有 _ = ns.issueQueue.Push(opts) 是最激进的一档——连错误都不看:站内通知丢了顶多红点晚亮,不值得为它做任何降级逻辑。错误处理的严格程度与"丢一条的代价"严格成正比,这是读懂 Gitea 各调用点差异的钥匙。
10.4.3 索引器队列:满队时的分级重试
modules/indexer/issues/util.go:174 的 pushIssueIndexerQueue 是唯一对 DeadlineExceeded 做了复杂处理的调用点:
func pushIssueIndexerQueue(indexerMetadata *IndexerMetadata, ... ) {
queue := getIssueIndexerQueue()
if queue == nil { // ... warn 并返回
}
if err := queue.Push(indexerMetadata); err != nil {
if errors.Is(err, queue.ErrAlreadyInQueue) {
return nil
}
if errors.Is(err, context.DeadlineExceeded) {
log.Warn("... increase the queue size ...")
// 仅 contextWithKeepRetry(后台任务)才循环重试
}
}
}
(此段为忠实转述,省略与主旨无关的参数。)要点:队列满的 warn 文案直接指导运维改 [queue.issue_indexer].LENGTH;而是否"死磕重试"取决于调用上下文——后台全量重建任务(keep retry)会循环重压,单次用户操作只记警告,避免把用户请求拖进 5 秒 × N 的泥潭。配套的 getIssueIndexerQueueHandler 返回 unhandled 让 worker pool 走 10.3.4 的回队路径。
对照组是 modules/indexer/code/indexer.go:138——代码索引器的 handler 恒返回 nil,源码注释给出了完全相反的理由:
// do not re-queue the failed items, otherwise some broken repo will block the queue
一个坏仓库如果每批都失败,回队机制会让它永远霸占队列头,健康仓库的索引更新全部饿死。回队是给"暂时不可用的外部依赖"(如启动中的 Elasticsearch)设计的,不是给"永久损坏的单条数据"设计的——两种失败必须由 handler 作者自己甄别,队列系统无法代劳。
10.4.4 重启恢复:三种恢复策略
队列 + 持久化后端只是提供了"数据还在"的条件,把它变回可消费状态需要业务自己补 populating 逻辑。仓库里有三种策略:
策略一:启动时扫描数据库回灌(webhook)。services/webhook/deliver.go:286 起一条 RunWithShutdownContext(populateWebhookSendingQueue),循环调 FindUndeliveredHookTaskIDs 分页查出所有 is_delivered=false 的 hook task 并逐个 Push。投递状态存在数据库,队列只是加速器——即使队列数据全丢,重启照样补齐。
策略二:定时任务兜底重建(PR 检查)。services/pull/check.go:529 创建 pr_patch_checker 队列后,RunWithShutdownContext(InitializePullRequests) 在启动时把所有状态为 Checking/Merging 的 PR 重新入队——状态机在 DB,队列可抛弃。
策略三:队列自身持久化即恢复(level 后端 + 自身扫描)。modules/indexer/issues/indexer.go:64 的 InitIssueIndexer 顺序很讲究:先建队列、再建索引器(globalIndexer 是 atomic.Pointer,dummyIndexer 兜底——索引器没就绪期间所有查询走空实现),队列 handler 在索引器不可用时靠 unhandled 回队自愈;若发现索引文件不存在则 populate(同步/异步 reindex),启动超时则 issueIndexerQueue.ShutdownWait(5 * time.Second) 后 log.Fatal——宁可启动失败也不带着一个半死不活的索引器服务用户。
services/automerge/automerge.go:37 的 populateRecentAutoMergeItems 还展示了第四种变体:限定时间窗回灌(只恢复近 24 小时内排程的自动合并),过期任务本就不该执行,回灌纯属浪费。
10.5 [queue] 配置:GetQueueSettings 的映射链
10.5.1 结构与默认值
队列配置的类型是 modules/setting/queue.go 的 QueueSettings,八个字段全部对应 INI 键(除 Name 是内部字段):
type QueueSettings struct {
Name string // not an INI option, it is the name for [queue.the-name] section
Type string
Datadir string
ConnStr string // for leveldb or redis
Length int // max queue length before blocking
QueueName, SetName string // the name suffix for storage (db key, redis key), "set" is for unique queue
BatchLength int
MaxWorkers int
}
GetQueueSettings(rootCfg, name)(30-98 行)的执行顺序决定了一切:
queueSettingsDefault := QueueSettings{
Type: "level", // dummy, channel, level, redis
Datadir: "queues/common", // relative to AppDataPath
Length: 100000, // queue length before a channel queue will block
QueueName: "_queue",
SetName: "_unique",
BatchLength: 20,
MaxWorkers: runtime.NumCPU() / 2,
}
if queueSettingsDefault.MaxWorkers < 1 {
queueSettingsDefault.MaxWorkers = 1
}
if queueSettingsDefault.MaxWorkers > 10 {
queueSettingsDefault.MaxWorkers = 10
}
- 硬编码默认:
TYPE=level、DATADIR=queues/common、LENGTH=100000、BATCH_LENGTH=20、MAX_WORKERS=NumCPU/2(夹在 1..10——半数核数、上限 10,因为队列 worker 是批处理型,过多只会挤占 HTTP goroutine);QUEUE_NAME=_queue/SET_NAME=_unique是存储键后缀; - 默认值经
json.Marshal/Unmarshal深拷贝(49-54 行,手写逐字段拷贝太啰嗦,借 JSON 序列化一步到位); - 先映射公共节
[queue],再映射具名节[queue.<name>]——后者覆盖前者,实现"全局默认 + 单队列例外":
cfg.Name = name
if sec, err := rootCfg.GetSection("queue"); err == nil {
if err = sec.MapTo(&cfg); err != nil { /* ... */ }
}
if sec, err := rootCfg.GetSection("queue." + name); err == nil {
if err = sec.MapTo(&cfg); err != nil { /* ... */ }
if sec.HasKey("CONN_STR") {
cfg.ConnStr = sec.Key("CONN_STR").String()
}
}
具名节里 CONN_STR 有显式特判读取(68-70 行)——因为 MapTo 对空值的处理可能覆盖公共节已合并的值,这里加保险;
DATADIR相对路径补上AppDataPath前缀并转斜杠(73-79 行);- redis 连接串三级回退(81-85 行,注释原话):
// redis fallback order: [queue]/[queue.name] CONN_STR (already merged above) wins,
// then the shared [redis] conn, then the built-in localhost default for compatibility
if cfg.Type == "redis" && cfg.ConnStr == "" {
cfg.ConnStr = util.IfZero(Redis.ConnStr, "redis://127.0.0.1:6379/0")
}
即:本队列的 CONN_STR → 全局 [redis] CONN_STR → 内置 redis://127.0.0.1:6379/0。共享连接的约定让"一个 Redis 同时当缓存和队列后端"只需要配 [redis] 一处;
LENGTH/MaxWorkers/BatchLength若仍 ≤0 则回退默认(87-95 行)。
10.5.2 旧键迁移:LoadQueueSettings 的 Fatal 守门
LoadQueueSettings(100-123 行,由 setting.go:237 在启动早期调用)不读任何新配置,它只做一件事——检查 8 个已删除的旧键,发现任何一个就 log.Fatal:
handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_TYPE")
handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_BATCH_NUMBER")
handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_DIR")
handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "ISSUE_INDEXER_QUEUE_CONN_STR")
handleOldLengthConfiguration(rootCfg, "issue_indexer", "indexer", "UPDATE_BUFFER_LEN")
handleOldLengthConfiguration(rootCfg, "mailer", "mailer", "SEND_BUFFER_LEN")
handleOldLengthConfiguration(rootCfg, "pr_patch_checker", "repository", "PULL_REQUEST_QUEUE_LENGTH")
handleOldLengthConfiguration(rootCfg, "mirror", "repository", "MIRROR_QUEUE_LENGTH")
if hasOld {
log.Fatal("Please update your app.ini to remove deprecated config options")
}
这张表本身就是一部队列配置迁移史:[indexer].ISSUE_INDEXER_QUEUE_* → [queue.issue_indexer]、[mailer].SEND_BUFFER_LEN → [queue.mail].LENGTH、[repository].MIRROR_QUEUE_LENGTH → [queue.mirror].LENGTH。Gitea 对 INI 旧键的态度是"拒绝静默忽略"——留着旧键意味着用户以为自己调了参数而实际没生效,Fatal 逼用户正视。同类机制还有 modules/setting/task.go 中废弃的 [task].QUEUE_TYPE → [queue.task].TYPE(v1.19.0 起,带 FIXME: INI-MUST-SIDE-EFFECT 注释的迁移代码)。
10.5.3 app.example.ini 的 [queue] 节
custom/conf/app.example.ini:1666 起的注释块(节选,; 表示默认注释):
;[queue]
;TYPE = persistable-channel
;DATADIR = queues/
;LENGTH = 100000
;BATCH_LENGTH = 20
;CONN_STR =
;QUEUE_NAME = _queue
;SET_NAME = _unique
;MAX_WORKERS =
示例文件与代码默认值存在两处有意的差异,需要向读者点明:
- 示例写
TYPE=persistable-channel,代码默认level。原因见 10.2.3:persistable-channel落在getNewQueueFn的 default 分支、被规范化为level,两种写法在当前版本行为完全一致(都只用 LevelDB),示例保留旧名是兼容惯性; DATADIR=queues/(示例)vsqueues/common(代码)。示例是"根目录"写法,代码默认指向common子目录;只要用户没有显式写DATADIR,实际生效的是代码默认值。
MAX_WORKERS 留空即走 NumCPU/2(1..10)。共享 Redis 连接另有 ;[redis] 节(app.example.ini:1653,CONN_STR = redis://127.0.0.1:6379/0),被 10.5.1 第 5 条的回退链引用。
10.5.4 键名生成:toBaseConfig
config.go 的 toBaseConfig 决定数据在 LevelDB/Redis 里叫什么:
baseConfig.QueueFullName = managedName + queueSetting.QueueName
baseConfig.SetFullName = baseConfig.QueueFullName + queueSetting.SetName
if baseConfig.SetFullName == baseConfig.QueueFullName {
baseConfig.SetFullName += "_unique"
}
以 webhook_sender 为例:QueueFullName = webhook_sender_queue,SetFullName = webhook_sender_queue_unique。第三个 if 是防御——若某个配置把 SET_NAME 设成空串,unique 集合键会退化成与队列键同名,两者互相覆盖,这里强制补 _unique 后缀避免事故。DataFullDir 由调用方保证绝对路径(GetQueueSettings 第 4 步已拼好)。
LevelDB 后端按 Datadir + QueueFullName 落目录文件,Redis 后端直接拿这两个字符串当 key——所以改队列名等于丢数据(旧键成为孤儿),升级时改名需人工清理。
10.6 命名队列清单:16 个真实队列与业务对应表
全仓库 CreateSimpleQueue|CreateUniqueQueue 调用点 grep 实测共 16 处(排除测试与示例),逐一核对后的完整清单:
| # | 队列名 | 类型 | item 类型 | 创建位置(文件:行) | 业务含义 |
|---|---|---|---|---|---|
| 1 | webhook_sender | unique | int64(hook task ID) | services/webhook/deliver.go:281 | Webhook 投递 |
| 2 | notification-service | simple | issueNotificationOpts | services/uinotification/notify.go:46 | 站内通知红点 |
| 3 | task | simple | —(services/task) | services/task/task.go:42 | 管理后台异步任务 |
| 4 | mirror | unique | —(镜像 ID) | services/mirror/queue.go:43 | 镜像同步 |
| 5 | branch_sync | unique | — | services/repository/branch.go:676 | 分支同步 |
| 6 | mail | simple | 邮件消息 | services/mailer/mailer.go:48 | 邮件发送 |
| 7 | pr_auto_merge | unique | — | services/automerge/automerge.go:37 | 排程自动合并 |
| 8 | repo-archive | unique | — | services/repository/archiver/archiver.go:283 | 仓库压缩包生成 |
| 9 | tag_sync | unique | — | services/release/tag.go:45 | 标签同步 |
| 10 | repo_license_updater | unique | — | services/repository/repository.go:112 | 许可证识别更新 |
| 11 | push_update | simple | []*PushUpdateOptions(整切片入队) | services/repository/push.go:34 | push 后置事件分发 |
| 12 | pr_patch_checker | unique | string(PR ID 十进制串) | services/pull/check.go:529 | PR 可合并性检查 |
| 13 | repo_stats_update | unique | int64(仓库 ID) | modules/indexer/stats/queue.go:32 | 仓库统计索引 |
| 14 | issue_indexer | unique | *IndexerMetadata{ID, IsDelete, IDs} | modules/indexer/issues/indexer.go:70 | Issue/PR 全文索引 |
| 15 | code_indexer | unique | —(仓库 ID) | modules/indexer/code/indexer.go:141 | 代码内容索引 |
| 16 | actions_ready_job | unique | — | services/actions/init.go:63 | Actions 就绪任务 |
几条校正与观察(对应本章开头的证伪清单第 2、3 条):
- 没有
notifications、api、prpatch队列——站内通知队列的正式名是notification-service,PR 检查是pr_patch_checker,且没有任何以api命名的队列(API 层的耗时操作走的是push_update与各业务队列); - 12 个 unique、4 个 simple。凡是"同 ID 重复入队无意义"的场景(同一个 PR 检查两次、同一个仓库统计两次)一律 unique;纯流水型(邮件、通知、push 批次、管理后台任务)用 simple,因为每一条都是独立事件,去重反而会吞事件;
- item 类型选得很克制:
int64/string/小结构体,绝无map/any(呼应 10.1.1 的 JSON 稳定性警告)。push_update是唯一的切片 item——git post-receive hook 一次推多个 ref,整批作为一个原子事件入队(routers/private/hook_post_receive.go:109调PushUpdates); pr_patch_checker用string而非int64:unique 去重按序列化字节比较,"123"与"123"相同,123与123.0不同——选 string 同时规避了 JSON 数字精度的潜在坑(大 ID 在 JS 系消费方的眼里)。
命名队列与配置的对应关系:每个名字对应一节 [queue.<名字>],不写则继承 [queue] 公共节,再不写走代码默认(10.5.1)。例如想让 webhook 队列走 Redis:
[queue.webhook_sender]
TYPE = redis
LENGTH = 10000
10.7 后台触发源全景与通知全链路
10.7.1 后台触发源全景表
一个"异步工作"可能从五个地方被点着,全景如下:
| 触发源 | 典型入口 | 落到哪 | 生命周期管理 |
|---|---|---|---|
| 进程启动 | InitWebInstalled(routers/init.go:115) | 各 Init() 建队列 + go RunWithCancel;cron.Init(ctx)(:173,最后一行);cache.Init(:129) | graceful 管理,随 ShutdownContext 收敛 |
| HTTP 请求 | web 路由 / private 路由 | queue.Push(同步入队即返回)、cache.Put | 请求 goroutine 不等待消费 |
| git push | routers/private/hook_post_receive.go post-receive → PushUpdates(:109);分支新建 → branch.go:134 | push_update、repo_stats_update、issue_indexer、pr_patch_checker 等批量入队 | hook 进程只负责点火 |
| Cron 调度 | gocron 到点 / DoRunAtStart 启动即跑 | 任务函数内部再推队列(如 update_mirrors → mirror 队列) | scheduler.Shutdown() 挂在 RunAtShutdown |
| 队列消费者 | worker 调 handler | handler 内部再推下游队列(通知 → NotificationCountChange → websocket 推送) | worker pool 自治(10.3) |
启动顺序中与本章直接相关的骨架(routers/init.go 省略无关行):
func InitWebInstalled(ctx context.Context) {
// ...
mailer.NewContext(ctx)
mustInit(cache.Init) // :129 缓存最先就绪
mustInit(feed_service.Init)
mustInit(uinotification.Init) // :131 注册通知 notifier + 建 notification-service 队列
// ...
// Booting long running goroutines.
mustInit(indexer_service.Init) // :151 索引器 + issue_indexer/code_indexer 队列
mirror_service.InitSyncMirrors() // :153 镜像队列
mustInit(webhook.Init) // :154 webhook_sender 队列 + 回灌
mustInit(pull_service.Init) // :155 pr_patch_checker 队列 + PR 状态恢复
mustInitCtx(ctx, automerge.Init) // :156 自动合并队列 + 24h 回灌
mustInit(task.Init) // :157 管理后台任务队列
// ...
mustInitCtx(ctx, actions_service.Init) // :168 actions_ready_job 队列
// Finally start up the cron
cron.Init(ctx) // :173 cron 殿后
}
注释 // Finally start up the cron 不是随便写的:cron 必须最后启动——它的 RunAtStart 任务(如 check_repo_stats)会立即推队列、查全表,若排在索引器/镜像服务之前,任务要么空转要么撞 nil 指针。这个顺序是启动依赖的显式编码。
10.7.2 notify 总线:11 个 notifier 的多播
services/notify/notify.go 是一条极简的同步多播总线:
var notifiers []Notifier
// RegisterNotifier providers method to receive notify messages
func RegisterNotifier(notifier Notifier) {
go notifier.Run()
notifiers = append(notifiers, notifier)
}
每个事件函数都是同一模板——for _, notifier := range notifiers { notifier.CreateIssueComment(...) }。同步循环、逐个调用,所以每个 notifier 自己决定"快进快出(只推队列)"还是"同步做重活";RegisterNotifier 里的 go notifier.Run() 让消费循环在注册瞬间就开始跑。
grep 实测注册点 11 处(含测试 1 处),生产路径 10 个 notifier:
| notifier | 注册点 | 下游 |
|---|---|---|
| uinotification | services/uinotification/notify.go:36 | notification-service 队列 |
| webhook | services/webhook/notifier.go:32 | webhook_sender 队列 |
| indexer | services/indexer/indexer.go:15 | issue_indexer.UpdateIssueIndexer |
| websocket | services/websocket/notifier.go:16 | 实时推送 |
| feed | services/feed/notifier.go:31 | 动态流写库 |
| audit | services/audit/notifier.go:19 | 审计日志 |
| mirror | services/mirror/notifier.go:16 | mirror 队列 |
| automerge | services/automerge/automerge.go:35 | pr_auto_merge 队列 |
| mailer | services/mailer/mailer.go:34 | mail 队列 |
| actions | services/actions/init.go:69 | Actions 工作流触发 |
CreateIssueComment 还有一道前置闸门 shouldSendCommentChangeNotification(49-58 行):comment.Review.Type == ReviewTypePending 时直接 return——未提交的 pending review 草稿不触发任何通知,评论者还在编辑的逐字内容不会打扰任何人。这道闸门放在总线层而非各 notifier,保证 10 个消费方行为一致。
10.7.3 通知全链路时序:一条评论从点击到红点
以"用户 A 在 issue 下发表评论,用户 B 收到红点与实时推送"为例,完整链路(各环节均对应前文实测源码):
sequenceDiagram
autonumber
participant U as 浏览器(A)
participant H as routers/web/issue<br/>HTTP handler
participant DB as 数据库
participant NB as notify 总线<br/>services/notify/notify.go
participant UN as uinotification<br/>CreateIssueComment
participant Q as notification-service 队列<br/>WorkerPoolQueue
participant WK as worker goroutine
participant WS as websocket notifier
U->>H: POST /issues/:index/comments
H->>DB: 创建 Comment(事务内)
H->>NB: CreateIssueComment(ctx, doer, repo, issue, comment, mentions)
Note over NB: shouldSendCommentChangeNotification<br/>pending review 则中断
NB->>UN: 同步多播第 1 个 notifier
NB->>WS: 同步多播(websocket 推送走自己的通道)
UN->>Q: Push(issueNotificationOpts{IssueID, CommentID, AuthorID})
Note over UN: _ = Push(...) 错误被忽略<br/>不阻塞 HTTP 返回
H-->>U: 200 + 渲染后的评论页
Q->>WK: 100ms debounce 或攒满 20 条派发
WK->>DB: CreateOrUpdateIssueNotifications(issueID, ...)
WK->>UN: 对每个受影响 user: NotificationCountChange(userID)
UN->>WS: NotificationCountChange → 推给 B 的浏览器
WS-->>U: B 的页面红点 +1(WebSocket 帧)
时序图背后的三个设计判断:
- HTTP handler 在入队后立即返回。队列把"写通知表 + 查所有 watcher + 推送"这批 O(关注者数) 的工作移出请求路径——这就是队列存在的第一性理由;
- 总线是同步的,队列是异步的。多播本身不排队(10 个 notifier 的方法调用都在请求 goroutine 内完成),但每个 notifier 内部只做
Push这一个 O(1) 动作,重活全在下游 worker。唯一的例外是RepoPendingTransfer(uinotification 末尾)——它同步开事务写库,因为它必须与转移事务的成败一致; - webhook 分支多了数据库级去重。
webhook_sender是 unique 队列(防同 task ID 在队列里重复),handler 里还有第二道task.IsDelivered检查(webhook.go:92)——队列去重 + 数据库状态去重双保险,因为队列去重不 100% 可靠(10.2.4),而 webhook 重复投递会让下游 CI 跑两遍,代价高。
10.7.4 消费者启动失败的兜底矩阵
16 个队列对 CreateXxxQueue 返回 nil 的处理分为三档,抽样实测:
| 档位 | 代表 | 代码 | 理由 |
|---|---|---|---|
log.Fatal | uinotification:48 | log.Fatal("Unable to create notification-service queue") | 通知能力是站点基础功能,宁可不启动 |
return error | stats:34、webhook:282 | errors.New("unable to create ... queue") → 被 mustInit 放大为 Fatal | 可测试性:库代码不直接 Fatal,把决定权交给调用方 |
| 降级继续 | issues indexer | 建队失败仍可继续(查询走 dummyIndexer) | 搜索不可用 ≠ 站点不可用 |
mustInit/mustInitCtx(routers/init.go)把 error 统一转成启动失败,所以三档的实际效果大多相同,差别在于代码是否可以被单元测试单独调用。
10.8 缓存体系 modules/cache
10.8.1 Init 与"先 Ping 十次"
modules/cache 是对 gitea.com/go-chi/cache v0.2.1 的一层薄包装(go.mod 实测版本;无 vendor 目录,依赖走 module 代理)。全部文件只有五个业务文件:cache.go、string_cache.go、context.go、ephemeral.go、cache_redis.go、cache_twoqueue.go。cache.go 的 Init(routers/init.go:129 调用):
func Init() error {
if defaultCache == nil {
c, err := NewStringCache(setting.CacheService.Cache)
if err != nil {
return err
}
for range 10 {
if err = c.Ping(); err == nil {
break
}
time.Sleep(time.Second)
}
if err != nil {
return err
}
defaultCache = c
}
return nil
}
与 Redis 队列后端(10.2.4)同款的"10 次 × 1 秒"握手——缓存是 mustInit 的一环,连不上 redis/memcache 直接启动失败而不是降级到无缓存:因为配置了 ADAPTER=redis 却静默跑成不缓存,会让用户误以为缓存生效,性能预期全错。defaultCache 是包级 StringCache 变量,GetCache() 直接返回它。
10.8.2 StringCache 接口与"缓存错误"技巧
接口全文(string_cache.go:29-41,即开头证伪清单第 7 条):
type StringCache interface {
Ping() error
Get(key string) (string, bool)
Put(key, value string, ttl int64) error
Delete(key string) error
IsExist(key string) bool
PutJSON(key string, v any, ttl int64) error
GetJSON(key string, ptr any) (exist bool, err *GetJSONError)
ChiCache() chi_cache.Cache
}
没有 Iterator——缓存是黑盒 KV,不需要也不应该枚举(Redis 的 SCAN 在集群下语义模糊)。ChiCache() 是逃逸舱口:把底层 chi_cache.Cache 原样交出,给"我就是要用第三方接口"的场景(验证码 Store = cache.GetCache().ChiCache())。
实现体 stringCache 只是逐方法转发给 chiCache,NewStringCache 经 chi_cache.NewCacher(Options{Adapter, AdapterConfig, Interval}) 创建——适配器注册机制在 go-chi/cache 包内,Gitea 只需 blank import。cache.go 顶部导入 memcache:
_ "gitea.com/go-chi/cache/memcache" //nolint:depguard // memcache plugin for cache, it is required for config "ADAPTER=memcache"
redis 适配器则是本包自己的 cache_redis.go(init() 里 cache.Register("redis", &RedisCacher{})),twoqueue 同理(cache_twoqueue.go 注册 mc.Register("twoqueue", ...))。三种注册方式并存的原因:memcache 插件整体来自第三方包,redis/twoqueue 是 Gitea 自己写的(Redis 版还带 hsetName = "MacaronCache" 这个历史遗留的哈希名——从 Macaron 时代继承下来的键组织方式)。
缓存错误的编码技巧是本包最聪明的一笔。PutJSON 对 error 类型特判:
const cachedErrorPrefix = "<CACHED-ERROR>:"
func (sc *stringCache) PutJSON(key string, v any, ttl int64) error {
var s string
switch v := v.(type) {
case error:
s = cachedErrorPrefix + v.Error()
default:
b, err := json.Marshal(v)
// ...
}
return sc.chiCache.Put(key, s, ttl)
}
Go 的 error 是接口,存不进 string 缓存——于是把错误消息加前缀存成字符串,GetJSON 再切前缀还原成 GetJSONError。这让"缓存负结果"成为可能:contributors_graph 的调用点(services/repository/graph.go 一带,grep PutJSON 实测)连同 error 一起缓存,避免对不存在的数据反复打数据库。代价是错误消息在 TTL 内被"冻结"——瞬时故障会被缓存成一段时间的稳定故障,所以只适合结果天然稳定的场景(仓库没有贡献者就是没有,不会因为数据库抖动而变化)。
10.8.3 便捷函数与 SafeCacheKey
cache.go 提供三个业务常用入口:
func GetString(key string, getFunc func() (string, error)) (string, error) {
if defaultCache == nil || setting.CacheService.TTL == 0 {
return getFunc()
}
cached, exist := defaultCache.Get(key)
if !exist {
value, err := getFunc()
if err != nil {
return value, err
}
return value, defaultCache.Put(key, value, setting.CacheService.TTLSeconds())
}
return cached, nil
}
读穿(read-through)模板:未命中才调 getFunc,回填失败不报错(缓存是尽力而为,主数据已拿到)。TTL == 0 是全局关闭缓存的开关——注意只关便捷函数,不关 GetCache().Put 直接调用点。GetInt64 借 GetString 实现(字符串中转 int64),空串归一为 0。Remove(key) 同样吞掉 Delete 错误。
SafeCacheKey(prefix, input) 处理 memcached 的两条硬限制(字符集 + 250 键长):
func safeCacheKey(prefix, input string, limit int) string {
safeAsKey := len(prefix)+len(input)+3 <= limit
if safeAsKey {
for i := 0; i < len(input); i++ {
if c := input[i]; c <= ' ' || c >= 127 {
safeAsKey = false
break
}
}
}
sep, key := ":s-", input
if !safeAsKey {
hashBytes := sha256.Sum256(util.UnsafeStringToBytes(input))
sep, key = ":h-", hex.EncodeToString(hashBytes[:])
}
return prefix + sep + key
}
先判断"能当键用吗"(长度 len(prefix)+len(input)+3 <= limit、每个字节在可打印 ASCII 区间),能则原样拼 :s- 前缀,不能则整段 input 换成 sha256 摘要拼 :h- 前缀。两种键型前缀可辨(:s-/:h-),230 而非 250 留了 20 字节余量给其他调用方的前缀。头像缓存键是典型用户:avatar: + 用户 ID 或 SRV 哈希——不哈希的话带邮箱的键会把地址明文写进 memcached(SafeCacheKey 顺带完成了键脱敏)。注释同时提醒:prefix 本身太长时哈希也救不了,只能"让缓存去报错"——key 合法性的责任在调用方设计 prefix 时。
10.8.4 请求级缓存 EphemeralCache
context.go + ephemeral.go 解决另一个问题:同一个请求里 RepoUserPermission 被模板调用十几次,每次都查数据库太蠢。routers/common/middleware.go:90 的 WithCacheContext 给每个请求注入一个生命周期 5 分钟的请求级缓存:
contextCacheLifetime = 5 * time.Minute
GetWithContextCache(ctx, key, getFunc) 先查请求缓存再查全局缓存(源码里带着 FIXME 警告,提醒开发者别把跨请求语义塞进来)。过期清理时打的 log.Warn 值得注意——"highly likely to be abused":请求缓存活过请求本身意味着有 goroutine 泄漏,这是把它当泄漏探测器用。
10.8.5 真实使用点清单
grep 实测的代表性调用点:
| 使用点 | 函数/键 | 缓存内容 | TTL 来源 |
|---|---|---|---|
| 头像 SRV 记录 | cache.GetString | DNS 解析结果 | CacheService.TTL |
| 邮箱哈希头像 | cache.GetString + SafeCacheKey | gravatar 类哈希 | 同上 |
| 提交计数 | cache.GetInt64 | git-commits-graph-count(services/context/repo.go:289) | 同上 |
| 用户设置项 | cache.GetString | user setting | 同上 |
| 最后提交 | gitRepo.LastCommitCache(modules/git/last_commit_cache.go,TTL 经 setting.LastCommitCacheTTLSeconds() 注入) | 最后一次提交信息 | [cache.last_commit].ITEM_TTL 默认 8760h(一年) |
| 仓库权限 | GetWithContextCache | RepoUserPermission | 请求级 |
| GPG keys / emails | GetWithContextCache | 用户密钥、邮箱列表 | 请求级 |
| 分支 divergence | GetCache().Get/Put | 30 天内分支差异 | 显式 TTL |
| commit status | GetCache().Get/Put/Delete | 状态汇总 | 显式 TTL |
| contributors 图 | PutJSON/GetJSON | 图数据含 error | 显式 TTL |
| 验证码 | GetCache().ChiCache() | 进度挑战数据 | go-chi 默认 |
| healthcheck | Ping() | — | — |
modules/setting/cache.go 的配置结构与适配器白名单:
CacheService.Adapter = sec.Key("ADAPTER").In("memory", []string{"memory", "redis", "memcache", "twoqueue"})
| INI 键 | 默认 | 说明 |
|---|---|---|
[cache].ADAPTER | memory | memory / redis / memcache / twoqueue |
[cache].HOST | — | redis 取不到则回退 [redis].CONN_STR;twoqueue 回退 "50000"(容量);memcache 直接用 |
[cache].INTERVAL | 60 | 内存适配器 GC 间隔(秒) |
[cache].ITEM_TTL | 16h | 便捷函数默认 TTL |
[cache.last_commit].ITEM_TTL | 8760h | 最后提交缓存 TTL |
memcache 有个专属怪癖 TTLSeconds():memcached 的 TTL 是 32 位秒数(2038 年溢出),Gitea 的 8760h 约 3153 万秒尚可,但为通用性,超过 MemcacheMaxTTL(30 天)时改存 unix 时间戳——memcached 对大于 30 天的值按时间戳处理,这是协议约定。LastCommitCacheTTLSeconds() 是它的副本,因为 last commit TTL 常被设成一年,必然超限。
10.8.6 SlowCacheThreshold:在 admin SelfCheck,不在 doctor
const (
testCacheKey = "DefaultCache.TestKey"
// SlowCacheThreshold marks cache tests as slow
// set to 30ms per discussion: https://github.com/go-gitea/gitea/issues/33190
// TODO: Replace with metrics histogram
SlowCacheThreshold = 30 * time.Millisecond
)
Test() 对固定键做 Delete→Put→Get(500 字节 hex 数据)三连并计时。30ms 的阈值来自 issue #33190 的讨论(源码注释给出链接),作者同时留了 TODO 承认"直方图指标才是正解"。该常量的真实消费点经 grep 实测只有两处,均在 admin 包:
routers/web/admin/config.go:49——admin 系统配置页的TestCache按钮:测速超过 30ms 即在页面 flash 警告;routers/web/admin/admin.go:229-233——仪表盘SelfCheck自检面板,把CacheSlow作为一项健康信号展示。
services/doctor 没有任何 cache 或 queue 检查项(该包 grep cache|queue 零命中;cmd/doctor_test.go 里的 Register 只是示例代码)。所以"doctor 会检测慢缓存"的说法在本仓库不成立——检测入口是 admin 面板的两个按钮。同理 gitea doctor 子命令的检查清单里也没有队列项,队列健康度的唯一观测面是 admin 队列页(10.1.2 的 Manager 登记与 10.3.6 的 flush 操作)。
10.9 Cron 调度 services/cron
10.9.1 没有 modules/cron:架构与启动
先执行开头的证伪清单第 1 条:modules/cron 目录不存在(Glob modules/cron/*.go 零结果),调度实现全部在 services/cron/。底层是 github.com/go-co-op/gocron/v2 v2.22.0(go.mod 实测),Gitea 只做四件事:任务注册表、配置映射、多实例锁、状态展示。
cron.go 顶部建 scheduler(进程级单例):
var scheduler gocron.Scheduler
func init() {
var err error
scheduler, err = gocron.NewScheduler(gocron.WithLocation(time.Local))
if err != nil {
log.Fatal("Unable to create cron scheduler: %v", err)
}
}
WithLocation(time.Local)——调度时区跟随服务器本地时区,所以 @midnight 是服务器的午夜而非 UTC 午夜,中国部署者的"凌晨清理"就是北京时间凌晨。
Init 的完整流程(33-59 行):
func Init(original context.Context) {
defer pprof.SetGoroutineLabels(original)
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().ShutdownContext(), "Service: Cron", process.SystemProcessType, true)
initBasicTasks()
initExtendedTasks()
initActionsTasks()
lock.Lock()
for _, task := range tasks {
if task.IsEnabled() && task.DoRunAtStart() {
go task.Run()
}
}
scheduler.Start()
started = true
lock.Unlock()
graceful.GetManager().RunAtShutdown(context.Background(), func() {
if err := scheduler.Shutdown(); err != nil {
log.Error("Unable to shutdown cron scheduler: %v", err)
}
lock.Lock()
started = false
lock.Unlock()
finished()
})
}
四个阶段:注册三个组的任务(注册即读配置、决定是否挂 scheduler,见 10.9.2)→ 所有 enabled && RunAtStart 的任务并发起跑 → scheduler.Start() 开始计时并置 started = true → 关停钩子里 scheduler.Shutdown()。started 标志解决一个真实竞态:如果任务在 scheduler.Start() 之前注册完成时就要 RunAtStart,RegisterTask 里有对应分支(if started && ...),10.9.3 详述。RunAtStart 的任务用 go task.Run() 是因为任务函数可能跑几十秒,Init 不能被自己的"启动即跑"任务卡住——代价是 scheduler 启动与首批任务执行是并发的,好在每个任务都有 globallock 护体(10.9.4)。
10.9.2 任务注册:RegisterTask 的四道检查
tasks.go:169-218 是所有任务进入系统的唯一通道:
func RegisterTask[T Config](name string, config T, fun func(context.Context, *user_model.User, T) error) error {
log.Debug("Registering task: %s", name)
i18nKey := "admin.dashboard." + name
if value := translation.NewLocale("en-US").TrString(i18nKey); value == i18nKey {
return fmt.Errorf("translation is missing for task %q, please add translation for %q", name, i18nKey)
}
_, err := setting.GetCronSettings(name, config)
if err != nil {
log.Error("Unable to register cron task with name: %s Error: %v", name, err)
return err
}
task := &Task{
Name: name,
config: config,
fun: func(ctx context.Context, doer *user_model.User, runConfig Config) error {
return fun(ctx, doer, runConfig.(T)) //nolint:forcetypeassert // must be valid
},
}
lock.Lock()
locked := true
defer func() {
if locked {
lock.Unlock()
}
}()
if _, has := tasksMap[task.Name]; has {
log.Error("A task with this name: %s has already been registered", name)
return fmt.Errorf("duplicate task with name: %s", task.Name)
}
if config.IsEnabled() {
// We cannot use the entry return as there is no way to lock it
if err := addTaskToScheduler(task); err != nil {
return err
}
}
tasks = append(tasks, task)
tasksMap[task.Name] = task
if started && config.IsEnabled() && config.DoRunAtStart() {
lock.Unlock()
locked = false
task.Run()
}
return nil
}
四道检查,每道都有明确理由:
- i18n 强制:任务名必须有 en-US 翻译键
admin.dashboard.<name>,否则注册失败——admin 仪表盘的任务列表靠这个键显示任务名,漏翻译会渲染成原始 key,Gitea 用启动失败逼贡献者补options/locale/locale_en-US.json(也正呼应工作区规则"只编辑 locale_en-US.json"); - 读配置:
setting.GetCronSettings(name, config)把[cron.<name>]映射进 config——注册的同时完成配置注入,没有独立的"读配置"阶段; - 重名拒绝:
tasksMap查重,重复即 error; - 条件挂载:
IsEnabled()为真才真正addTaskToScheduler;任务始终进tasks表(禁用的任务也要在管理页可见,否则用户不知道有这个任务可开)。
RegisterTaskFatal 是薄包装:注册失败直接 log.Fatal——内置任务注册失败说明配置文件写坏了,必须停下来。手动运行路径 started && enabled && DoRunAtStart 分支处理"任务注册晚于 scheduler 启动"的时序(如 admin 页面热路径触发的注册),先释放全局锁再 task.Run(),避免持锁跑任务函数。
10.9.3 gocron 挂载与 Schedule 语义
func addTaskToScheduler(task *Task) error {
tags := []string{task.Name, task.config.GetSchedule()} // name and schedule can't be get from job, so we add them as tag
withSeconds := scheduleHasSeconds(task.config.GetSchedule())
_, err := scheduler.NewJob(
gocron.CronJob(task.config.GetSchedule(), withSeconds),
gocron.NewTask(task.Run),
gocron.WithTags(tags...),
)
// ...
}
func scheduleHasSeconds(schedule string) bool {
return len(strings.Fields(schedule)) >= 6
}
两个设计点:
- 任务名与 schedule 塞进 gocron job 的 tags。gocron 的 Job 接口不回传调度表达式,
ListTasks(cron.go:86-137)靠"第一个 tag 是任务名、第二个 tag 是 schedule"重建管理页表格——tag 成了 Gitea 与 gocron 之间事实上的元数据通道; - 秒级支持的判定就是"字段数 ≥ 6"。5 字段(
0 0 * * *)按分钟精度解析,6 字段(30 0 0 * * *)启用秒。这让同一套配置同时兼容经典 crontab 与 gocron 的扩展语法。
Schedule 字符串的三种写法在本章任务表里都有实例:@midnight(宏,= 0 0 0 * * *)、@every 10m(间隔)、@annually(宏)、以及标准 5/6 字段表达式。没有 HideNightly 机制(证伪清单第 5 条)——所有任务无差别地显示在 admin 页,夜间任务只是碰巧 Schedule=@midnight。
ListTasks 还有一个手动运行的对账逻辑(cron.go:117-121):job 的 LastRunStartedAt(cron 触发时间)与 task.LastRun(手动触发时间)取较晚者展示——admin 页面上"上次运行时间"必须反映两种触发方式的任一。
10.9.4 多实例防重:globallock 而非 DB 行锁
这是开头证伪清单第 8 条,也是 cron 子系统最关键的一段代码(tasks.go:75-90):
func getCronTaskLockKey(name string) string {
return "cron_task:" + name
}
// RunWithUser will run the task incrementing the cron counter at the time with User
func (t *Task) RunWithUser(doer *user_model.User, config Config) {
locked, releaser, err := globallock.TryLock(graceful.GetManager().ShutdownContext(), getCronTaskLockKey(t.Name))
if err != nil {
log.Error("Failed to acquire lock for cron task %q: %v", t.Name, err)
return
}
if !locked {
log.Trace("a cron task %q is already running", t.Name)
return
}
defer releaser()
// ... 执行任务 ...
}
逐点拆解:
- 锁键是
cron_task:<任务名>,全局命名空间(cron_task:git_gc_repos),与仓库级锁(repo-*:)不冲突; - TryLock 语义:拿不到锁不报错、不等待,记一条 Trace 日志直接放弃。这正是多实例部署需要的行为——三个 Gitea 实例的 scheduler 都在
@midnight触发git_gc_repos,只有抢到锁的那个执行,另两个安静退出。若用Lock阻塞等待,后两个实例会一直挂着直到前一个做完再重复执行,防重就失效了; - 锁的载体是 globallock(10.10),默认 memory 后端在单实例下是进程内互斥(防同一进程的 cron 触发与 admin 手动运行撞车),配置
SERVICE_TYPE=redis后变成跨实例分布式锁(redsync 实现)。不是数据库行锁——grepcron相关的 DB 锁代码零命中,tasks表没有锁记录行,ExecTimes/Status只是统计展示,不承担互斥; defer releaser()与Run的 panic recover 配合:即使任务函数 panic,锁也会释放(defer 顺序:RunWithShutdownContext内部的 recover 在里层,releaser 在外层兜底)。
锁的粒度值得注意:整个任务执行期间持锁,包括遍历所有仓库的长任务。git_gc_repos 在大实例上可能跑一小时,这一小时内其他实例的同名任务全部静默跳过——这是特性不是缺陷(重复 GC 纯浪费),但意味着监控看到"某实例任务没跑"时,要先分辨是没抢到锁还是故障,日志级别是 Trace 而非 Warn,排查时注意调日志级别。
10.9.5 队列 unique + globallock 的协作(PR 检查案例)
cron 的锁并不是孤例——services/pull/check.go:107-124 展示了 queue unique 与 globallock 的联合用法,也顺带解释了 10.2.5 的 immediate 队列死锁问题:
func StartPullRequestCheckOnView(ctx context.Context, pr *issues_model.PullRequest) {
// TODO: its correctness totally depends on the "unique queue" feature and the global lock.
// So duplicate "start" requests will be ignored if there is already a task in the queue or one is running.
// Ideally in the future we should decouple the "unique queue" feature from the "start" request.
if pr.Status == issues_model.PullRequestStatusChecking {
if setting.IsInTesting {
// In testing mode, there might be an "immediate" queue, which is not a real queue, everything is executed in the same goroutine
// So we can't use the global lock here, otherwise it will cause a deadlock.
AddPullRequestToCheckQueue(pr.ID)
} else {
// When a PR check starts, the task is popped from the queue and the task handler acquires the global lock
// So we need to acquire the global lock here to prevent from duplicate tasks
_, _ = globallock.TryLockAndDo(ctx, getPullWorkingLockKey(pr.ID), func(ctx context.Context) error {
AddPullRequestToCheckQueue(pr.ID) // the queue is a unique queue and won't add the same task again
return nil
})
}
}
}
三层防重各司其职:unique 队列防"同 PR 在队列里排两次"(入队瞬间的去重);globallock 防"PR 正在被 worker 检查时又有人入队"(执行期间的互斥——worker pop 出 item 后先拿 getPullWorkingLockKey(prID),unique 集合此时已删除该成员,若不加锁,同一 PR 可被二次入队导致并发检查同一 PR);testing 分支的存在是因为 immediate 队列在 Push 时同步执行 handler,"入队"与"执行"在同一 goroutine——先拿锁再 Push 会让 handler 内的 TryLock 永远失败(自己锁自己),故 testing 模式绕开锁。作者的 TODO 也坦承这个设计"完全依赖 unique 队列特性",是权宜之计。
10.9.6 任务清单:三组 31 个内置任务(含默认开关)
initBasicTasks/initExtendedTasks/initActionsTasks 分别在 tasks_basic.go、tasks_extended.go、tasks_actions.go,每个任务一个 RegisterTaskFatal 调用。全部任务的默认配置(Enabled / RunAtStart / Schedule)逐一从源码摘出——这是本章最重要的一张表,也是运维调 [cron.<name>] 时的对照字典:
basic 组(tasks_basic.go,站点日常维护)
| 任务名 | Enabled | RunAtStart | Schedule | 额外默认参数 | 作用 |
|---|---|---|---|---|---|
update_mirrors | ✅ | ❌ | @every 10m | PullLimit/PushLimit=50 | 把启用的镜像推入 mirror 队列(仅 Mirror.Enabled 时注册) |
repo_health_check | ✅ | ❌ | @midnight | Timeout=Git.Timeout.GC | 全仓库 fsck 健康检查 |
check_repo_stats | ✅ | ✅ | @midnight | — | 重算仓库统计(stars/forks 等) |
archive_cleanup | ✅ | ✅ | @midnight | OlderThan=24h | 删除过期的仓库压缩包 |
sync_external_users | ✅ | ❌ | @midnight | UpdateExisting=true | 从外部域名同步用户头像 |
deleted_branches_cleanup | ✅ | ✅ | @midnight | OlderThan=24h | 清理已删除分支的记录 |
update_migration_poster_id | ✅ | ✅ | @midnight | — | 迁移仓库的发布者 ID 回填(仅 !DisableMigrations 时注册) |
cleanup_hook_task_table | ✅ | ❌ | @midnight | OlderThan=168h, Keep=10 | 清理 webhook 投递历史表 |
cleanup_packages | ✅ | ✅ | @midnight | OlderThan=24h | 删除过期包版本(仅 Packages.Enabled 时注册) |
sync_repo_licenses | ❌ | ❌ | @annually | — | 批量识别仓库许可证 |
extended 组(tasks_extended.go,默认多为关闭的"重型操作")
| 任务名 | Enabled | RunAtStart | Schedule | 作用 |
|---|---|---|---|---|
delete_inactive_accounts | ❌ | ❌ | @annually | 删除长期不活跃账号 |
delete_repo_archives | ❌ | ❌ | @annually | 删除全部仓库压缩包 |
git_gc_repos | ❌ | ❌ | @every 72h | 全仓库 git gc(磁盘回收) |
resync_all_sshkeys | ❌ | ❌ | @every 72h | 重算 SSH 指纹 |
resync_all_sshprincipals | ❌ | ❌ | @every 72h | 重算 SSH principals |
resync_all_hooks | ❌ | ❌ | @every 72h | 重算仓库 hooks 状态 |
reinit_missing_repos | ❌ | ❌ | @every 72h | 重建缺失的仓库目录 |
delete_missing_repos | ❌ | ❌ | @every 72h | 从 DB 删除磁盘上丢失的仓库(危险操作,故默认关) |
delete_generated_repository_avatars | ❌ | ❌ | @every 72h | 删除生成的仓库头像 |
delete_old_actions | ❌ | ❌ | @every 168h | OlderThan=365d,清理 Actions 运行记录 |
delete_old_audit_events | ✅ | ❌ | @every 24h | OlderThan(需审计开启才注册) |
update_checker | ✅ | ❌ | @every 168h | 请求 https://dl.gitea.com/gitea/version.json 检查新版本 |
delete_old_system_notices | ❌ | ❌ | @every 168h | OlderThan=168h,清理系统通知 |
gc_lfs | ❌ | ❌ | @every 24h | LFS 对象 GC(仅 LFS.StartServer 时注册) |
rebuild_issue_indexer | ❌ | ❌ | @annually | 全量重建 issue 索引 |
actions 组(tasks_actions.go,仅 Actions.Enabled 时整个文件参与注册)
| 任务名 | Enabled | RunAtStart | Schedule | 作用 |
|---|---|---|---|---|
stop_zombie_tasks | ✅ | ✅ | @every 5m | 掐掉失去宿主的 runner 任务 |
stop_endless_tasks | ✅ | ✅ | @every 30m | 掐掉超时未结束的任务 |
cancel_abandoned_jobs | ✅ | ✅ | @every 6h | 取消无人认领的 job |
start_schedule_tasks | ✅ | ❌ | @every 1m | 启动到点的 schedule 型 workflow |
cleanup_actions | ✅ | ❌ | @midnight | 清理过期 Actions 数据 |
cleanup_action_runs | ✅ | ❌ | @midnight | 清理过期 run 记录 |
读这张表的几条规律:
- basic 组默认开、extended 组默认关是刻意的产品分层:basic 是任何实例都该做的日常打扫(破坏性低、耗时可控),extended 里一半是"可能删数据"或"全仓库级 IO 重活",必须让管理员显式开(
delete_missing_repos这种会直接从数据库删仓库的,双重确认都不够,默认关是底线); RunAtStart=true的任务在每次进程启动后立即执行一次——check_repo_stats、archive_cleanup这类"停机期间错过的打扫"要补做;而update_mirrors有 10 分钟周期兜底,启动即跑反而会在初始化未完成时制造压力,故RunAtStart=false;- 条件注册(
update_mirrors/update_migration_poster_id/cleanup_packages/delete_old_audit_events/gc_lfs/整个 actions 组)发生在 init 函数的 if 分支里,功能开关关闭时任务根本不进tasks表,admin 页面也不显示——与"Enabled=false 但显示"的显式禁用是两种不同语义; - 所有 Schedule 都是
@midnight/@every N/@annually宏,没有一个用 6 字段表达式——秒级精度是给高级用户留的口子,内置任务用不上。
10.9.7 admin 面板:手动运行有按钮,启用开关在 app.ini
任务的运行入口在 routers/web/admin/admin.go:
DashboardPost(:155):表单op字段直通任务名——cron.GetTask(form.Op)取出任务后go task.RunWithUser(ctx.Doer, nil)异步执行(doer 记入LastDoer,页面上能看到"谁触发的")。两个特例sync_repo_branches/sync_repo_tags不是 cron 任务而是直接起 goroutine 的操作。dashboard 模板里共 13 个这样的 op 按钮;CronTasks(:249):渲染templates/admin/cron.tmpl,数据来自cron.ListTasks()(10.9.3),列是任务名/spec/上次/下次/状态/消息/执行次数。
一个容易误解的点必须澄清:admin 页面上没有任务启用开关。cron 管理页只有"立即运行"按钮;[cron.<name>].ENABLED 的修改必须落在 app.ini 并重启进程(GetCronSettings 只在 RegisterTask 时读一次,没有热加载)。安装向导是唯一会代写该键的地方——routers/install/install.go:104/370 把 cron.update_checker.ENABLED 写进生成的 app.ini(用户在安装页勾选"检查更新"时)。路由挂在 /-/admin/monitor/cron(routers/web/web.go:791)。
任务执行的状态机(tasks.go:99-156,RunWithShutdownContext 内):ExecTimes++ → 记 LastRun → 进程管理器登记(pm.AddContext,gitea processes 可见"cron: "条目)→ 调 t.fun → 按结果写 Status = finished|cancelled|error → 除了 finished 必发系统通知(system_model.CreateNotice),finished 还要看 NoticeOnSuccess 配置才发。db.ErrCancelled 被单独识别为 cancelled 状态——任务可以在执行中被上下文取消并优雅归类,这是与 panic 的区别(panic 被 defer 的 recover 捕获,记 error 日志但状态字段保持前值)。
FormatMessage(setting.go)按 doer 分流 i18n 键:doer 为空(cron 自动触发)→ admin.dashboard.cron.<status>,否则(人工触发)→ admin.dashboard.task.<status>——通知文案会说"任务被系统调度执行"还是"任务被 <用户名> 手动执行"。
10.9.8 [cron.<name>] 配置键与 GetCronSettings 的边界
每个任务的 config 结构(setting.go 的 Config 接口 + BaseConfig)暴露四个基础键,经 GetCronSettings 映射:
INI 键(位于 [cron.<name>] 节) | 默认(内置任务) | 说明 |
|---|---|---|
ENABLED | 各任务不同(见上表) | 是否挂上 scheduler |
RUN_AT_START | 各任务不同 | 进程启动后是否立即执行一次 |
SCHEDULE | 各任务不同 | gocron 表达式(宏 / 5 字段 / 6 字段含秒) |
NOTICE_ON_SUCCESS | false | 成功时也写系统通知 |
扩展 config 还带任务专属键(GetCronSettings 对一级匿名嵌入结构递归映射):update_mirrors 的 PULL_LIMIT/PUSH_LIMIT、repo_health_check 的 TIMEOUT/ARGS、OlderThanConfig 的 OLDER_THAN、CleanupHookTaskConfig 的 CLEANUP_TYPE/KEEP 等——同一个 [cron.<name>] 节,扁平地放所有键。
modules/setting/cron.go 的实现只有 32 行,但有一条被测试固化的边界:
func getCronSettings(rootCfg ConfigProvider, name string, config any) (any, error) {
if err := rootCfg.Section("cron." + name).MapTo(config); err != nil {
return config, err
}
typ := reflect.TypeOf(config).Elem()
val := reflect.ValueOf(config).Elem()
for i := 0; i < typ.NumField(); i++ {
field := val.Field(i)
tpField := typ.Field(i)
if tpField.Type.Kind() == reflect.Struct && tpField.Anonymous {
if err := rootCfg.Section("cron." + name).MapTo(field.Addr().Interface()); err != nil {
return config, err
}
}
}
return config, nil
}
两次 MapTo:先映射外层结构,再对每个匿名嵌入字段单独映射——因为 go-ini 的 MapTo 不深入匿名嵌入(或行为不完整),这里手动补一层。cron_test.go 固化了这个行为:只支持一级嵌入,两级嵌入的内层字段读不到。任务的 config 全部设计成"BaseConfig 一级嵌入"正是为了适配这条限制——这也是为什么 UpdateMirrorTaskConfig 长这样:
RegisterTaskFatal("update_mirrors", &UpdateMirrorTaskConfig{
BaseConfig: BaseConfig{ ... }, // 一级匿名嵌入 → GetCronSettings 能覆盖
PullLimit: 50,
PushLimit: 50,
}, ...)
另一个被证伪的假设在这里落地:[cron] 公共节不存在于代码中。getCronSettings 只读 cron.<name>,grep 全仓库没有任何读取 [cron](无后缀节)的语句。app.example.ini:2185 起那个 ;[cron] 注释块(ENABLED/RUN_AT_START/SCHEDULE 的通用说明)只是文档性注释——它描述的是每个 [cron.xxx] 节的键,不是可生效的公共默认节。想给所有任务改默认值?没有这个入口,只能逐节写。
10.10 全局锁 modules/globallock
10.10.1 Locker 接口与 ReleaseFunc 契约
locker.go 全文只有接口与一个类型别名,但注释密度极高——它规定了所有使用方的纪律:
type Locker interface {
// Lock tries to acquire a lock for the given key, it blocks until the lock is acquired or the context is canceled.
//
// Lock returns a ReleaseFunc to release the lock, it cannot be nil.
// It's always safe to call this function even if it fails to acquire the lock, and it will do nothing in that case.
// And it's also safe to call it multiple times, but it will only release the lock once.
// That's why it's called ReleaseFunc, not UnlockFunc.
// But be aware that it's not safe to not call it at all; it could lead to a memory leak.
// So a recommended pattern is to use defer to call it:
// release, err := locker.Lock(ctx, "key")
// if err != nil {
// return err
// }
// defer release()
//
// Lock returns an error if failed to acquire the lock.
// Be aware that even the context is not canceled, it's still possible to fail to acquire the lock.
// For example, redis is down, or it reached the maximum number of tries.
Lock(ctx context.Context, key string) (ReleaseFunc, error)
// TryLock tries to acquire a lock for the given key, it returns immediately.
// It follows the same pattern as Lock, but it doesn't block.
// And if it fails to acquire the lock because it's already locked, not other reasons like redis is down,
// it will return false without any error.
TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error)
}
// ReleaseFunc is a function that releases a lock.
type ReleaseFunc func()
三段注释各自防一类事故:"不能不调用"(内存泄漏——锁会挂在管理结构里);"锁失败也可能返回 error 而非 false"(redis 挂了 ≠ 锁被占着,调用方必须区分"放弃"与"故障");"ReleaseFunc 可重复调用、失败也可调"(鼓励无脑 defer release(),省去判断锁是否成功拿到的分支)。
包级门面 globallock.go 提供四个函数:Lock/TryLock 直接转发 DefaultLocker(),LockAndDo/TryLockAndDo 是"拿锁 + 执行 + defer 释放"的样板收拢(10.9.5 的 PR 检查用的就是 TryLockAndDo)。DefaultLocker() 用 atomic.Pointer + 双检锁做懒初始化,initDefaultLocker 按配置选后端:
func initDefaultLocker() Locker {
switch setting.GlobalLock.ServiceType {
case "redis":
return NewRedisLocker(setting.GlobalLock.ServiceConnStr)
default: // "memory"
return NewMemoryLocker()
}
}
10.10.2 memory 后端:sync.Map + 自旋等待
memory_locker.go 是单实例下的实现:sync.Map 以 key 为索引记录持有者。Lock 的获取路径用 100ms 周期的 ticker 自旋——每 100ms 查一次锁是否释放,直到拿到或 ctx 取消。这个粗糙的实现有两个可接受性理由:单实例内竞争极少(见 10.10.4 的使用场景,都是低频互斥);100ms 粒度对"防重复执行"绰绰有余,而细粒度唤醒机制(channel 通知链)的复杂度不值得。释放路径用 sync.Once 保证幂等(兑现 ReleaseFunc "多次调用只释放一次"的契约)。
memory 后端的能力边界要向读者明确:它只在单进程内互斥。多实例部署下每个进程有自己的 sync.Map,cron_task:git_gc_repos 在三个实例里是三把互不相干的锁——所以多实例必须配 redis 后端(10.9.4 的防重论述以配置了 redis 为前提;未配置时 Gitea 的 cron 实际是"每个实例各跑一遍",这是部署文档层面的已知约束)。
10.10.3 redis 后端:redsync、30 秒过期与自动续期
redis_locker.go 用 github.com/go-redsync/redsync/v4 v4.18.0(go.mod 实测)实现分布式锁,四组关键事实:
const redisLockKeyPrefix = "gitea:globallock:"
// redisLockExpiry is the default expiry time for a lock.
// Define it as a variable to make it possible to change it in tests.
var redisLockExpiry = 30 * time.Second
- 键空间隔离:
gitea:globallock:前缀,与队列键、缓存哈希名互不冲突; - 30 秒过期 + 15 秒续期:
startExtend用time.AfterFunc(redisLockExpiry/2, l.startExtend)形成 15 秒一轮的续期自调度——每轮mutexM.Range找出所有未过期的锁逐个Extend()。持锁进程崩溃时锁会在 30 秒内自动过期,不会永久死锁;续期失败也"过期兜底"(注释明说)。这是分布式锁的标准安全模式(锁必须有 TTL); - TryLock 靠 tries=1 + 错误类型判定:
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
f, err := l.lock(ctx, key, 1)
if _, taken := errors.AsType[*redsync.ErrTaken](err); taken {
return false, f, nil
}
if _, nodeTaken := errors.AsType[*redsync.ErrNodeTaken](err); nodeTaken {
return false, f, nil
}
return err == nil, f, err
}
WithTries(1) 让 redsync 只尝试一次;ErrTaken(多数派节点上锁已存在)/ErrNodeTaken(单节点模式下已存在)被翻译成 locked=false, err=nil——"锁被占"是正常业务结果不是错误,兑现接口注释的承诺;其他错误(连接失败)原样上抛;
- 释放失败静默:
releaseOnce包裹的mutex.Unlock()忽略错误,注释给了两个理由——失败了 30 秒后自动过期;不能用UnlockContext(ctx),因为调用方的 ctx 可能已经超时/取消,那样反而放不掉锁。
10.10.4 真实使用场景:31 处调用点分类
grep globallock\. 全仓库实测约 31 处生产调用,按语义分五类:
| 类别 | 锁键模式 | 代表调用点 | 防什么 |
|---|---|---|---|
| cron 任务防重 | cron_task:<name> | services/cron/tasks.go:81 | 多实例/手动+定时撞车(10.9.4) |
| PR 工作互斥 | getPullWorkingLockKey(prID) | services/pull/ 的 merge/check/update/pull.go | 同 PR 并发检查、并发合并(10.9.5) |
| 仓库工作互斥 | transfer/wiki 相关键 | services/repository/ | 同仓库并发转移、并发 wiki 操作 |
| 镜像拉取互斥 | getRepoPullMirrorLockKey | services/mirror/ | 同镜像被 cron 与手动触发同时拉取 |
| git 配置写锁 | repo-config:/repo-write: | modules/git/git.go:210/214 | 并发写同一仓库的 git config/受保护 ref(文件级竞态) |
| 包注册互斥 | container blob/manifest、maven、arch、keypair、terraform 键 | services/packages/ | 同包版本并发上传产生重复/损坏 |
| 其他 | versioned migration、UpdateTaskByState-run-<runID> | models/db/、services/actions/ | 并发跑迁移;并发状态机转移同一 run |
几个值得展开的:
- git 层的锁是最后一道防线。
modules/git/git.go的repo-config:/repo-write:锁保护的是"对同一仓库目录的非原子文件操作"——即使上层业务已经用 PR 锁互斥了,git 层仍不信任调用方(不同业务路径可能都去写 config),纵深防御; - 包上传锁解决的是内容寻址存储的经典竞态:两个用户同时上传同一个 blob,若不互斥,一个在写文件另一个可能读到半个文件;
UpdateTaskByState-run-<runID>把锁键做到了单个 Actions run 的粒度——状态机转移必须串行,否则pending→running会被并发执行两次造成任务双跑。
配置结构(modules/setting/global_lock.go,app.example.ini:3123):
| INI 键 | 默认 | 说明 |
|---|---|---|
[global_lock].SERVICE_TYPE | memory | memory 或 redis |
[global_lock].SERVICE_CONN_STR | 共享 [redis].CONN_STR | 仅 redis 模式用;nosql.ToRedisURI 校验失败 log.Fatal |
与队列的 redis 回退链(10.5.1 第 5 条)同构:全局锁、缓存、队列三个子系统共享同一个 [redis] 连接配置,一处改全生效。
10.10.5 三道防线的总结:unique queue、globallock、DB 状态
到这里可以回收全章最微妙的一处设计——为什么 PR 检查同时需要 unique 队列和 globallock(10.9.5 的 TODO 原文承认"correctness totally depends on"两者):
时刻 T0:PR#42 状态 Checking,A 页面触发 StartPullRequestCheckOnView
→ globallock.TryLock(repo-工作锁:42) 成功
→ queue.Push("42"):unique set 里无 "42" → 入队成功(set 加入 "42")
→ 释放锁
时刻 T1(worker 尚未 pop):B 页面再次触发
→ TryLock 成功(A 已释放)
→ queue.Push("42"):set 已含 "42" → ErrAlreadyInQueue,被吞
→ 【unique 队列在此防重】
时刻 T2:worker pop "42"(set 删除 "42"),开始检查,持有 getPullWorkingLockKey(42)
→ C 页面触发:TryLock 失败(worker 持锁)→ 直接 return
→ 【globallock 在此防重——此时 unique set 已经空了,队列帮不上忙】
时刻 T3:worker 完成,释放锁
→ 若检查期间 PR 又变了,新的入队请求现在可以通过 TryLock 正常排队
两个机制防的是时间轴上的两段不同窗口:入队前的窗口归 unique 队列,出队后的执行窗口归 globallock;DB 里的 PullRequestStatusChecking 状态是第三道粗筛(markPullRequestStatusAsChecking 用 UpdateColsIfNotMerged 的条件更新天然带 CAS 语义)。三道防线各守一段,缺一就会出现"同 PR 并发跑两次三路合并检查"的浪费甚至竞态。
10.11 限流与防抖:Gitea 没有通用限流器
"限流"在 Gitea 里不是一个子系统,而是散落在四五个点位的局部机制。先澄清一个容易想当然的假设:Gitea 没有通用的 HTTP rate limiter——没有 golang.org/x/time/rate 调用点、没有按 IP/账号计数的令牌桶中间件(第 9 章讲 REST 中间件栈时已逐层证实)。真实存在的是下面四种各管一段的机制。
10.11.1 QoS:基于 CODEL 的过载丢弃,不是限流而是"择劣牺牲"
routers/common/qos.go 是全站唯一的入口级过载保护,开关 setting.Service.QoS.Enabled 默认 false:
func QoS() func(next http.Handler) http.Handler {
if !setting.Service.QoS.Enabled {
return nil
}
maxOutstanding := setting.Service.QoS.MaxInFlightRequests
if maxOutstanding <= 0 {
maxOutstanding = 10
}
c := codel.NewPriority(codel.Options{
// The maximum number of waiting requests.
MaxPending: setting.Service.QoS.MaxWaitingRequests,
// The maximum number of in-flight requests.
MaxOutstanding: maxOutstanding,
// The target latency that a blocked request should wait
// for. After this, it might be dropped.
TargetLatency: setting.Service.QoS.TargetWaitTime,
})
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
...
err := c.Acquire(ctx, int(priority))
if err != nil {
log.Error("QoS error, dropping request of priority %s: %v", priority, err)
renderServiceUnavailable(w, req)
return
}
// Release long-polling immediately, so they don't always take up an in-flight request
if reqRecordInfo.IsLongPolling {
c.Release()
} else {
defer c.Release()
}
next.ServeHTTP(w, req)
})
}
}
配置默认值(modules/setting/service.go:286-289):ENABLED=false、MAX_INFLIGHT=4*NumCPU、MAX_WAITING=100、TARGET_WAIT_TIME=250ms。语义是 CoDel 家族的主动队列管理:同时在飞的请求超过 MAX_INFLIGHT 时新请求排队,排队超过 250ms 就丢弃并渲染 503 页面——它不限制"每秒多少请求",只保证"延迟不失控",过载时优雅降级而不是雪崩。
三档优先级(requestPriority)体现"保登录用户、牺牲匿名爬虫"的取舍:
| 优先级 | 值 | 判定 |
|---|---|---|
HighPriority | +10 | context 里有已登录用户 |
DefaultPriority | 0 | 其他 |
LowPriority | -10 | 路由 pattern 以 /{username}/{reponame}/ 开头(仓库页面,最易被爬) |
长轮询(reqRecordInfo.IsLongPolling)Acquire 后立即 Release——否则一个挂 30 秒的 SSE 连接会永久占掉一个 in-flight 名额,这是注释里点名的坑。
10.11.2 Actions 的并发取任务限流:信号量 + "不碰 DB 的节流"
services/actions/task.go 用一个带缓冲 channel 做信号量,限制同时进行的"任务分配事务":
func taskPickLimiter() chan struct{} {
taskPickSemOnce.Do(func() {
taskPickSem = make(chan struct{}, max(1, setting.Actions.MaxConcurrentTaskPicks))
})
return taskPickSem
}
// TryPickTask attempts to assign a task to the runner, bounding the number of
// concurrent assignment transactions to avoid a thundering herd when many
// runners poll at once. When the concurrency limit is reached it returns
// throttled=true without touching the DB, so the caller can let the runner
// retry on its next poll instead of advancing its tasks version.
func TryPickTask(ctx context.Context, runner *actions_model.ActionRunner) (task *runnerv1.Task, ok, throttled bool, err error) {
sem := taskPickLimiter()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
default:
return nil, false, true, nil
}
task, ok, err = PickTask(ctx, runner)
return task, ok, false, err
}
三个设计点:sync.Once 保证信号量全进程唯一;max(1, ...) 防配置成 0 后死锁;满载时立即返回 throttled=true 而完全不碰数据库——注释解释了为什么这比"进 DB 排队"好:调用方让 runner 下次轮询重试即可,不推进 tasks version,避免大量 runner 同时撞同一批分配事务形成 thundering herd(惊群)。默认 MaxConcurrentTaskPicks=16(modules/setting/actions.go:18)。
10.11.3 webhook:没有自动重试——被广泛误解的真相
很多读者会假设 webhook 投递失败后队列会退避重试。不会。看 handler 的完整实现(services/webhook/webhook.go:78-104):
func handler(items ...int64) []int64 {
ctx := graceful.GetManager().HammerContext()
for _, taskID := range items {
task, err := webhook_model.GetHookTaskByID(ctx, taskID)
if err != nil {
...
continue
}
if task.IsDelivered {
// Already delivered in the meantime
log.Trace("Task[%d] has already been delivered", task.ID)
continue
}
if err := Deliver(ctx, task); err != nil {
log.Error("Unable to deliver webhook task[%d]: %v", task.ID, err)
}
}
return nil
}
返回值永远是 nil——即使 Deliver 报错也只记日志,没有任何 item 进入 unhandled。对照 10.3.4 的回队机制:队列系统的重试能力完全取决于 handler 是否交还 unhandled,webhook 的 handler 主动放弃了它。所以"webhook 投递失败"的归宿是三行数据库记录,而不是重试:
- CAS 抢占:
Deliver里MarkTaskDelivered用WHERE is_delivered = false的条件更新做并发去重(models/webhook/hooktask.go:188-195,count != 0才继续)——unique 队列之外的第三道防线,防止同 task 被两个 worker 同时投递; - 失败落库:
defer里UpdateHookTask写IsSucceed(判定标准resp.StatusCode/100 == 2)与响应详情,UpdateWebhookLastStatus把最后一次状态挂到 webhook 上,admin 页面据此显示红叉; - 重放是人工动作:
ReplayHookTask(webhook.go:257-264)复制 task 行并重新入队——重放资格由人判断,不自动做。
超时与出口限制在 Init(deliver.go:274-290):DeliverTimeout 默认 5 秒(setting/webhook.go:25),webhookHTTPClient 的 Transport 由 egress.NewWebhookPolicy() 生成——受 ALLOWED_HOST_LIST 白名单与代理配置约束,这是"限流"之外的另一层"限制"(限制目标而非速率)。
投递失败的兜底只剩两道,都是时间换正确性:启动回灌 populateWebhookSendingQueue(10.4.4 策略一)把 FindUndeliveredHookTaskIDs 的历史未投递行重新入队;cleanup_hook_task_table cron(默认 @midnight、保留 168 小时内每 webhook 最近 10 条)给这些行设定了最终的清理期限。
10.11.4 mirror 同步的并发限制实况
镜像同步没有独立的信号量或限速器,并发由三层参数叠加而成:
- 每轮入队量:cron
update_mirrors每 10 分钟跑一次,mirror_service.Update(ctx, cfg.PullLimit, cfg.PushLimit)默认PullLimit=PushLimit=50(tasks_basic.go:37),MirrorsIterate/PushMirrorsIterate数满即以errLimit提前退出(services/mirror/mirror.go:32,91-116)——单轮最多入队 100 个同步请求,且入队时ErrAlreadyInQueue被静默吞掉(unique 队列防重复排队); - 同时执行数:
[queue.mirror]的MAX_WORKERS(默认NumCPU/2夹 [1,10])——这才是真正的并发上限,队列攒批 + worker 池的机制(10.3.3)在此表现为"最多 N 个镜像同时 git fetch"; - 单仓库互斥:
globallock.Lock("repo_pull_mirror_%d")(10.10.4 的镜像拉取互斥行)+ unique 队列双重防重——cron 周期触发与用户手动"同步"按钮撞车时,只有一个能进。
所以调镜像同步强度的旋钮不是某个 MIRROR_CONCURRENCY,而是 [cron.update_mirrors].PULL_LIMIT/PUSH_LIMIT × [queue.mirror].MAX_WORKERS 两个配置的乘积语义。
10.11.5 其他防抖点速查
| 点位 | 机制 | 参数 | 位置 |
|---|---|---|---|
| 队列攒批 | debounce 定时器(100ms 强制派发) | batchDebounceDuration | modules/queue/workergroup.go |
| 整批失败退避 | sleep 后回队 | unhandledItemRequeueDuration=1s | 同上,10.3.4 |
| Redis pop/push 重试 | 指数退避 50ms→2s | backoffBegin/backoffUpper | modules/queue/backoff.go |
| 验证邮件重发 | 用缓存当限流器:Put("MailResendLimit_"+user, 180),IsExist 即拒绝 | 180 秒 TTL | routers/web/auth/auth.go:741-747 |
| 静态资源热更新 | util.Debounce(重置式 timer,最后一次调用后 d 秒才执行) | 100ms | modules/util/timer.go:11,modules/assetfs/layered.go:238 |
MailResendLimit_ 最能体现 Gitea 的实用主义:需要的只是"同一用户 3 分钟内不能重复收信",为它引入令牌桶框架毫无必要,一个带 TTL 的缓存键就是完整的限流器——而 util.Debounce 是教科书式的"最后调用者获胜"防抖(db.t.Stop() 重置计时),用于"文件系统变更可能连发多次通知、只处理最后一次"的场景。
把四种机制放在一起看,Gitea 的过载哲学是分层设卡、宁丢不堵:HTTP 层 QoS 丢低优先级请求(503),Actions 层信号量让 runner 下轮再试(throttled),队列层 5 秒 Push 超时逼调用方降速(DeadlineExceeded),镜像层每轮限量入队(errLimit)——每一层都选择"快速失败并留痕",没有任何一层无限等待。
10.12 配置项总表与源码级问答
10.12.1 四个基础设施的配置速查表
[queue] 公共节 + [queue.<name>] 具名节(代码默认值,app.example.ini:1666):
| 键 | 代码默认 | 示例文件值 | 说明 |
|---|---|---|---|
TYPE | level | persistable-channel(=level) | dummy/immediate/channel/level/redis |
DATADIR | queues/common | queues/ | 相对 AppDataPath;level 后端落库目录 |
CONN_STR | — | — | redis 连接;回退 [redis].CONN_STR → localhost |
LENGTH | 100000 | 100000 | 队列满上限,满了 Push 等 5s 报错 |
BATCH_LENGTH | 20 | 20 | 攒满即派发的批大小 |
QUEUE_NAME | _queue | _queue | 存储键后缀(<name>_queue) |
SET_NAME | _unique | _unique | unique 集合键后缀 |
MAX_WORKERS | NumCPU/2(1..10) | 空=同左 | worker 上限;≤0 视为无上限 |
[cron.<name>](每任务一节,无公共节):ENABLED / RUN_AT_START / SCHEDULE / NOTICE_ON_SUCCESS + 任务专属键(OLDER_THAN、PULL_LIMIT 等),默认值见 10.9.6 表。
[cache] / [cache.last_commit](app.example.ini:1981/:2005):ADAPTER(memory/redis/memcache/twoqueue)、HOST、INTERVAL=60、ITEM_TTL=16h、[cache.last_commit].ITEM_TTL=8760h。
[global_lock](app.example.ini:3123):SERVICE_TYPE=memory(memory/redis)、SERVICE_CONN_STR(默认共享 [redis])。
相关但独立的节:[redis] CONN_STR(共享连接,:1653)、[session](:2015,session 存储复用同一 cache 抽象但独立配置)。
10.12.2 源码级问答
Q1:为什么 Push 队列满返回的是 context.DeadlineExceeded 而不是专用的 ErrQueueFull?
因为两种后端的"满"实现路径不同却共享同一错误通道。channel 后端用 case <-time.After(pushBlockTime): return context.DeadlineExceeded(base_channel.go:65-66);redis 后端的 backoffCall 在重试窗口耗尽时同样返回 context.DeadlineExceeded(backoff.go:53-54 case <-opts.end)。统一到标准库错误值让业务层一行 errors.Is(err, context.DeadlineExceeded) 就能识别"满",不用维护跨包错误;代价是它与真正的"ctx 超时"无法区分——但对调用方而言处理方式相同(重试或告警),区分没有收益。
Q2:worker pool 的 batchChan 为什么是无缓冲 channel?
有缓冲会引入两个问题:其一,缓冲里躺着的批在关停时要额外排空路径(无缓冲下 close(batchChan) + range 就能枚举所有未取走的批,10.3.5 的排空逻辑因此简单);其二,无缓冲让"派发"天然成为背压信号——select 的 default 分支立刻告诉分发循环"没有空闲 worker",进而触发起新 worker 的判定(10.3.3)。有缓冲时这个信号会被缓冲区吞掉,扩缩容决策就失去了依据。
Q3:unhandled 回队会不会造成无限循环?
会,而且这是有意接受的。整批失败时睡 1 秒再回队(10.3.4),等价于 1 次/秒的重试节奏——对"索引器启动中"这类暂态故障,几秒到几分钟后自动痊愈;对永久故障(坏数据),handler 作者必须像 code_indexer 那样主动返回 nil 打断循环(10.4.3)。系统层面唯一的兜底是关停:ctxRun.Done() 会让退避 select 立即返回、回队 Push 失败后走 basePushForShutdown,最终队列停下。把"判断数据是否可重试"的责任下放给 handler,是这个设计的核心取舍——队列系统不理解业务语义,宁可让 16 个 handler 各自决定,也不做统一的重试计数器(那会引入"重试 N 次后永久丢弃"的更危险语义)。
Q4:为什么 cron 的 RunAtStart 要 go task.Run() 而不是直接调?
直接调会串行化:check_repo_stats 在大实例上要遍历所有仓库,若同步执行,cron.Init 会被卡住,InitWebInstalled 不返回,HTTP 服务起不来——用户会看到"进程活着但网站打不开"的诡异状态。异步化后代价是 scheduler.Start() 晚于首批任务几毫秒到几秒,但由于任务挂的是 gocron job 之外的独立 goroutine 且每个任务有 globallock,即使 cron 触发与 RunAtStart 撞上,也只有一个实例能执行(10.9.4)。
Q5:缓存 TTL=0 关得掉所有缓存吗?
关不掉。GetString/GetInt64 检查 setting.CacheService.TTL == 0 直接走 getFunc(cache.go:86-88),但三类调用点不受影响:GetCache().Get/Put 直接调用(分支 divergence、commit status)用的是自己算的 TTL;PutJSON/GetJSON(contributors)同理;ChiCache() 逃逸给验证码的底层缓存完全不看这个配置。要全局关闭只能改 ADAPTER——这是"便捷函数层开关"与"基础设施层开关"的区别,文档含糊其辞的部署指南常在这里误导用户。
Q6:gitea manager flush-queues 到底做了什么?会丢数据吗?
调用链:cmd/manager.go:71(timeout 默认 60s、--non-blocking)→ HTTP /manager/flush-queues(routers/private/internal.go:92)→ routers/private/manager.go。同步模式把每个队列 FlushWithContext(ctx, timeout) 逐个消费完(超时返回 408);非阻塞模式用 HammerContext 起 goroutine 后立即返回 202——执行中的进程不受影响,只是截止时间更狠(Hammer 阶段的语义是"尽快")。数据不会丢:flush 是"消费"不是"删除",handler 正常处理;超时部分留在队列里。真正的"删除"是 admin 队列页的 RemoveAllItems(10.2 各后端的 RemoveAll),那才是丢数据的操作——所以它被单独放在页面按钮上而不是和 flush 混用。
Q7:为什么 unique 队列的去重键是序列化后的字节而不是业务 ID?
架构上 baseQueue 只见 []byte(10.2.1 接口),它不知道 item 的业务结构——这是 base 层与 worker 层的刻意解耦,四种后端才能用同一套测试代码(包文档:"they are tested by the same testing code")。副作用是去重粒度 = item 的完整 JSON:issue_indexer 的 item 是 *IndexerMetadata{ID, IsDelete, IDs},同一 issue 的"更新"和"删除"是不同字节因而都可入队;而 PR 检查选 string ID 让所有"同 PR"请求都撞同一个键(10.6 的观察四)。item 设计决定去重语义,这个自由度(和陷阱)交给了每个队列的创建者。
10.12.3 本章小结
四套基础设施的分工可以用一句话划界:队列搬运工作,缓存记住答案,cron 挑起时刻,globallock 保证唯一;限流与防抖(10.11)则不属于任何一套,它是散落在各层的"快速失败"纪律。
- 队列的三层结构(base → WorkerPoolQueue → Manager)把"存储选型"与"并发策略"彻底分离:换 redis 后端不影响任何 handler 代码;100ms 批合并、1 秒空闲缩容、2 秒关停宽限这一组常量决定了它的呼吸节奏;
unhandled回执机制是索引器这类外部依赖的自愈通道,但是否回队的判断权在 handler; - 缓存是对 go-chi/cache 的克制包装:八个方法的
StringCache接口、错误前缀编码实现负结果缓存、请求级 EphemeralCache 解决单请求重复查询;SlowCacheThreshold=30ms的检测在 admin SelfCheck 而非 doctor; - cron 只有一个包(
services/cron,无modules/cron):31 个内置任务分三组、默认开关按破坏性分层、schedule 秒级支持看字段数、无 HideNightly 概念; - globallock 是两行接口背后的分布式纪律:memory/redis 双后端、30s TTL + 15s 续期、
ReleaseFunc的"可重复但不可不调"契约;它与 unique 队列各守 PR 检查时间轴上的不同窗口,缺一不可。
下一章(第 11 章)将进入搜索索引——issue_indexer/code_indexer 两个队列的下游正是索引器本体,本章多次出现的"unhandled 回队"在那里会看到具体的应用现场。