文件存储模块最终设计方案
适用场景 :纯本地部署,未来可能迁移至阿里云 OSS 或其他 S3 兼容云存储
核心原则:数据库只存逻辑 Key,与物理存储彻底解耦;使用 S3 标准协议实现本地与云端无缝切换;内置自动过期策略,减少运维成本。
一、背景与目标
1.1 旧方案局限
原方案"本地磁盘 + 数据库绝对路径"存在物理位置强绑定、扩展性差、安全性需自建、云迁移成本极高等问题。
1.2 设计目标
- 数据库仅保留逻辑 Key(如
avatar/2024/user123.jpg),不含物理路径、桶名或域名。 - 本地自主可控,所有文件存于自有服务器。
- 完全采用 S3 兼容协议,本地 MinIO 与未来云 OSS 之间代码零改动迁移。
- 客户端直传/直下,应用不中转文件流,所有访问通过预签名 URL 控制。
- 新增:支持自动清理过期临时文件,无需编写额外清理脚本。
二、核心技术概念
- S3 协议:对象存储的 HTTP 接口行业标准,阿里云 OSS、MinIO 等均兼容。
- MinIO:开源高性能对象存储,可在本地完美实现 S3 API,并内置生命周期管理。
- boto3 :AWS 官方 Python SDK,统一操作所有 S3 兼容存储,切换后端只需改
endpoint_url。 - ORM 类比 :本方案如同文件存储的 ORM,
boto3屏蔽底层差异,数据库file_key相当于模型字段,换存储后端就像换数据库方言。
三、新旧方案对比
| 维度 | 旧方案(文件系统) | 新方案(MinIO + S3) | 选型理由 |
|---|---|---|---|
| 数据耦合 | 数据库存绝对路径 | 数据库只存逻辑 Key | 彻底解耦,迁移无忧 |
| 扩展性 | 依赖 NFS 共享 | MinIO 原生分布式 | 高可用场景必备 |
| 安全性 | 自建防护 | 预签名 URL、桶策略开箱即用 | 大幅降低风险 |
| 性能 | 应用中转文件流 | 客户端直传 MinIO | 高并发下优势明显 |
| 上云成本 | 重写代码 + 洗数据 | 改配置 + 数据同步 | 零代码改动 |
| 数据治理 | 需手工脚本清理过期文件 | 内置生命周期策略,自动过期删除 | 减少运维,存储可控 |
| 初期成本 | 无需额外组件 | 启动一个 MinIO 容器 | 极小投资换取长期弹性 |
结论:除非业务确定永不扩展、永不上云,否则新方案是唯一兼具当前可控与未来灵活的选择。
四、系统架构图(Mermaid)
#mermaid-svg-VlZm7RmzmrhXYWW4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VlZm7RmzmrhXYWW4 .error-icon{fill:#552222;}#mermaid-svg-VlZm7RmzmrhXYWW4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VlZm7RmzmrhXYWW4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .marker.cross{stroke:#333333;}#mermaid-svg-VlZm7RmzmrhXYWW4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VlZm7RmzmrhXYWW4 p{margin:0;}#mermaid-svg-VlZm7RmzmrhXYWW4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster-label text{fill:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster-label span{color:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster-label span p{background-color:transparent;}#mermaid-svg-VlZm7RmzmrhXYWW4 .label text,#mermaid-svg-VlZm7RmzmrhXYWW4 span{fill:#333;color:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .node rect,#mermaid-svg-VlZm7RmzmrhXYWW4 .node circle,#mermaid-svg-VlZm7RmzmrhXYWW4 .node ellipse,#mermaid-svg-VlZm7RmzmrhXYWW4 .node polygon,#mermaid-svg-VlZm7RmzmrhXYWW4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .rough-node .label text,#mermaid-svg-VlZm7RmzmrhXYWW4 .node .label text,#mermaid-svg-VlZm7RmzmrhXYWW4 .image-shape .label,#mermaid-svg-VlZm7RmzmrhXYWW4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-VlZm7RmzmrhXYWW4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .rough-node .label,#mermaid-svg-VlZm7RmzmrhXYWW4 .node .label,#mermaid-svg-VlZm7RmzmrhXYWW4 .image-shape .label,#mermaid-svg-VlZm7RmzmrhXYWW4 .icon-shape .label{text-align:center;}#mermaid-svg-VlZm7RmzmrhXYWW4 .node.clickable{cursor:pointer;}#mermaid-svg-VlZm7RmzmrhXYWW4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .arrowheadPath{fill:#333333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VlZm7RmzmrhXYWW4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VlZm7RmzmrhXYWW4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VlZm7RmzmrhXYWW4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster text{fill:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 .cluster span{color:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VlZm7RmzmrhXYWW4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VlZm7RmzmrhXYWW4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-VlZm7RmzmrhXYWW4 .icon-shape,#mermaid-svg-VlZm7RmzmrhXYWW4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VlZm7RmzmrhXYWW4 .icon-shape p,#mermaid-svg-VlZm7RmzmrhXYWW4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VlZm7RmzmrhXYWW4 .icon-shape .label rect,#mermaid-svg-VlZm7RmzmrhXYWW4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VlZm7RmzmrhXYWW4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VlZm7RmzmrhXYWW4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VlZm7RmzmrhXYWW4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 本地存储服务
应用服务器
上传/下载请求
生成/返回预签名URL
直接上传/下载文件
读写 file_key
S3 API HTTP 调用
查询 Key
客户端 浏览器/App
FastAPI 应用
存储适配层 StorageInterface
boto3 S3 客户端
MinIO 对象存储 :9000
本地磁盘 /data/minio
MySQL 数据库
仅存 file_key
数据流说明:
- 上传:客户端 → 应用获取预签名上传 URL → 直接 PUT 文件到 MinIO → 应用将 Key 写入 MySQL。
- 下载:客户端 → 应用查询 MySQL 获取 Key → 应用生成预签名下载 URL 返回 → 客户端直接 GET 文件。
五、详细实现
5.1 基础设施:Docker 部署 MinIO
yaml
# docker-compose.yml
version: '3.8'
services:
minio:
image: quay.io/minio/minio:latest
container_name: minio
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web 控制台
volumes:
- /data/minio:/data
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
command: server /data --console-address ":9001"
restart: unless-stopped
5.2 环境变量 (.env)
ini
STORAGE_ENDPOINT=http://localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin123
STORAGE_BUCKET=my-bucket
STORAGE_REGION=us-east-1
PRESIGNED_URL_EXPIRE=3600
MYSQL_HOST=localhost
MYSQL_USER=root
MYSQL_PASSWORD=yourpassword
MYSQL_DATABASE=app_db
5.3 MySQL 表结构
sql
CREATE TABLE user_avatars (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL UNIQUE,
file_key VARCHAR(512) NOT NULL COMMENT '逻辑Key,如 avatar/2024/user123.jpg',
original_name VARCHAR(256),
file_size BIGINT DEFAULT 0,
content_type VARCHAR(100),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
铁律 :file_key 绝不包含盘符、URL 前缀或桶名。
5.4 存储层代码
接口定义(storage/interface.py):
python
from abc import ABC, abstractmethod
from typing import Optional, BinaryIO
from datetime import timedelta
class StorageInterface(ABC):
@abstractmethod
def upload(self, key: str, file_obj: BinaryIO, content_type: Optional[str] = None) -> bool:
pass
@abstractmethod
def get_presigned_url(self, key: str, expires: Optional[timedelta] = None) -> str:
pass
@abstractmethod
def delete(self, key: str) -> bool:
pass
S3 适配器(storage/s3_storage.py):
python
import boto3, os
from botocore.config import Config
from botocore.exceptions import ClientError
from datetime import timedelta
from .interface import StorageInterface
class S3Storage(StorageInterface):
def __init__(self):
self.client = boto3.client(
's3',
endpoint_url=os.getenv('STORAGE_ENDPOINT'),
aws_access_key_id=os.getenv('STORAGE_ACCESS_KEY'),
aws_secret_access_key=os.getenv('STORAGE_SECRET_KEY'),
config=Config(signature_version='s3v4'),
region_name=os.getenv('STORAGE_REGION', 'us-east-1')
)
self.bucket = os.getenv('STORAGE_BUCKET')
self._ensure_bucket()
def _ensure_bucket(self):
try:
self.client.head_bucket(Bucket=self.bucket)
except ClientError as e:
if e.response['Error']['Code'] == '404':
self.client.create_bucket(Bucket=self.bucket)
def upload(self, key, file_obj, content_type=None):
extra_args = {'ContentType': content_type} if content_type else {}
try:
self.client.upload_fileobj(file_obj, self.bucket, key, ExtraArgs=extra_args)
return True
except ClientError:
return False
def get_presigned_url(self, key, expires=None):
if expires is None:
expires = timedelta(seconds=int(os.getenv('PRESIGNED_URL_EXPIRE', 3600)))
try:
return self.client.generate_presigned_url(
'get_object',
Params={'Bucket': self.bucket, 'Key': key},
ExpiresIn=int(expires.total_seconds())
)
except ClientError:
return ""
def delete(self, key):
try:
self.client.delete_object(Bucket=self.bucket, Key=key)
return True
except ClientError:
return False
storage = S3Storage()
5.5 业务接口(FastAPI)
python
# main.py
from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
from pymysql import connect
from pymysql.cursors import DictCursor
import os, uuid
from storage.s3_storage import storage
app = FastAPI()
def get_db():
conn = connect(
host=os.getenv('MYSQL_HOST'), user=os.getenv('MYSQL_USER'),
password=os.getenv('MYSQL_PASSWORD'), database=os.getenv('MYSQL_DATABASE'),
charset='utf8mb4', cursorclass=DictCursor
)
try: yield conn
finally: conn.close()
@app.post("/api/avatar/{user_id}")
async def upload_avatar(user_id: int, file: UploadFile = File(...), db=Depends(get_db)):
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else 'bin'
key = f"avatar/{user_id}/{uuid.uuid4().hex}.{ext}"
if not storage.upload(key, file.file, file.content_type):
raise HTTPException(500, "上传失败")
with db.cursor() as cur:
cur.execute("SELECT file_key FROM user_avatars WHERE user_id = %s", (user_id,))
if old := cur.fetchone():
storage.delete(old['file_key'])
cur.execute("DELETE FROM user_avatars WHERE user_id = %s", (user_id,))
cur.execute(
"INSERT INTO user_avatars (user_id, file_key, original_name, file_size, content_type) "
"VALUES (%s, %s, %s, %s, %s)",
(user_id, key, file.filename, file.size or 0, file.content_type)
)
db.commit()
url = storage.get_presigned_url(key)
return {"file_key": key, "url": url, "expires_in": os.getenv('PRESIGNED_URL_EXPIRE', 3600)}
@app.get("/api/avatar/{user_id}")
async def get_avatar_url(user_id: int, db=Depends(get_db)):
with db.cursor() as cur:
cur.execute("SELECT file_key FROM user_avatars WHERE user_id = %s", (user_id,))
if not (row := cur.fetchone()):
raise HTTPException(404, "未上传头像")
url = storage.get_presigned_url(row['file_key'])
return {"url": url}
前端只需调用 /api/avatar/123 获得 url,直接在 <img src="..."> 中使用,完全感知不到桶名和 Key。
5.6 对象生命周期管理(自动过期删除)
5.6.1 功能介绍
MinIO 内置对象生命周期管理(ILM),可自动删除或转换过期对象,无需编写外部清理脚本。常见场景:
- 用户上传的临时文件(如验证图片、临时附件)
- 限时分享的下载文件
- 系统日志转储
5.6.2 配置方式
通过命令行(推荐,可版本化管理):
bash
# 为 my-bucket 添加规则:前缀为 tmp/ 的对象创建 1 天后自动删除
mc ilm rule add myminio/my-bucket --prefix "tmp/" --expire-days 1
# 前缀为 logs/ 的对象 30 天后自动删除
mc ilm rule add myminio/my-bucket --prefix "logs/" --expire-days 30
通过 Web 控制台 :登录 http://服务器IP:9001 → 进入桶 → Lifecycle 选项卡 → 添加规则,设置前缀和过期天数。
5.6.3 业务代码适配
上传临时文件时,只需将 Key 放入指定前缀即可,其余交给 MinIO 后台自动处理:
python
# 业务中生成临时文件 Key
temp_key = f"tmp/{uuid.uuid4().hex}.pdf"
storage.upload(temp_key, file_obj)
# 1天后该文件会被 MinIO 自动删除,无需业务代码干预
5.6.4 监控与验证
可通过 MinIO 控制台或 mc ilm rule ls 查看已配置的规则。MinIO 每小时执行一次生命周期扫描,删除符合规则的对象。
六、备份与灾难恢复
(此处保留原有备份章节内容,包括 mc mirror、版本控制、cron 脚本等,不再重复展开)
七、运维与安全
- MinIO 的 9000 端口不得暴露公网,仅允许应用服务器访问。
- 桶默认私有,所有访问必须通过预签名 URL。
- 开启版本控制防止误删,并配合生命周期管理定期清理陈旧版本。
- 定期用
mc mirror备份,监控磁盘使用率与请求延迟。 - 设置合理的生命周期规则,避免临时文件堆积耗尽磁盘。
八、上云迁移路径
- 在云平台创建 S3 兼容 Bucket,获取 AccessKey。
- 修改
.env中的STORAGE_ENDPOINT、ACCESS_KEY、SECRET_KEY和BUCKET。 - 执行数据同步:
mc mirror myminio/my-bucket cloud/cloud-bucket - 注意:云平台同样支持生命周期策略,可通过其控制台或 S3 API 配置相同规则。
- 重启应用,代码和数据库零改动。
九、总结
本方案通过 MinIO 本地部署 + boto3 统一 S3 接口 + MySQL 逻辑 Key 登记 ,并将自动生命周期管理 融入数据治理闭环,彻底消除了旧方案的路径依赖和运维负担。系统从根源上获得了今日可控、明日可迁的架构灵活性,同时借助对象过期策略,实现存储成本与人工维护的双重优化。开发获得 ORM 般的编码体验,运维获得企业级存储特性,管理者获得最大化的投资保护。