加菲猫欢乐跑
65.99M · 2026-03-26
在构建图片上传与处理服务时,随着文件数量从几万增长到千万级别,原始的“扁平化存储”和“同步处理”模式会迅速遇到瓶颈。本文总结了一套基于 Python (Sanic) 的高性能图片服务重构方案,涵盖了异步 I/O、哈希分片存储、无阻塞计算以及前后端协同的全链路优化。
在重构之前,系统存在以下主要问题:
inode 查找变慢,os.listdir 等操作在文件数过百万时会消耗巨大内存并阻塞系统。pyvips)和文件删除(os.remove)是同步的 CPU/IO 密集型操作,直接运行在 Sanic 的异步循环中会卡死整个 Web 服务,导致高并发下 Ping 值飙升。为了解决单目录文件过多的问题,我们采用了基于 SHA256 哈希的分层存储结构。
/uploads/abcdef12345.png/uploads/ab/cd/abcdef12345.png通过取哈希值的前 4 位生成两级子目录(256 * 256),可以将千万级文件均匀分散到 65,536 个文件夹中,每个文件夹仅存放数百个文件,确保存储性能线性扩展。
Python
# file.py 核心逻辑
def get_shard_relative_path(file_key: str) -> str:
"""生成的路径结构: ab/cd/"""
if len(file_key) < 4:
return ''
return os.path.join(file_key[:2], file_key[2:4])
为了保证 Sanic 的高并发能力,我们严格区分了 I/O 绑定 和 CPU 绑定 任务。
aiofiles 实现真正的异步写入。asyncio.to_thread 中,放入线程池运行,避免阻塞主循环。Python
import asyncio
import aiofiles
# 异步写入文件
async def save_file(file_path: str, content: bytes) -> None:
folder = os.path.dirname(file_path)
# 异步创建目录(防止 mkdir 阻塞)
if not os.path.exists(folder):
await asyncio.to_thread(os.makedirs, folder, exist_ok=True)
async with aiofiles.open(file_path, 'wb') as fp:
await fp.write(content)
# 无阻塞的删除操作
async def safe_remove_file(fid: int) -> None:
# ... 获取 file_key ...
# 将耗时的 glob 查找和 remove 操作扔给线程池
await asyncio.to_thread(_remove_related_files_sync, file_key)
针对现存的扁平结构文件,我们设计了安全的迁移脚本 migrate.py。
os.scandir 和 glob.iglob 替代 os.listdir,采用迭代器模式,即使遍历数亿文件也不会爆内存。os.rename 在同一文件系统下的原子性,迁移瞬间完成。DRY_RUN,先空跑验证路径逻辑,再执行实际移动。Python
# 迁移逻辑片段
shard_part = get_shard_folder(filename) # 计算 ab/cd
target_dir = os.path.join(UPLOAD_PATH, shard_part)
# 移动文件
os.rename(entry.path, os.path.join(target_dir, filename))
为了配合后端的存储变更,前端不再依赖后端返回完整 URL,而是根据 file_key 自动计算分片路径。这不仅减轻了数据库压力,还支持了动态的图片尺寸调整(_fw 参数)。
JavaScript
/**
* 前端根据 Hash 自动生成分片路径
* 输入: key="abcde...", ext="png", size=200
* 输出: /uploads/ab/cd/abcde..._fw200.png
*/
export function getFileUrl(fileKey, fileExt, size = 0) {
const shard1 = fileKey.slice(0, 2);
const shard2 = fileKey.slice(2, 4);
let fileName = `${fileKey}.${fileExt}`;
if (size > 0) {
fileName = `${fileKey}_fw${size}.${fileExt}`;
}
return `${BASE_URL}/${shard1}/${shard2}/${fileName}`;
}
对于历史数据中存储的旧版 URL(不带分片路径),我们提供了一个 fixFileUrl 函数进行运行时自动修复。
JavaScript
// 输入: "http://cdn.com/abcdef123.png"
// 输出: "http://cdn.com/ab/cd/abcdef123.png"
export function fixFileUrl(url, size = 0) {
// 自动检测是否缺少分片路径并注入
// 自动处理 _fw 尺寸参数的替换
}
最后,在 Web 层面上,我们确保路由处理函数也是完全异步的,能够从容应对高并发请求。
Python
@app.get('/upload/<file_name:path>')
async def serve_uploaded_file(request, file_name):
# 在线程池中计算真实路径(包含可能发生的图片缩放计算)
real_path = await asyncio.to_thread(file.get_real_file_path, file_name)
if not real_path or not os.path.exists(real_path):
return response.text("File not found", status=404)
# 高效流式发送
return await response.file(real_path)
通过这次重构,我们将一个基础的图片上传功能升级为企业级的高性能文件服务。