From 232abf2019acf3f66f43f1146dfdf008f7962651 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:09:54 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=8A=A0=E5=AE=B9=E9=87=8F=E9=97=B8=E4=B8=8E=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个), 超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉 进程被杀留下的 .tmp - 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」, 热图不会被误删后反复回源拉存储 - 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed 标记,否则已删图片会永久占着缓存 --- backend/internal/admin/api.go | 7 ++ backend/internal/api/thumb.go | 1 + backend/internal/thumbs/thumbs.go | 110 ++++++++++++++++++++++++- backend/internal/thumbs/thumbs_test.go | 78 ++++++++++++++++++ backend/main.go | 1 + 5 files changed, 193 insertions(+), 4 deletions(-) diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 47d55fb..079994a 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -25,6 +25,7 @@ import ( "oneblog/internal/model" "oneblog/internal/storage" "oneblog/internal/store" + "oneblog/internal/thumbs" ) type API struct { @@ -34,6 +35,9 @@ type API struct { // 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例) Blobs storage.BlobStore PublicBase string + // Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的 + // 缩略图产物,否则已删图片会一直占着缓存。 + Thumbs *thumbs.Store loginOnce sync.Once logins *loginLimiter @@ -786,6 +790,9 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { httpx.ServerError(w, err) return } + if a.Thumbs != nil { + a.Thumbs.Purge(f.SHA256) + } httpx.OK(w, map[string]any{"ok": true}) default: httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") diff --git a/backend/internal/api/thumb.go b/backend/internal/api/thumb.go index fdcfa44..6274c5e 100644 --- a/backend/internal/api/thumb.go +++ b/backend/internal/api/thumb.go @@ -127,6 +127,7 @@ func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string httpx.NotFound(w) return } + a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") http.ServeContent(w, r, path, fi.ModTime(), fp) } diff --git a/backend/internal/thumbs/thumbs.go b/backend/internal/thumbs/thumbs.go index 3b70c37..b366186 100644 --- a/backend/internal/thumbs/thumbs.go +++ b/backend/internal/thumbs/thumbs.go @@ -11,8 +11,10 @@ import ( "image/jpeg" "image/png" "io" + "io/fs" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -168,14 +170,27 @@ func min255(v uint32) uint32 { // {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记 // // 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。 +// 缓存只增不减会慢慢吃磁盘,所以 Put 之后按 MaxBytes 做容量闸淘汰(LRU: +// 命中时 Touch 更新 mtime,淘汰时先下手最久没人要的)。 type Store struct { - Dir string - mu sync.Mutex - locks map[string]*sync.Mutex + Dir string + MaxBytes int64 + + mu sync.Mutex + locks map[string]*sync.Mutex + lastSweep time.Time + sweeping bool } +// DefaultMaxBytes 是缓存总量上限:约 1700 张 150KB 的缩略图, +// 远超单用户博客的实际存量,纯粹是防多年以后无限涨。 +const DefaultMaxBytes = 256 << 20 + +// sweepInterval 限制扫描频率——淘汰是后台维护,不该拖慢写缓存的请求。 +const sweepInterval = 10 * time.Minute + func NewStore(dir string) *Store { - return &Store{Dir: dir, locks: make(map[string]*sync.Mutex)} + return &Store{Dir: dir, MaxBytes: DefaultMaxBytes, locks: make(map[string]*sync.Mutex)} } func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) } @@ -217,9 +232,35 @@ func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) return "", err } _ = os.Remove(s.failedPath(sha)) + s.maybeSweep() return p, nil } +// Touch 把缓存文件的 mtime 拨到现在:命中一次就等于「最近还要用」, +// 供容量闸按 LRU 淘汰。失败无所谓(只影响淘汰顺序)。 +func (s *Store) Touch(path string) { + now := time.Now() + _ = os.Chtimes(path, now, now) +} + +// Purge 清掉某个原图(按内容哈希)的全部缩略图与失败标记。 +// 删除上传文件时调用,否则缓存会一直留着已删图片的产物。 +func (s *Store) Purge(sha string) { + if len(sha) < 32 { + return + } + entries, err := os.ReadDir(s.dir(sha)) + if err != nil { + return + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), sha[:32]) { // {sha}-{w}.jpg / {sha}.failed + _ = os.Remove(filepath.Join(s.dir(sha), e.Name())) + } + } + _ = os.Remove(s.dir(sha)) // 目录空了就收掉 +} + // FailedRecently 报告是否在冷却期(近期生成失败过)。 func (s *Store) FailedRecently(sha string) bool { fi, err := os.Stat(s.failedPath(sha)) @@ -252,6 +293,67 @@ func (s *Store) Lock(sha string, w int) *sync.Mutex { // lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。 const lockCacheMax = 4096 +// maybeSweep 在写入新缓存后检查总量;超上限就按 mtime 从旧到新删, +// 删到 3/4 水位(留滞回,避免每次写入都刚好卡在边界反复删)。 +// 每 sweepInterval 最多跑一次,且同一时刻只有一个在跑。 +func (s *Store) maybeSweep() { + if s.MaxBytes <= 0 { + return + } + s.mu.Lock() + if s.sweeping || time.Since(s.lastSweep) < sweepInterval { + s.mu.Unlock() + return + } + s.sweeping, s.lastSweep = true, time.Now() + s.mu.Unlock() + + defer func() { + s.mu.Lock() + s.sweeping = false + s.mu.Unlock() + }() + + var files []fileInfo + var total int64 + _ = filepath.WalkDir(s.Dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + // 半途而废的临时文件(进程被杀)直接清 + if strings.HasSuffix(path, ".tmp") { + _ = os.Remove(path) + return nil + } + fi, err := d.Info() + if err != nil { + return nil + } + total += fi.Size() + files = append(files, fileInfo{path: path, size: fi.Size(), mtime: fi.ModTime()}) + return nil + }) + if total <= s.MaxBytes { + return + } + sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) }) + lowWater := s.MaxBytes * 3 / 4 + for _, f := range files { + if total <= lowWater { + break + } + if err := os.Remove(f.path); err == nil { + total -= f.size + } + } +} + +type fileInfo struct { + path string + size int64 + mtime time.Time +} + func extFor(mime string) string { switch mime { case "image/png": diff --git a/backend/internal/thumbs/thumbs_test.go b/backend/internal/thumbs/thumbs_test.go index 7209ec1..e2a1742 100644 --- a/backend/internal/thumbs/thumbs_test.go +++ b/backend/internal/thumbs/thumbs_test.go @@ -6,7 +6,12 @@ import ( "image/color" "image/jpeg" "image/png" + "io/fs" + "os" + "path/filepath" + "strings" "testing" + "time" ) func makePNG(t *testing.T, w, h int, transparent bool) []byte { @@ -92,3 +97,76 @@ func TestFlattenAlphaUnpremultiply(t *testing.T) { t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3]) } } + +func TestSweepEvictsOldest(t *testing.T) { + s := NewStore(t.TempDir()) + s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个 + sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) } + + // 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间) + for i := 0; i < 3; i++ { + if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil { + t.Fatal(err) + } + } + // 跳过节流,让这次 Put 真的触发扫描 + s.lastSweep = time.Time{} + oldest := time.Now().Add(-3 * time.Hour) + for i := 0; i < 2; i++ { // 前两个假装很久没人访问 + os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest) + } + if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil { + t.Fatal(err) + } + + var total int64 + var kept []string + filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + fi, _ := d.Info() + total += fi.Size() + kept = append(kept, d.Name()) + return nil + }) + if total > s.MaxBytes { + t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept) + } + if len(kept) >= 4 { + t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept) + } + // 最新写入的那个必须还在 + if s.FindCached(sha(3), 480) == "" { + t.Fatalf("刚写入的缓存被误删: %v", kept) + } +} + +func TestPurgeRemovesAllEntriesForFile(t *testing.T) { + s := NewStore(t.TempDir()) + sha := strings.Repeat("f", 64) + for _, w := range []int{480, 960} { + if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil { + t.Fatal(err) + } + } + s.MarkFailed(sha) + other := strings.Repeat("a", 64) + if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil { + t.Fatal(err) + } + + s.Purge(sha) + + for _, w := range []int{480, 960} { + if p := s.FindCached(sha, w); p != "" { + t.Fatalf("purge 后仍残留 %s", p) + } + } + if s.FailedRecently(sha) { + t.Fatal("失败标记没清掉") + } + if s.FindCached(other, 480) == "" { + t.Fatal("误删了别的文件的缓存") + } +} diff --git a/backend/main.go b/backend/main.go index e4ffd95..00ed714 100644 --- a/backend/main.go +++ b/backend/main.go @@ -82,6 +82,7 @@ func main() { } adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI.Blobs = blobs + adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图 root := http.NewServeMux() root.Handle("/api/admin/", adminAPI.Routes())