Gitea源码深度剖析-第10章 异步基础设施:队列、缓存、Cron 调度、限流与全局锁

0 阅读1小时+

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 验证后的真实情况书写,这里集中列出,后文不再重复声明:

  1. modules/cron 不存在。Cron 调度只有一个包:services/cron/(内部文件 cron.go、tasks.go、setting.go、tasks_basic.go、tasks_extended.go、tasks_actions.go)。全局唯一入口是 cron.Init(ctx)。
  2. queue.CreateQueue / GetQueue 不存在。真实的创建入口只有 CreateSimpleQueue[T] 与 CreateUniqueQueue[T] 两个泛型函数(modules/queue/manager.go:93/98);查询入口是 Manager.GetManagedQueue(qid),按整数 qid 取,没有按名字查询的 API。
  3. "notifications"、"api"、"prpatch" 这些队列名不存在。真实队列名以 10.6 节的实测清单为准(例如站内通知叫 notification-service,PR 变更检查叫 pr_patch_checker)。
  4. PersistableChannelQueue 结构体不存在。getNewQueueFn 的 default 分支注释写明 level(leveldb,levelqueue,persistable-channel) 全部落到 level 后端,旧版"channel + LevelDB 混合持久化队列"在当前版本由 level 实现承担(见 10.2.3)。
  5. HideNightly 不存在(大小写不敏感全仓库 grep 无匹配)。任务调度没有"隐藏夜间任务"的概念,只有每个任务自己的 Schedule 字符串。
  6. SlowCacheThreshold 不在 doctor 里。services/doctor 的检查清单没有任何 cache/queue 项;该阈值的真实消费点是 admin 面板的 TestCache 与 SelfCheck(见 10.8.6)。
  7. 缓存接口叫 StringCache,没有 Iterator。接口定义在 modules/cache/string_cache.go,含 Ping/Get/Put/Delete/IsExist/PutJSON/GetJSON/ChiCache 八个方法。
  8. 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 三层结构

队列系统是清晰的三明治结构,自下而上:

  1. baseQueue(base.go 定义接口,四个实现):只负责"字节进、字节出",外加去重与长度查询;
  2. WorkerPoolQueue[T](workerqueue.go):泛型包装,负责序列化、worker 池、批量派发、flush、关停;这是业务代码唯一拿到手的对象;
  3. 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[&#34;Queues map[int64]ManagedWorkerPoolQueue<br/>qidCounter 自增分配 qid<br/>GetManager() / FlushAll() / AddManagedQueue()&#34;]
    end

    subgraph wpq[&#34;WorkerPoolQueue[T](workerqueue.go / workergroup.go)&#34;]
        RUN[&#34;doRun() 主循环<br/>popItemChan ← baseQueue.PopItem<br/>batchBuffer 合并 → doDispatchBatchToWorker&#34;]
        BC[&#34;batchChan chan []T&#34;]
        WK[&#34;worker × N(doStartNewWorker)<br/>doWorkerHandle → safeHandler<br/>unhandled → 延迟回队&#34;]
        FL[&#34;flushChan(测试/admin flush-queues)&#34;]
    end

    subgraph base[&#34;baseQueue 四实现(base.go 接口)&#34;]
        CH[&#34;baseChannel<br/>chan []byte + Set 去重<br/>内存、进程内&#34;]
        LV[&#34;baseLevelQueue(+unique)<br/>gitea.com/lunny/levelqueue<br/>LevelDB 持久化&#34;]
        RD[&#34;baseRedis(+unique)<br/>RPush/LPop + SAdd/SRem<br/>跨实例共享&#34;]
        DM[&#34;baseDummy<br/>空操作/立即执行<br/>仅测试&#34;]
    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))
}
常量值作用出场位置
batchDebounceDuration100ms攒不满一个 batch 时的最长等待10.3.3
workerIdleDuration1sworker 空闲多久后退出(但保留最后一个)10.3.3
shutdownDefaultTimeout2s关停时等 worker 收尾的默认宽限10.3.5
unhandledItemRequeueDuration1s整批失败后的退避时长10.3.4
pushBlockTime(base.go)5sPush 队列满超时10.2
backoffBegin/backoffUpper(backoff.go)50ms/2sRedis 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,满足其一即派发:

  1. 攒够 batchLength(配置 BATCH_LENGTH,默认 20)——立即派发,背靠背吞吐;
  2. 攒不够——启动一个 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)
			}
		}
	}
}

三条规则:

  1. workerActiveNum 计数随处理起落,admin 页的 active workers 列就是它;
  2. 整批失败才退避:只有 len(unhandled) == len(batch)(一条都没处理成功)才睡 unhandledItemRequeueDuration(1 秒)——这是"索引器整个宕了"的场景,立刻回队只会空转刷日志;部分失败则立即回队,因为多半是单条数据的问题。退避期间 flush 中的失败批不回队(isFlushing 分支),否则 flush 永远等不到空队列;
  3. 回队走 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)
	}()

逐行拆解这个"优雅"到底优雅在哪:

  1. 三路收集在途数据:batchChan 里已派发未被取走的批(close 后 range 抽干)、batchBuffer 里攒了但没派发的、popItemChan 里弹出但还没进 buffer 的——一个 item 都不许蒸发;
  2. shutdownTimeout != 0(默认 2s)时把这些 item 直接写回 base queue(basePushForShutdown),LevelDB/Redis 后端上等于落盘,下次启动接着跑;channel 后端上退回内存缓冲。同时等所有 worker 收尾,最多等 shutdownTimeout,超时记 error——这就是 ShutdownWait(timeout)(workerqueue.go:194-198,写入 timeout 后 cancel 并阻塞等 shutdownDone)承诺的"predictable time";
  3. 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_stats cron 还会再触发;
  • 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.ErrAlreadyInQueueunique 队列里已有同 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
	}
  1. 硬编码默认: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 是存储键后缀;
  2. 默认值经 json.Marshal/Unmarshal 深拷贝(49-54 行,手写逐字段拷贝太啰嗦,借 JSON 序列化一步到位);
  3. 先映射公共节 [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 对空值的处理可能覆盖公共节已合并的值,这里加保险;

  1. DATADIR 相对路径补上 AppDataPath 前缀并转斜杠(73-79 行);
  2. 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] 一处;

  1. 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/(示例)vs queues/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 类型创建位置(文件:行)业务含义
1webhook_senderuniqueint64(hook task ID)services/webhook/deliver.go:281Webhook 投递
2notification-servicesimpleissueNotificationOptsservices/uinotification/notify.go:46站内通知红点
3tasksimple—(services/task)services/task/task.go:42管理后台异步任务
4mirrorunique—(镜像 ID)services/mirror/queue.go:43镜像同步
5branch_syncunique—services/repository/branch.go:676分支同步
6mailsimple邮件消息services/mailer/mailer.go:48邮件发送
7pr_auto_mergeunique—services/automerge/automerge.go:37排程自动合并
8repo-archiveunique—services/repository/archiver/archiver.go:283仓库压缩包生成
9tag_syncunique—services/release/tag.go:45标签同步
10repo_license_updaterunique—services/repository/repository.go:112许可证识别更新
11push_updatesimple[]*PushUpdateOptions(整切片入队)services/repository/push.go:34push 后置事件分发
12pr_patch_checkeruniquestring(PR ID 十进制串)services/pull/check.go:529PR 可合并性检查
13repo_stats_updateuniqueint64(仓库 ID)modules/indexer/stats/queue.go:32仓库统计索引
14issue_indexerunique*IndexerMetadata{ID, IsDelete, IDs}modules/indexer/issues/indexer.go:70Issue/PR 全文索引
15code_indexerunique—(仓库 ID)modules/indexer/code/indexer.go:141代码内容索引
16actions_ready_jobunique—services/actions/init.go:63Actions 就绪任务

几条校正与观察(对应本章开头的证伪清单第 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 pushrouters/private/hook_post_receive.go post-receive → PushUpdates(:109);分支新建 → branch.go:134push_update、repo_stats_update、issue_indexer、pr_patch_checker 等批量入队hook 进程只负责点火
Cron 调度gocron 到点 / DoRunAtStart 启动即跑任务函数内部再推队列(如 update_mirrors → mirror 队列)scheduler.Shutdown() 挂在 RunAtShutdown
队列消费者worker 调 handlerhandler 内部再推下游队列(通知 → 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注册点下游
uinotificationservices/uinotification/notify.go:36notification-service 队列
webhookservices/webhook/notifier.go:32webhook_sender 队列
indexerservices/indexer/indexer.go:15issue_indexer.UpdateIssueIndexer
websocketservices/websocket/notifier.go:16实时推送
feedservices/feed/notifier.go:31动态流写库
auditservices/audit/notifier.go:19审计日志
mirrorservices/mirror/notifier.go:16mirror 队列
automergeservices/automerge/automerge.go:35pr_auto_merge 队列
mailerservices/mailer/mailer.go:34mail 队列
actionsservices/actions/init.go:69Actions 工作流触发

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 帧)

时序图背后的三个设计判断:

  1. HTTP handler 在入队后立即返回。队列把"写通知表 + 查所有 watcher + 推送"这批 O(关注者数) 的工作移出请求路径——这就是队列存在的第一性理由;
  2. 总线是同步的,队列是异步的。多播本身不排队(10 个 notifier 的方法调用都在请求 goroutine 内完成),但每个 notifier 内部只做 Push 这一个 O(1) 动作,重活全在下游 worker。唯一的例外是 RepoPendingTransfer(uinotification 末尾)——它同步开事务写库,因为它必须与转移事务的成败一致;
  3. 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.Fataluinotification:48log.Fatal("Unable to create notification-service queue")通知能力是站点基础功能,宁可不启动
return errorstats:34、webhook:282errors.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.GetStringDNS 解析结果CacheService.TTL
邮箱哈希头像cache.GetString + SafeCacheKeygravatar 类哈希同上
提交计数cache.GetInt64git-commits-graph-count(services/context/repo.go:289)同上
用户设置项cache.GetStringuser setting同上
最后提交gitRepo.LastCommitCache(modules/git/last_commit_cache.go,TTL 经 setting.LastCommitCacheTTLSeconds() 注入)最后一次提交信息[cache.last_commit].ITEM_TTL 默认 8760h(一年)
仓库权限GetWithContextCacheRepoUserPermission请求级
GPG keys / emailsGetWithContextCache用户密钥、邮箱列表请求级
分支 divergenceGetCache().Get/Put30 天内分支差异显式 TTL
commit statusGetCache().Get/Put/Delete状态汇总显式 TTL
contributors 图PutJSON/GetJSON图数据含 error显式 TTL
验证码GetCache().ChiCache()进度挑战数据go-chi 默认
healthcheckPing()——

modules/setting/cache.go 的配置结构与适配器白名单:

	CacheService.Adapter = sec.Key("ADAPTER").In("memory", []string{"memory", "redis", "memcache", "twoqueue"})
INI 键默认说明
[cache].ADAPTERmemorymemory / redis / memcache / twoqueue
[cache].HOST—redis 取不到则回退 [redis].CONN_STR;twoqueue 回退 "50000"(容量);memcache 直接用
[cache].INTERVAL60内存适配器 GC 间隔(秒)
[cache].ITEM_TTL16h便捷函数默认 TTL
[cache.last_commit].ITEM_TTL8760h最后提交缓存 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 包:

  1. routers/web/admin/config.go:49——admin 系统配置页的 TestCache 按钮:测速超过 30ms 即在页面 flash 警告;
  2. 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
}

四道检查,每道都有明确理由:

  1. i18n 强制:任务名必须有 en-US 翻译键 admin.dashboard.<name>,否则注册失败——admin 仪表盘的任务列表靠这个键显示任务名,漏翻译会渲染成原始 key,Gitea 用启动失败逼贡献者补 options/locale/locale_en-US.json(也正呼应工作区规则"只编辑 locale_en-US.json");
  2. 读配置:setting.GetCronSettings(name, config) 把 [cron.<name>] 映射进 config——注册的同时完成配置注入,没有独立的"读配置"阶段;
  3. 重名拒绝:tasksMap 查重,重复即 error;
  4. 条件挂载: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 实现)。不是数据库行锁——grep cron 相关的 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,站点日常维护)

任务名EnabledRunAtStartSchedule额外默认参数作用
update_mirrors✅❌@every 10mPullLimit/PushLimit=50把启用的镜像推入 mirror 队列(仅 Mirror.Enabled 时注册)
repo_health_check✅❌@midnightTimeout=Git.Timeout.GC全仓库 fsck 健康检查
check_repo_stats✅✅@midnight—重算仓库统计(stars/forks 等)
archive_cleanup✅✅@midnightOlderThan=24h删除过期的仓库压缩包
sync_external_users✅❌@midnightUpdateExisting=true从外部域名同步用户头像
deleted_branches_cleanup✅✅@midnightOlderThan=24h清理已删除分支的记录
update_migration_poster_id✅✅@midnight—迁移仓库的发布者 ID 回填(仅 !DisableMigrations 时注册)
cleanup_hook_task_table✅❌@midnightOlderThan=168h, Keep=10清理 webhook 投递历史表
cleanup_packages✅✅@midnightOlderThan=24h删除过期包版本(仅 Packages.Enabled 时注册)
sync_repo_licenses❌❌@annually—批量识别仓库许可证

extended 组(tasks_extended.go,默认多为关闭的"重型操作")

任务名EnabledRunAtStartSchedule作用
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 168hOlderThan=365d,清理 Actions 运行记录
delete_old_audit_events✅❌@every 24hOlderThan(需审计开启才注册)
update_checker✅❌@every 168h请求 https://dl.gitea.com/gitea/version.json 检查新版本
delete_old_system_notices❌❌@every 168hOlderThan=168h,清理系统通知
gc_lfs❌❌@every 24hLFS 对象 GC(仅 LFS.StartServer 时注册)
rebuild_issue_indexer❌❌@annually全量重建 issue 索引

actions 组(tasks_actions.go,仅 Actions.Enabled 时整个文件参与注册)

任务名EnabledRunAtStartSchedule作用
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_SUCCESSfalse成功时也写系统通知

扩展 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
  1. 键空间隔离:gitea:globallock: 前缀,与队列键、缓存哈希名互不冲突;
  2. 30 秒过期 + 15 秒续期:startExtend 用 time.AfterFunc(redisLockExpiry/2, l.startExtend) 形成 15 秒一轮的续期自调度——每轮 mutexM.Range 找出所有未过期的锁逐个 Extend()。持锁进程崩溃时锁会在 30 秒内自动过期,不会永久死锁;续期失败也"过期兜底"(注释明说)。这是分布式锁的标准安全模式(锁必须有 TTL);
  3. 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——"锁被占"是正常业务结果不是错误,兑现接口注释的承诺;其他错误(连接失败)原样上抛;

  1. 释放失败静默: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 操作
镜像拉取互斥getRepoPullMirrorLockKeyservices/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_TYPEmemorymemory 或 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+10context 里有已登录用户
DefaultPriority0其他
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 投递失败"的归宿是三行数据库记录,而不是重试:

  1. CAS 抢占:Deliver 里 MarkTaskDelivered 用 WHERE is_delivered = false 的条件更新做并发去重(models/webhook/hooktask.go:188-195,count != 0 才继续)——unique 队列之外的第三道防线,防止同 task 被两个 worker 同时投递;
  2. 失败落库:defer 里 UpdateHookTask 写 IsSucceed(判定标准 resp.StatusCode/100 == 2)与响应详情,UpdateWebhookLastStatus 把最后一次状态挂到 webhook 上,admin 页面据此显示红叉;
  3. 重放是人工动作: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 同步的并发限制实况

镜像同步没有独立的信号量或限速器,并发由三层参数叠加而成:

  1. 每轮入队量: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 队列防重复排队);
  2. 同时执行数:[queue.mirror] 的 MAX_WORKERS(默认 NumCPU/2 夹 [1,10])——这才是真正的并发上限,队列攒批 + worker 池的机制(10.3.3)在此表现为"最多 N 个镜像同时 git fetch";
  3. 单仓库互斥: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 强制派发)batchDebounceDurationmodules/queue/workergroup.go
整批失败退避sleep 后回队unhandledItemRequeueDuration=1s同上,10.3.4
Redis pop/push 重试指数退避 50ms→2sbackoffBegin/backoffUppermodules/queue/backoff.go
验证邮件重发用缓存当限流器:Put("MailResendLimit_"+user, 180),IsExist 即拒绝180 秒 TTLrouters/web/auth/auth.go:741-747
静态资源热更新util.Debounce(重置式 timer,最后一次调用后 d 秒才执行)100msmodules/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):

键代码默认示例文件值说明
TYPElevelpersistable-channel(=level)dummy/immediate/channel/level/redis
DATADIRqueues/commonqueues/相对 AppDataPath;level 后端落库目录
CONN_STR——redis 连接;回退 [redis].CONN_STR → localhost
LENGTH100000100000队列满上限,满了 Push 等 5s 报错
BATCH_LENGTH2020攒满即派发的批大小
QUEUE_NAME_queue_queue存储键后缀(<name>_queue)
SET_NAME_unique_uniqueunique 集合键后缀
MAX_WORKERSNumCPU/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 回队"在那里会看到具体的应用现场。