导航菜单

文件命名与目录设计

OSS 上出现了一个巨型目录

迁移到 OSS 后的第一个周末,我在 OSS 控制台看了一眼文件列表:

guangying-images/
  originals/
    a3f8c2e1b4567890abcdef1234567890.jpg
    b7d9e1f234567890abcdef1234567890.jpg
    c1e2f3a4567890123456789012345678.jpg
    d4e5f678901234567890123456789abcd.jpg
    ... (3,500 个文件,全平铺在一个目录下)

3,500 个文件不算多,但 OSS 的 list_objects API 在单目录下文件数超过 1,000 时性能开始下降。等到 100 万张图时,这个接口会慢到不可用。

更重要的是,很多文件系统(包括 OSS 的内部索引)对单目录下的文件数量有隐性限制。

扁平目录 vs 分层目录

# ❌ 扁平目录:所有文件平铺
FLAT_STRUCTURE = 'originals/{uuid}.jpg'
# 问题:单目录文件数无上限,list_objects 慢

# ✅ 分层目录:按规则分散到子目录
HIERARCHICAL_STRUCTURE = 'originals/{date}/{hash_prefix}/{uuid}.jpg'
# 优势:每个子目录文件数可控

我的设计:四层目录结构

经过研究,我设计了这样的目录结构:

originals/
  2024/
    06/
      a3/
        a3f8c2e1.jpg
        a3b7d9e1.png
      b7/
        b7d9e1f2.jpg
      ...
    07/
      ...

thumbs/
  small/
    2024/06/a3/a3f8c2e1_small.webp
  medium/
    2024/06/a3/a3f8c2e1_medium.webp
  large/
    2024/06/a3/a3f8c2e1_large.webp
import hashlib
import time

def generate_object_key(user_id, filename):
    """生成存储路径:年/月/哈希前缀/唯一ID.扩展名"""
    # 按日期分第一层
    date_parts = time.strftime('%Y/%m').split('/')
    year, month = date_parts[0], date_parts[1]
    
    # 按文件哈希前缀分第二层(分散热点)
    hash_input = f"{user_id}_{time.time()}_{filename}"
    file_hash = hashlib.md5(hash_input.encode()).hexdigest()
    hash_prefix = file_hash[:2]  # 取前 2 位 = 256 个子目录
    
    # 唯一文件名
    unique_id = uuid.uuid4().hex[:12]
    ext = filename.rsplit('.', 1)[-1].lower()
    
    object_key = f'originals/{year}/{month}/{hash_prefix}/{unique_id}.{ext}'
    return object_key

# 示例输出:
# originals/2024/06/a3/f8c2e1b45678.jpg
# originals/2024/06/b7/d9e1f2345678.png
# originals/2024/07/c1/e2f3a4567890.jpg

为什么取哈希前 2 位?

# 256 个子目录的容量分析
hash_prefix_count = 256  # 16^2 = 256 个子目录

# 假设 100 万张图片
total_files = 1_000_000
files_per_dir = total_files / hash_prefix_count
# = 3,906 个/目录 ← 非常健康

# 即使 1 亿张图片
total_files = 100_000_000
files_per_dir = total_files / hash_prefix_count
# = 390,625 个/目录 ← 仍然可以接受

文件命名规范

NAMING_RULES = {
    '原图': '{unique_id}.{ext}',
    # 例:f8c2e1b45678.jpg
    
    '缩略图': '{unique_id}_{size_name}.{format}',
    # 例:f8c2e1b45678_small.webp
    
    '裁剪图': '{unique_id}_{width}x{height}.{format}',
    # 例:f8c2e1b45678_300x200.webp
    
    '临时文件': 'temp/{user_id}/{unique_id}.{ext}',
    # 例:temp/12345/abc123def456.jpg
}

# 缩略图的完整路径生成
def get_thumb_path(original_key, size_name):
    """根据原图路径推导缩略图路径"""
    # originals/2024/06/a3/f8c2e1b45678.jpg
    # → thumbs/small/2024/06/a3/f8c2e1b45678_small.webp
    
    parts = original_key.split('/')
    filename = parts[-1]
    name_without_ext = filename.rsplit('.', 1)[0]
    
    return f'thumbs/{size_name}/{" ".join(parts[1:-1])}/{name_without_ext}_{size_name}.webp'

本节小结

关键原则

  • 绝不平铺所有文件——用日期 + 哈希前缀做多级目录
  • 文件名不含业务含义(用户 ID、标题等),只用唯一 ID
  • 路径可推导——知道原图路径就能算出缩略图路径,不需要查数据库

我的思考

思考 1

为什么用哈希前缀而不是用户 ID 做目录分片?按用户 ID 分不是更方便管理吗?

参考答案

按用户 ID 分片的问题

users/12345/photos/abc.jpg    ← 热门摄影师,上传了 10 万张
users/67890/photos/def.jpg    ← 普通用户,只上传了 3 张
  1. 数据倾斜严重:活跃用户的目录可能积累几十万文件,不活跃用户的目录几乎为空
  2. 隐私风险:路径中包含用户 ID,第三方 CDN 日志可能泄露用户行为
  3. 迁移困难:如果某个用户的数据需要迁移,会影响整个目录结构

按哈希前缀分片的优势

originals/2024/06/a3/abc.jpg   ← 用户 12345 的
originals/2024/06/a3/def.jpg   ← 用户 67890 的
originals/2024/06/b7/ghi.jpg   ← 用户 12345 的
  1. 均匀分布:哈希函数天然分散,每个子目录的文件数基本相同
  2. 无法反推用户:路径中不含用户信息
  3. 访问热点分散:同一用户的图片分散在不同子目录,避免集中读取

管理需求怎么解决? 在数据库中维护 用户 → 图片列表 的映射,而不是在文件路径中编码。

-- 数据库负责关系,文件系统只负责存储
CREATE TABLE photos (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    object_key VARCHAR(255) NOT NULL,  -- originals/2024/06/a3/abc.jpg
    status VARCHAR(20) DEFAULT 'active',
    created_at TIMESTAMP DEFAULT NOW(),
    INDEX idx_user (user_id),
    INDEX idx_object_key (object_key),
);

搜索