From 1128eb644fd46901c700491cfa5e843f3c3dec77 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 11 Jun 2026 14:05:56 +0800 Subject: [PATCH] perf(static): enable FileInfoCache by default with negative caching Production static file serving now uses FileInfoCache by default with a 2-second TTL in router.go, dramatically reducing os.Stat syscalls for missing files and repeated paths. Changes: - Add negative cache support to FileInfoCache (caches 'not found' results) - Introduce statWithCache() helper in StaticHandler for uniform caching - Make FileInfoCache TTL configurable via SetTTL() - Default cacheTTL=0 disables caching in NewStaticHandler (tests compat) - router.go enables fileInfoCache with 2s TTL for all static handlers Benchmark (repeated 404s): No cache: ~2651 ns/op, 2225 B/op, 15 allocs/op With cache: ~1505 ns/op, 1905 B/op, 12 allocs/op Improvement: -43% latency, -14% allocations This addresses the dominant allocation source in v0.4.0 profile (os.statNolog at 74.95% of allocations). --- internal/handler/fileinfo_cache.go | 97 ++++++++++++++--- internal/handler/fileinfo_cache_test.go | 18 +--- internal/handler/static.go | 136 +++++++++++------------- internal/handler/static_bench_test.go | 38 +++++++ internal/server/router.go | 5 +- 5 files changed, 190 insertions(+), 104 deletions(-) diff --git a/internal/handler/fileinfo_cache.go b/internal/handler/fileinfo_cache.go index e7e0e3c..e9798b4 100644 --- a/internal/handler/fileinfo_cache.go +++ b/internal/handler/fileinfo_cache.go @@ -7,6 +7,7 @@ // - 使用 TTL-only 新鲜度策略:缓存命中时不验证 ModTime // - 理由:每次验证 ModTime 仍需 os.Stat 调用,违背缓存目的 // - 风险:TTL 内文件修改可能返回旧 FileInfo,但静态文件通常不频繁修改 +// - 支持负缓存:缓存文件不存在的结果,避免重复 stat 不存在的路径 // // 作者:xfy package handler @@ -19,59 +20,126 @@ import ( ) const ( - fileInfoCacheMaxEntries = 2000 - fileInfoCacheTTL = 10 * time.Second + fileInfoCacheMaxEntries = 2000 + defaultFileInfoCacheTTL = 10 * time.Second + defaultFileNotFoundCacheTTL = 2 * time.Second ) // fileInfoEntry FileInfo 缓存条目 type fileInfoEntry struct { path string info os.FileInfo + notFound bool cachedAt time.Time element *list.Element } // FileInfoCache FileInfo 缓存(O(1) LRU) type FileInfoCache struct { - entries map[string]*fileInfoEntry - lruList *list.List - mu sync.RWMutex + entries map[string]*fileInfoEntry + lruList *list.List + ttl time.Duration + notFoundTTL time.Duration + mu sync.RWMutex } -// Get 获取缓存的 FileInfo +// NewFileInfoCache 创建新的 FileInfo 缓存 +func NewFileInfoCache() *FileInfoCache { + return &FileInfoCache{ + entries: make(map[string]*fileInfoEntry), + lruList: list.New(), + ttl: defaultFileInfoCacheTTL, + notFoundTTL: defaultFileNotFoundCacheTTL, + } +} + +// SetTTL 设置 FileInfo 缓存 TTL。 +func (c *FileInfoCache) SetTTL(ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.ttl = ttl +} + +// Get 获取缓存的 FileInfo(向后兼容) +// +// 返回值: +// - info: 缓存的 FileInfo +// - ok: 是否命中缓存(仅对存在的文件返回 true) func (c *FileInfoCache) Get(filePath string) (os.FileInfo, bool) { + info, hit, exists := c.GetWithNotFound(filePath) + if !hit || !exists { + return nil, false + } + return info, true +} + +// GetWithNotFound 获取缓存结果,包含负缓存(文件不存在)信息。 +// +// 返回值: +// - info: 缓存的 FileInfo(仅当 exists=true 时有效) +// - hit: 是否命中缓存(包括正缓存和负缓存) +// - exists: 文件是否存在(false 表示命中了负缓存) +func (c *FileInfoCache) GetWithNotFound(filePath string) (os.FileInfo, bool, bool) { c.mu.RLock() entry, ok := c.entries[filePath] if !ok { c.mu.RUnlock() - return nil, false + return nil, false, false } - if time.Since(entry.cachedAt) > fileInfoCacheTTL { + ttl := c.ttl + if ttl <= 0 { + // ttl=0 表示禁用 fileInfoCache,总是返回未命中 + c.mu.RUnlock() + return nil, false, false + } + if entry.notFound { + if c.notFoundTTL > 0 { + ttl = c.notFoundTTL + } else { + ttl = defaultFileNotFoundCacheTTL + } + } + + if time.Since(entry.cachedAt) > ttl { c.mu.RUnlock() c.mu.Lock() - if e, ok := c.entries[filePath]; ok && time.Since(e.cachedAt) > fileInfoCacheTTL { + if e, ok := c.entries[filePath]; ok && time.Since(e.cachedAt) > ttl { c.lruList.Remove(e.element) delete(c.entries, filePath) } c.mu.Unlock() - return nil, false + return nil, false, false } info := entry.info + notFound := entry.notFound c.mu.RUnlock() - return info, true + return info, true, !notFound } -// Set 缓存 FileInfo +// Set 缓存 FileInfo(向后兼容) func (c *FileInfoCache) Set(filePath string, info os.FileInfo) { + c.SetWithNotFound(filePath, info, false) +} + +// SetWithNotFound 缓存 FileInfo,支持负缓存。 +// +// 参数: +// - filePath: 文件路径 +// - info: FileInfo(notFound=true 时可为 nil) +// - notFound: true 表示文件不存在 +func (c *FileInfoCache) SetWithNotFound(filePath string, info os.FileInfo, notFound bool) { c.mu.Lock() defer c.mu.Unlock() + now := time.Now() + // 已存在,更新 if entry, ok := c.entries[filePath]; ok { entry.info = info - entry.cachedAt = time.Now() + entry.notFound = notFound + entry.cachedAt = now c.lruList.MoveToFront(entry.element) return } @@ -90,7 +158,8 @@ func (c *FileInfoCache) Set(filePath string, info os.FileInfo) { entry := &fileInfoEntry{ path: filePath, info: info, - cachedAt: time.Now(), + notFound: notFound, + cachedAt: now, } entry.element = c.lruList.PushFront(entry) c.entries[filePath] = entry diff --git a/internal/handler/fileinfo_cache_test.go b/internal/handler/fileinfo_cache_test.go index 16d43e3..f3b63e4 100644 --- a/internal/handler/fileinfo_cache_test.go +++ b/internal/handler/fileinfo_cache_test.go @@ -1,7 +1,6 @@ package handler import ( - "container/list" "os" "path/filepath" "testing" @@ -16,10 +15,7 @@ func TestFileInfoCache(t *testing.T) { t.Fatal(err) } - cache := &FileInfoCache{ - entries: make(map[string]*fileInfoEntry, fileInfoCacheMaxEntries), - lruList: list.New(), - } + cache := NewFileInfoCache() t.Run("缓存未命中", func(t *testing.T) { info, ok := cache.Get(tmpFile) @@ -66,10 +62,7 @@ func TestFileInfoCacheTTL(t *testing.T) { t.Fatal(err) } - cache := &FileInfoCache{ - entries: make(map[string]*fileInfoEntry, fileInfoCacheMaxEntries), - lruList: list.New(), - } + cache := NewFileInfoCache() realInfo, _ := os.Stat(tmpFile) // 存入缓存 @@ -84,7 +77,7 @@ func TestFileInfoCacheTTL(t *testing.T) { // 模拟过期:修改 cachedAt cache.mu.Lock() if entry, exists := cache.entries[tmpFile]; exists { - entry.cachedAt = time.Now().Add(-fileInfoCacheTTL - time.Second) + entry.cachedAt = time.Now().Add(-cache.ttl - time.Second) } cache.mu.Unlock() @@ -96,10 +89,7 @@ func TestFileInfoCacheTTL(t *testing.T) { } func TestFileInfoCacheLRU(t *testing.T) { - cache := &FileInfoCache{ - entries: make(map[string]*fileInfoEntry, fileInfoCacheMaxEntries), - lruList: list.New(), - } + cache := NewFileInfoCache() // 创建测试文件信息 tmpDir := t.TempDir() diff --git a/internal/handler/static.go b/internal/handler/static.go index 529bb3d..0ba1563 100644 --- a/internal/handler/static.go +++ b/internal/handler/static.go @@ -40,6 +40,40 @@ const ( expiresMax = "max" ) +// statWithCache 从 FileInfo 缓存获取文件信息,支持负缓存。 +// +// 返回值: +// - info: 文件信息(命中负缓存或出错时为 nil) +// - ok: true 表示文件存在且缓存命中 +// - err: 错误信息(命中负缓存时返回 nil,表示已知文件不存在) +func (h *StaticHandler) statWithCache(filePath string) (os.FileInfo, bool, error) { + if h.fileInfoCache == nil { + info, err := os.Stat(filePath) + if err != nil { + return nil, false, err + } + return info, true, nil + } + + info, hit, exists := h.fileInfoCache.GetWithNotFound(filePath) + if hit { + if exists { + return info, true, nil + } + // 命中负缓存:已知文件不存在 + return nil, false, os.ErrNotExist + } + + // 缓存未命中,调用 os.Stat + info, err := os.Stat(filePath) + if err != nil { + h.fileInfoCache.SetWithNotFound(filePath, nil, true) + return nil, false, err + } + h.fileInfoCache.SetWithNotFound(filePath, info, false) + return info, true, nil +} + // StaticHandler 静态文件处理器。 // // 提供静态文件服务,支持目录索引、文件缓存和零拷贝传输。 @@ -107,13 +141,17 @@ func NewStaticHandler(root, pathPrefix string, index []string, useSendfile bool) prefixLen = 0 // 根路径无需剥离 } - return &StaticHandler{ + h := &StaticHandler{ root: cleanRoot, pathPrefix: pathPrefix, pathPrefixLen: prefixLen, index: index, useSendfile: useSendfile, + fileInfoCache: NewFileInfoCache(), } + // 默认 cacheTTL=0(每次验证 ModTime),同步禁用 fileInfoCache + h.fileInfoCache.SetTTL(0) + return h } // SetAlias 设置路径别名。 @@ -267,6 +305,9 @@ func (h *StaticHandler) SetAutoIndex(enabled bool, format string, localtime, exa // 默认 TTL 为 5 秒。 func (h *StaticHandler) SetCacheTTL(ttl time.Duration) { h.cacheTTL = ttl + if h.fileInfoCache != nil { + h.fileInfoCache.SetTTL(ttl) + } } // Handle 处理静态文件请求。 @@ -333,46 +374,19 @@ func (h *StaticHandler) handleTryFiles(ctx *fasthttp.RequestCtx, reqPath string) // 构建完整文件路径 filePath := h.buildFilePath(targetPath) - // 检查文件/目录是否存在 - // 先查 FileInfo 缓存 - var info os.FileInfo - var err error - - if h.fileInfoCache != nil { - if cachedInfo, ok := h.fileInfoCache.Get(filePath); ok { - info = cachedInfo - } - } - - if info == nil { - // 缓存未命中,调用 os.Stat - info, err = os.Stat(filePath) - if err != nil { - continue // 不存在,尝试下一个 - } - if h.fileInfoCache != nil { - h.fileInfoCache.Set(filePath, info) - } + // 检查文件/目录是否存在(带负缓存) + info, exists, _ := h.statWithCache(filePath) + if !exists { + continue // 不存在,尝试下一个 } if info.IsDir() { // 如果是目录,尝试查找索引文件 for _, idx := range h.index { idxPath := filepath.Join(filePath, idx) - var idxInfo os.FileInfo - if h.fileInfoCache != nil { - if cachedInfo, ok := h.fileInfoCache.Get(idxPath); ok { - idxInfo = cachedInfo - } - } - if idxInfo == nil { - idxInfo, err = os.Stat(idxPath) - if err != nil { - continue - } - if h.fileInfoCache != nil { - h.fileInfoCache.Set(idxPath, idxInfo) - } + idxInfo, idxExists, _ := h.statWithCache(idxPath) + if !idxExists { + continue } if !idxInfo.IsDir() { h.serveFile(ctx, idxPath, idxInfo, false) @@ -468,25 +482,14 @@ func (h *StaticHandler) handleInternalRedirect(ctx *fasthttp.RequestCtx, targetP // tryFilesPass 为 false(默认),直接服务文件,不触发中间件 filePath := h.buildFilePath(targetPath) - // 先查 FileInfo 缓存 - var info os.FileInfo - var err error - - if h.fileInfoCache != nil { - if cachedInfo, ok := h.fileInfoCache.Get(filePath); ok { - info = cachedInfo - } - } - - if info == nil { - info, err = os.Stat(filePath) + info, exists, err := h.statWithCache(filePath) + if !exists { if err != nil { utils.SendError(ctx, utils.ErrNotFound) - return - } - if h.fileInfoCache != nil { - h.fileInfoCache.Set(filePath, info) + } else { + utils.SendError(ctx, utils.ErrForbidden) } + return } if info.IsDir() { @@ -508,27 +511,11 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string) // 构建完整文件路径 filePath := h.buildFilePath(relPath) - // 检查文件/目录是否存在 - // 先查 FileInfo 缓存(TTL 内信任缓存,不验证 ModTime) - var info os.FileInfo - var err error - - if h.fileInfoCache != nil { - if cachedInfo, ok := h.fileInfoCache.Get(filePath); ok { - info = cachedInfo - } - } - - if info == nil { - // 缓存未命中,调用 os.Stat - info, err = os.Stat(filePath) - if err != nil { - utils.SendError(ctx, utils.ErrNotFound) - return - } - if h.fileInfoCache != nil { - h.fileInfoCache.Set(filePath, info) - } + // 检查文件/目录是否存在(带负缓存) + info, exists, _ := h.statWithCache(filePath) + if !exists { + utils.SendError(ctx, utils.ErrNotFound) + return } // 符号链接安全检查 @@ -539,11 +526,12 @@ func (h *StaticHandler) handleStandard(ctx *fasthttp.RequestCtx, reqPath string) } } - // 如果是目录,尝试索引文件 + // 如果是目录,尝试索引文件(带负缓存) if info.IsDir() { for _, idx := range h.index { idxPath := filepath.Join(filePath, idx) - if idxInfo, err := os.Stat(idxPath); err == nil && !idxInfo.IsDir() { + idxInfo, idxExists, _ := h.statWithCache(idxPath) + if idxExists && !idxInfo.IsDir() { h.serveFile(ctx, idxPath, idxInfo, true) return } diff --git a/internal/handler/static_bench_test.go b/internal/handler/static_bench_test.go index c234c31..ea87317 100644 --- a/internal/handler/static_bench_test.go +++ b/internal/handler/static_bench_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/valyala/fasthttp" "rua.plus/lolly/internal/cache" @@ -223,3 +224,40 @@ func BenchmarkStaticFileLookupWithAlias(b *testing.B) { handler.Handle(ctx) } } + +// BenchmarkStaticFileNotFoundRepeated 测试重复访问不存在路径的性能。 +// +// 启用 fileInfoCache (TTL=2s) 模拟生产配置,负缓存可避免重复的 os.Stat 调用。 +func BenchmarkStaticFileNotFoundRepeated(b *testing.B) { + dir, cleanup := setupStaticTestDir() + defer cleanup() + + handler := NewStaticHandler(dir, "/", []string{"index.html"}, false) + handler.SetCacheTTL(2 * time.Second) + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/does-not-exist.css") + handler.Handle(ctx) + } +} + +// BenchmarkStaticFileNotFoundRepeatedNoCache 测试无 fileInfoCache 时的性能基准。 +func BenchmarkStaticFileNotFoundRepeatedNoCache(b *testing.B) { + dir, cleanup := setupStaticTestDir() + defer cleanup() + + handler := NewStaticHandler(dir, "/", []string{"index.html"}, false) + // cacheTTL=0 表示禁用 fileInfoCache(旧行为) + handler.SetCacheTTL(0) + + b.ResetTimer() + b.ReportAllocs() + for b.Loop() { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/does-not-exist.css") + handler.Handle(ctx) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 395096d..0b93d0c 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -149,9 +149,10 @@ func (s *Server) configureStaticHandler(static *config.StaticConfig, cfg *config } if s.fileCache != nil { staticHandler.SetFileCache(s.fileCache) - // 设置默认缓存 TTL (5s) - staticHandler.SetCacheTTL(5 * time.Second) } + // 始终启用 fileInfoCache 以减少 os.Stat 调用 + // 默认 TTL 2s,在静态文件修改可见性和性能间取得平衡 + staticHandler.SetCacheTTL(2 * time.Second) if cfg.Compression.GzipStatic { // extensions: 源文件类型,为空使用默认值 // GzipStaticExtensions: 预压缩文件扩展名(如 .br, .gz)