文件存储模块最终设计方案

文件存储模块最终设计方案


适用场景 :纯本地部署,未来可能迁移至阿里云 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

数据流说明

  1. 上传:客户端 → 应用获取预签名上传 URL → 直接 PUT 文件到 MinIO → 应用将 Key 写入 MySQL。
  2. 下载:客户端 → 应用查询 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 脚本等,不再重复展开)


七、运维与安全

  1. MinIO 的 9000 端口不得暴露公网,仅允许应用服务器访问。
  2. 桶默认私有,所有访问必须通过预签名 URL。
  3. 开启版本控制防止误删,并配合生命周期管理定期清理陈旧版本。
  4. 定期用 mc mirror 备份,监控磁盘使用率与请求延迟。
  5. 设置合理的生命周期规则,避免临时文件堆积耗尽磁盘。

八、上云迁移路径

  1. 在云平台创建 S3 兼容 Bucket,获取 AccessKey。
  2. 修改 .env 中的 STORAGE_ENDPOINTACCESS_KEYSECRET_KEYBUCKET
  3. 执行数据同步:mc mirror myminio/my-bucket cloud/cloud-bucket
  4. 注意:云平台同样支持生命周期策略,可通过其控制台或 S3 API 配置相同规则。
  5. 重启应用,代码和数据库零改动

九、总结

本方案通过 MinIO 本地部署 + boto3 统一 S3 接口 + MySQL 逻辑 Key 登记 ,并将自动生命周期管理 融入数据治理闭环,彻底消除了旧方案的路径依赖和运维负担。系统从根源上获得了今日可控、明日可迁的架构灵活性,同时借助对象过期策略,实现存储成本与人工维护的双重优化。开发获得 ORM 般的编码体验,运维获得企业级存储特性,管理者获得最大化的投资保护。