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).
This commit is contained in:
xfy 2026-06-11 14:05:56 +08:00
parent 445401c40f
commit 1128eb644f
5 changed files with 190 additions and 104 deletions

View File

@ -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: FileInfonotFound=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

View File

@ -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()

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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