必须封装成可启停结构体并卡在router.Run()之后启动,否则服务未就绪就扫描会panic、时间漂移、goroutine泄漏;需用ticker+context控制生命周期,统一用time.Now()快照比对ModTime(),删除前加日志并捕获error继续执行。

直接在 Gin 启动后起 time.Ticker 协程清理上传目录是最简可行路径,但必须封装成可启停结构体、卡在 router.Run() 之后启动,否则服务未就绪就扫文件会 panic 或漏删。
为什么不能在 main() 里直接 go time.Sleep 轮询
常见错误是写成 go func() { for { clean(); time.Sleep(1 * time.Hour) } }()——这会导致三类问题:
- 服务还没监听成功(
router.Run()没返回),clean()就开始遍历目录,若uploads/目录不存在或权限不足,直接 panic 退出 goroutine,后续再无清理 - 每次清理耗时不稳定(比如某次扫描遇到大文件卡住 90 秒),下一轮 sleep 就变成 1h50m,时间漂移严重
- 服务关闭时 goroutine 无法感知,残留运行,测试时容易泄漏
用 time.Ticker + context 控制生命周期的正确姿势
清理器必须是结构体,含 ticker *time.Ticker、done chan struct{}、dir string 和 maxAge time.Duration 字段。Start 方法里初始化 ticker 并启动 goroutine;Stop 方法调 ticker.Stop() 并 close done。
关键实操点:
- 启动时机必须卡在
router.Run(":8080")之后——它是个阻塞调用,返回即表示 HTTP server 已就绪 - 每次 tick 触发前先检查
dir是否存在且可读:if _, err := os.Stat(dir); os.IsNotExist(err) { return } - 统一用
time.Now()快照比对os.FileInfo.ModTime(),别在循环里反复调time.Since(),避免纳秒级系统时间跳变导致误删 - 删除前加日志:
log.Printf("deleting expired file: %s", path);捕获os.Remove错误但 continue,防止单个权限问题中断整轮扫描
SQL 式清理?不,文件系统没 WHERE 条件
数据库能靠 WHERE expires_at 安全分批删,但文件系统没有索引和原子条件删除。所谓“过期”全靠应用层比对时间戳,因此:
- 别依赖
os.FileInfo.Atim()(访问时间)——多数 Linux 默认禁用,值不可靠 - 若需更高精度(比如按文件内容里的 JSON 字段判断过期),得打开并解析每个文件,但 I/O 开销剧增,仅适合小目录
- 临时文件可提前用
os.Chtimes(path, atime, mtime)把ModTime设为期望过期时刻,后续只比对ModTime即可,省去解析开销 - 绝不用
os.RemoveAll(dir)清整个目录——它不区分新旧文件,且可能因子目录权限失败而中断
robfig/cron/v3 不是银弹,慎用 AddFunc
虽然 robfig/cron/v3 支持 @hourly 等表达式,但清理类任务用 AddFunc 易漏跑:
- 闭包捕获变量出错:比如
for _, d := range dirs { c.AddFunc("@daily", func() { clean(d) }) },最终所有任务都 clean 最后一个d - panic 不 recover:一个
os.Remove权限拒绝会终止整个 cron 实例,后续所有任务停摆 - 无超时控制:若某次清理卡死,v3 默认跳过下一轮,@daily 变成 @never
真要用 cron,必须封装成 cron.Job 实现 Run() 方法,显式加 recover()、context.WithTimeout() 和带上下文的日志。
最易忽略的一点:清理器 Stop 时,要确保正在执行的那轮 clean() 能被上下文取消——否则 filepath.WalkDir 可能卡在某个大文件上,Stop 返回了但 goroutine 还在跑。


















