4. 本地缓存策略与存储管理:LRU缓存、磁盘配额、过期清理、存储路径规范

4.1 概述

动态分发系统中,本地缓存是降低网络延迟、减少后端压力的核心组件。不加控制的缓存会导致磁盘空间耗尽、性能退化、数据陈旧。本章围绕四种关键策略讲解实现:LRU淘汰(Least Recently Used)、磁盘配额(Disk Quota)、过期清理(Expiry Eviction)以及存储路径规范(Path Convention)。通过组合这些机制,构建一个可自愈、有上限、可审计的本地缓存层。

4.2 原理简述

策略原理解决的核心问题
LRU 缓存基于访问时间排序,优先淘汰最久未使用的数据缓存容量固定时保留热点数据
磁盘配额限制缓存目录的总大小或文件数,超出时触发清理防止缓存膨胀撑满磁盘
过期清理为缓存项设置 TTL,后台或按需清除过期条目保证数据时效性,避免脏数据
存储路径规范按来源、层级、哈希等组织缓存文件路径便于管理、调试、快速定位

4.3 实现流程

  1. 初始化:创建缓存根目录,读取配额、TTL、LRU容量等配置。
  2. 写入流程:计算目标路径 → 检查配额 → 若超出则执行LRU或过期清理 → 写入文件 → 更新LRU索引 → 记录元数据。
  3. 读取流程:路径定位 → 文件存在且未过期? → 若过期则删除并回源;若有效则更新LRU访问时间 → 返回内容。
  4. 定期清理:定时任务扫描全量条目,删除过期文件;同时监控配额余量,触发大清理。
  5. 路径生成:对URL或资源Key做SHA256取前两级目录,分散文件,避免单目录过多。
关键决策:LRU与过期清理的优先级。推荐将过期清理作为第一道防线(删除无效数据),LRU作为第二道防线(在配额边界剔除冷数据)。

4.4 代码实现(核心片段)

LRU缓存索引(Python + lru-dict):

from lru import LRU

cache_index = LRU(maxsize=5000)  # 最大缓存条目数

def put_cache(key, filepath, size):
    # 淘汰时会被自动调用 evict 回调
    cache_index[key] = {'path': filepath, 'size': size, 'ctime': time.time()}

def get_cache(key):
    if key in cache_index:
        entry = cache_index[key]
        entry['ctime'] = time.time()  # 模拟LRU,实际访问会内置更新
        return entry['path']
    return None

磁盘配额检查与清理:

import shutil, os

CACHE_ROOT = '/data/cache'
QUOTA_BYTES = 10 * 1024 * 1024 * 1024  # 10GB

def ensure_quota(needed_bytes):
    while get_dir_size(CACHE_ROOT) + needed_bytes > QUOTA_BYTES:
        # 从LRU中获取最久未使用条目
        oldest_key = get_lru_oldest()
        if not oldest_key:
            break
        remove_cache_file(oldest_key)

过期清理扫描:

import time, glob

TTL_SECONDS = 3600 * 24  # 1天

def cleanup_expired(root):
    now = time.time()
    for meta_file in glob.glob(f'{root}/**/.meta.*', recursive=True):
        with open(meta_file) as f:
            meta = json.load(f)
        if now - meta['write_time'] > TTL_SECONDS:
            cache_file = meta_file.replace('.meta.', '')
            os.remove(cache_file)
            os.remove(meta_file)

存储路径规范函数:

import hashlib, os

CACHE_ROOT = '/data/cache'

def get_cache_path(url):
    # 对URL做SHA256,取前4字符作为两级目录
    h = hashlib.sha256(url.encode()).hexdigest()
    dir1, dir2 = h[:2], h[2:4]
    safe_name = h[4:] + '.cache'
    return os.path.join(CACHE_ROOT, dir1, dir2, safe_name)

4.5 配置示例(YAML)

local_cache:
  enabled: true
  root: /data/cache
  quota:
    max_bytes: 10737418240   # 10GB
    max_files: 100000
    cleanup_policy: LRU_FIRST
  ttl:
    default_seconds: 86400
    per_type:
      images: 3600
      manifests: 600
  path_strategy: HASH_FLAT_TWO_LEVEL
  lru_capacity: 5000

4.6 调试与验证

使用以下命令或脚本检查缓存状态:

# 查看缓存目录大小
du -sh /data/cache

# 统计文件数量
find /data/cache -type f | wc -l

# 监控LRU命中率(需在代码中暴露计数器)
curl http://localhost:8080/__cache_stats
# 返回示例:
# {"hits": 1234, "misses": 56, "evictions": 12, "quota_utilization": 0.73}
调试技巧:在开发环境开启详细日志(如每次淘汰记录原因),生产环境使用采样日志避免性能影响。

4.7 性能与稳定性优化

  • LRU索引持久化:定期将内存LRU表写入磁盘快照,重启后可恢复热点状态。
  • 批量清理:当配额超出时,不要逐条删除,一次淘汰一批(例如按照LRU顺序删除总大小的10%)。
  • 异步IO:文件写入、删除操作使用异步队列,避免阻塞主线程。
  • 目录分片:使用两级哈希目录后,每个目录下文件数可控(通常小于1000),避免ls/glob延迟。
  • 并发锁:对LRU索引和元数据文件使用读写锁,细粒度避免竞态。

4.8 常见错误与避坑

错误场景原因解决方案
缓存目录创建失败权限不足或父目录不存在启动时检查并创建,设置umask,以Systemd服务形式运行
配额清理后仍超限多个进程同时写入,清理不及时使用文件锁或基于目录的临时预留
LRU索引丢失所有数据进程崩溃未持久化启动时扫描文件系统重建索引(代价较高,可做增量恢复)
过期清理后文件还在元数据扩展属性未写或者时间戳错误采用原子写入:先写临时文件再rename
路径哈希冲突不同URL生成了相同哈希前缀使用完整哈希作为文件名,且保留尾部扩展名以便区分类型
重要:不要在每次读请求时都扫全量目录,LRU索引必须常驻内存。对于超大规模缓存(百万级文件),考虑用rocksdb等嵌入式KV存储替代文件系统。

4.9 缓存管理流程SVG

本地缓存读写与清理流程 收到读取请求 计算缓存路径 文件存在? 未过期? 读取文件+更新LRU 删除过期文件 回源获取新数据 写入缓存(检查配额) 返回缓存响应 是 否(回源) 是 否 配额不足→触发LRU清理

4.10 小结