lolly/internal/handler/fileinfo_cache.go
xfy 1128eb644f 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).
2026-06-11 14:05:56 +08:00

172 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package handler 提供 HTTP 请求处理器,包括路由、静态文件服务和零拷贝传输。
//
// 该文件实现 FileInfo 缓存,用于减少 os.Stat 调用。
// 替代原 fd 池设计,避免 fd 所有权问题。
//
// 设计说明:
// - 使用 TTL-only 新鲜度策略:缓存命中时不验证 ModTime
// - 理由:每次验证 ModTime 仍需 os.Stat 调用,违背缓存目的
// - 风险TTL 内文件修改可能返回旧 FileInfo但静态文件通常不频繁修改
// - 支持负缓存:缓存文件不存在的结果,避免重复 stat 不存在的路径
//
// 作者xfy
package handler
import (
"container/list"
"os"
"sync"
"time"
)
const (
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
ttl time.Duration
notFoundTTL time.Duration
mu sync.RWMutex
}
// 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, false
}
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) > ttl {
c.lruList.Remove(e.element)
delete(c.entries, filePath)
}
c.mu.Unlock()
return nil, false, false
}
info := entry.info
notFound := entry.notFound
c.mu.RUnlock()
return info, true, !notFound
}
// 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.notFound = notFound
entry.cachedAt = now
c.lruList.MoveToFront(entry.element)
return
}
// 淘汰最久未用的
if c.lruList.Len() >= fileInfoCacheMaxEntries {
if oldest := c.lruList.Back(); oldest != nil {
if entry, ok := oldest.Value.(*fileInfoEntry); ok {
delete(c.entries, entry.path)
}
c.lruList.Remove(oldest)
}
}
// 插入新条目
entry := &fileInfoEntry{
path: filePath,
info: info,
notFound: notFound,
cachedAt: now,
}
entry.element = c.lruList.PushFront(entry)
c.entries[filePath] = entry
}
// FileInfoCacheStats FileInfo 缓存统计
type FileInfoCacheStats struct {
Entries int
}