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:
parent
445401c40f
commit
1128eb644f
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user