缩略图缓存加容量闸与删除联动清理

- Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个),
  超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉
  进程被杀留下的 .tmp
- 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」,
  热图不会被误删后反复回源拉存储
- 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed
  标记,否则已删图片会永久占着缓存
This commit is contained in:
Sakurasan
2026-09-28 15:09:54 +08:00
parent 6108aca34c
commit 232abf2019
5 changed files with 193 additions and 4 deletions
+7
View File
@@ -25,6 +25,7 @@ import (
"oneblog/internal/model" "oneblog/internal/model"
"oneblog/internal/storage" "oneblog/internal/storage"
"oneblog/internal/store" "oneblog/internal/store"
"oneblog/internal/thumbs"
) )
type API struct { type API struct {
@@ -34,6 +35,9 @@ type API struct {
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例) // 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
Blobs storage.BlobStore Blobs storage.BlobStore
PublicBase string PublicBase string
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
// 缩略图产物,否则已删图片会一直占着缓存。
Thumbs *thumbs.Store
loginOnce sync.Once loginOnce sync.Once
logins *loginLimiter logins *loginLimiter
@@ -786,6 +790,9 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
if a.Thumbs != nil {
a.Thumbs.Purge(f.SHA256)
}
httpx.OK(w, map[string]any{"ok": true}) httpx.OK(w, map[string]any{"ok": true})
default: default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
+1
View File
@@ -127,6 +127,7 @@ func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string
httpx.NotFound(w) httpx.NotFound(w)
return return
} }
a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path, fi.ModTime(), fp) http.ServeContent(w, r, path, fi.ModTime(), fp)
} }
+103 -1
View File
@@ -11,8 +11,10 @@ import (
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -168,14 +170,27 @@ func min255(v uint32) uint32 {
// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记 // {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记
// //
// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。 // 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。
// 缓存只增不减会慢慢吃磁盘,所以 Put 之后按 MaxBytes 做容量闸淘汰(LRU:
// 命中时 Touch 更新 mtime,淘汰时先下手最久没人要的)。
type Store struct { type Store struct {
Dir string Dir string
MaxBytes int64
mu sync.Mutex mu sync.Mutex
locks map[string]*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 { 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]) } 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 return "", err
} }
_ = os.Remove(s.failedPath(sha)) _ = os.Remove(s.failedPath(sha))
s.maybeSweep()
return p, nil 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 报告是否在冷却期(近期生成失败过)。 // FailedRecently 报告是否在冷却期(近期生成失败过)。
func (s *Store) FailedRecently(sha string) bool { func (s *Store) FailedRecently(sha string) bool {
fi, err := os.Stat(s.failedPath(sha)) fi, err := os.Stat(s.failedPath(sha))
@@ -252,6 +293,67 @@ func (s *Store) Lock(sha string, w int) *sync.Mutex {
// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。 // lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。
const lockCacheMax = 4096 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 { func extFor(mime string) string {
switch mime { switch mime {
case "image/png": case "image/png":
+78
View File
@@ -6,7 +6,12 @@ import (
"image/color" "image/color"
"image/jpeg" "image/jpeg"
"image/png" "image/png"
"io/fs"
"os"
"path/filepath"
"strings"
"testing" "testing"
"time"
) )
func makePNG(t *testing.T, w, h int, transparent bool) []byte { 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]) 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("误删了别的文件的缓存")
}
}
+1
View File
@@ -82,6 +82,7 @@ func main() {
} }
adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Blobs = blobs adminAPI.Blobs = blobs
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
root := http.NewServeMux() root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes()) root.Handle("/api/admin/", adminAPI.Routes())