1. 项目概述:为什么一个文件上传功能需要单独拆解成“上传+日志”两件事?
FastDFS 是我过去五年里在多个中小型系统中反复用到的分布式文件存储方案,它不像 MinIO 那样自带管理界面和完整审计能力,也不像对象存储服务那样默认记录每一次 PUT/GET 的详细元数据。它的设计哲学很务实:轻量、快、可水平扩展,但“日志”这件事,是交还给业务层自己兜底的。所以标题里写的“实现 fastdfs 文件上传与记录文件日志”,表面看是两个动作,实则是一个闭环——上传不是终点,上传后能说清楚“谁、什么时候、传了什么、传到哪台 storage、大小多少、是否成功”,这才是生产环境真正可用的起点。
我见过太多团队踩的第一个坑,就是把 FastDFS 当成“上传即完成”的黑盒:前端调用 upload 接口返回 success,后端就松一口气,以为万事大吉。结果某天运维发现 storage 节点磁盘爆满,一查发现全是重复上传的同名文件;或者业务方突然要追溯某张用户头像的原始上传时间,数据库里却只有“2024-03-15 14:22:07”这种模糊时间戳,根本无法关联到 FastDFS 返回的 group、filename、file_id 等关键字段。这些问题,根源不在 FastDFS 本身,而在于上传逻辑和日志记录之间存在事实上的割裂。
这个标题里的“三、”很有意思——它暗示这不是一个从零开始的新项目,而是某个已有系统中的第三步演进。也就是说,前两步大概率已经完成了基础接入(比如 Nginx + tracker + storage 部署)和简单上传(比如只调用 client API 上传并返回 file_id)。现在要做的,是让这个上传行为真正“落地生根”,变成可追踪、可审计、可回溯、可联动的业务事件。核心关键词“fastdfs 文件上传”和“记录文件日志”必须被当作一个原子操作来设计,而不是两个独立函数拼凑。日志不是事后补录,而是上传流程中不可跳过的中间态;上传也不是孤立动作,它必须携带足够上下文,才能支撑起后续所有运营、排查、计费、合规需求。
适合谁参考?如果你正在用 FastDFS 做用户附件、图片库、文档中心、甚至小规模视频切片存储,并且已经过了“能传上去就行”的初级阶段,正面临磁盘清理难、文件归属不清、审计报告写不出来、或者准备对接内容安全审核系统等实际问题,那么这篇就是为你写的。它不讲 FastDFS 架构原理(网上资料够多),也不堆砌 SDK 文档(官方 Java/Python Client 都有),而是聚焦在“上传那一刻,代码里到底该写几行、填哪些字段、存到哪张表、怎么防重、怎么兜底”这些一线开发者每天要拍脑袋决定的细节上。
2. 整体设计思路:为什么日志不能只记 file_id,也不能只存数据库?
2.1 日志的四个刚性需求:谁、什么、何时、何地
在动手写第一行代码前,我先和团队拉了个 15 分钟的对齐会,明确日志必须回答四个问题:
- 谁(Who):不是简单的“用户ID”,而是完整的身份上下文。比如是 Web 端用户 A 通过 App B 上传的,还是后台定时任务 C 批量导入的?这决定了权限校验粒度和后续通知策略。
- 什么(What):不只是原始文件名(
avatar.jpg),还要包括业务标识(如user_profile_avatar_12345)、MIME 类型(image/jpeg)、实际字节数(128456)、MD5 校验值(用于防篡改和去重)、以及 FastDFS 返回的完整路径(group1/M00/00/00/wKgBZlqXyZaAABCD1234567890.jpg)。 - 何时(When):必须精确到毫秒,且统一使用 UTC 时间戳(避免本地时区混乱),同时记录“上传请求发起时间”和“FastDFS 返回成功时间”两个时间点,差值就是真实上传耗时,是性能监控的关键指标。
- 何地(Where):不仅指 FastDFS 的 storage 节点 IP 和端口(
192.168.5.101:23000),还要记录 tracker 地址(192.168.5.100:22122)、当前应用服务器 IP(用于链路追踪),以及如果用了 CDN 或反向代理,原始请求的 X-Forwarded-For 头也要落库。
这四点看似基础,但我在三个不同项目里都看到过缺失。最典型的是只存file_id(即group1/M00/...这串),结果当 storage 节点故障需要手动恢复时,根本无法确认这个file_id对应的是哪个用户的哪次上传行为;或者只记了“上传成功”时间,没记“请求发起”时间,导致无法区分是网络延迟高还是 FastDFS 本身慢。
2.2 为什么不能只依赖 FastDFS 自带的 storage 日志?
FastDFS 的 storage 节点确实会在logs/目录下生成storage.log,里面会有类似2024-03-15 14:22:07.123 [INFO] file uploaded, group=group1, path=M00/00/00/wKgBZlqXyZaAABCD1234567890.jpg, size=128456的记录。但问题在于:
- 格式非结构化:纯文本日志,没有固定分隔符,解析成本高,且容易因日志轮转、压缩、清理而丢失;
- 缺少业务上下文:日志里绝不会出现“用户ID=88923”、“业务类型=contract_attachment”、“来源=mobile_app_v2.3”这类信息;
- 权限与访问隔离:运维通常只开放 storage 日志给 SRE,开发和产品无权直接读取,更别说实时查询;
- 无法做关联分析:你想查“用户 88923 在过去 24 小时上传的所有 PDF 文件”,storage 日志里根本没有
user_id和file_type字段,只能靠猜。
所以,storage 日志是基础设施层的“底层心跳”,而我们这里要建的,是业务层的“操作台账”。两者定位不同,必须共存,不能互相替代。
2.3 为什么日志不能只存数据库?引入消息队列的必要性
很多团队第一反应是:“那我上传完,直接 insert 一条记录到 MySQL 表里不就行了?”短期看没问题,但一旦并发上来,或者数据库临时抖动,就会出问题。我经历过一次线上事故:某营销活动上线,1000+ 用户同时上传身份证照片,上传接口平均响应时间从 200ms 涨到 2s,DBA 查出来是file_log表的 insert 语句锁等待严重。原因很简单:MySQL 的 insert 在高并发下会竞争自增主键和二级索引,而我们的日志表又要求强一致性(不能丢一条上传记录),结果上传接口成了整个链路的瓶颈。
解决方案不是加索引或分库分表,而是解耦。我把日志记录从“同步写库”改为“异步发消息”。上传成功后,立刻将日志结构体序列化为 JSON,发到 Kafka 主题file-upload-log;由一个独立的消费者服务(用 Go 写的轻量级进程)负责从 Kafka 拉取、校验、批量写入 MySQL 或 Elasticsearch。这样带来的好处是:
- 上传接口响应时间稳定:发 Kafka 是内存操作,耗时 < 5ms,不受 DB 压力影响;
- 日志写入具备重试和堆积能力:Kafka 可以缓冲数小时甚至数天的日志,消费者挂了也不丢数据;
- 日志消费可灵活扩展:同一个主题,可以同时接 MySQL 写入、ES 全文检索、Flink 实时统计、甚至钉钉告警机器人;
- 天然支持最终一致性:允许日志比上传晚几秒落库,只要保证不丢、不错,业务上完全可接受。
当然,Kafka 不是唯一选择。如果你的系统已经用了 RabbitMQ 或 Pulsar,完全可以替换;如果连消息队列都没有,用 Redis Stream 也能扛住中小流量。核心思想是:上传是强实时的,日志记录是准实时的,两者必须分层处理。
3. 核心细节解析:一张日志表的设计、字段含义与避坑指南
3.1 数据库表结构:为什么用 BIGINT 而不用 UUID 作主键?
这是我在设计file_upload_log表时反复推敲过的点。最终采用的是自增BIGINT主键,而非更“现代”的 UUID 或雪花 ID。理由很实在:
- 排序友好:日志查询最常用的是按时间范围(
created_at BETWEEN ? AND ?)和按用户 ID(user_id = ?)筛选,BIGINT主键天然聚簇,范围扫描极快;UUID 是随机字符串,会导致大量页分裂和碎片; - 存储节省:
BIGINT占 8 字节,UUID 占 16 字节,在亿级日志表里,光主键就能省下几十 GB 存储; - 关联高效:下游如果要做“用户上传统计”,需要
JOIN user_info表,BIGINT关联速度远超字符串; - 调试直观:运维查问题时,看到
id=123456789比id="a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"更容易定位和沟通。
当然,BIGINT有上限(约 900 万万亿),但对我们这种日均百万级上传的系统,撑 100 年没问题。真到了那天,再平滑迁移到分库分表或雪花 ID 也不迟。
下面是这张表的完整 DDL(以 MySQL 8.0 为例),每个字段我都标注了为什么这么设计:
CREATE TABLE `file_upload_log` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键,自增BIGINT', `trace_id` varchar(32) NOT NULL DEFAULT '' COMMENT '全链路追踪ID,用于跨服务日志串联', `user_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '上传用户ID,0表示匿名或系统任务', `app_code` varchar(20) NOT NULL DEFAULT '' COMMENT '应用编码,如web、ios、android、admin', `business_type` varchar(32) NOT NULL DEFAULT '' COMMENT '业务类型,如user_avatar、contract_pdf、product_image', `original_filename` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名,含扩展名', `file_size_bytes` bigint unsigned NOT NULL DEFAULT '0' COMMENT '文件大小,单位字节', `mime_type` varchar(100) NOT NULL DEFAULT '' COMMENT 'MIME类型,如image/png、application/pdf', `file_md5` char(32) NOT NULL DEFAULT '' COMMENT '文件MD5,32位小写十六进制,用于去重和校验', `fastdfs_group_name` varchar(16) NOT NULL DEFAULT '' COMMENT 'FastDFS组名,如group1', `fastdfs_filename` varchar(255) NOT NULL DEFAULT '' COMMENT 'FastDFS文件名,即file_id的后半部分', `fastdfs_full_path` varchar(512) NOT NULL DEFAULT '' COMMENT 'FastDFS完整路径,group1/M00/00/00/xxx.jpg', `storage_ip` varchar(15) NOT NULL DEFAULT '' COMMENT '实际存储节点IP', `storage_port` smallint unsigned NOT NULL DEFAULT '23000' COMMENT 'storage端口,FastDFS默认23000', `tracker_ip` varchar(15) NOT NULL DEFAULT '' COMMENT 'tracker地址IP', `upload_start_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传请求发起时间,精确到毫秒', `upload_end_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'FastDFS返回成功时间,精确到毫秒', `upload_duration_ms` int unsigned NOT NULL DEFAULT '0' COMMENT '上传耗时,单位毫秒,upload_end_time - upload_start_time', `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '状态:1=成功,2=失败,3=已删除(软删)', `error_message` text COMMENT '失败时的错误详情,成功时为空', `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间', `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间', PRIMARY KEY (`id`), KEY `idx_user_id_created_at` (`user_id`,`created_at`) COMMENT '按用户+时间查询', KEY `idx_fastdfs_full_path` (`fastdfs_full_path`) COMMENT '按FastDFS路径唯一查询', KEY `idx_file_md5` (`file_md5`) COMMENT '按MD5去重和查重', KEY `idx_trace_id` (`trace_id`) COMMENT '按链路ID快速定位', KEY `idx_business_type_created_at` (`business_type`,`created_at`) COMMENT '按业务类型+时间统计' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FastDFS文件上传日志主表';提示:
fastdfs_full_path字段必须建索引,因为这是你后续做“文件是否存在”、“文件是否被引用”的唯一可靠依据。别信file_id字符串拼接,FastDFS 的M00是软链接,实际路径可能变。
3.2 关键字段的实操填充逻辑:MD5 怎么算?fastdfs_full_path怎么拼?
很多新手卡在第一步:SDK 返回的fileId是group1/M00/00/00/wKgBZlqXyZaAABCD1234567890.jpg,但fastdfs_group_name和fastdfs_filename怎么拆?其实非常简单,用斜杠/切分即可:
# Python 示例 file_id = "group1/M00/00/00/wKgBZlqXyZaAABCD1234567890.jpg" parts = file_id.split('/') group_name = parts[0] # "group1" filename = "/".join(parts[1:]) # "M00/00/00/wKgBZlqXyZaAABCD1234567890.jpg" full_path = file_id # 就是原样而 MD5 的计算,必须在上传前对原始文件流计算,不能等上传完再去 storage 上下载一遍再算——那太慢且不可靠。正确姿势是:
- 接收 HTTP 请求的 multipart/form-data 文件流;
- 边读取流边写入临时文件(或内存 buffer),同时用 hashlib 更新 MD5;
- 流读取完毕,得到最终 MD5 值;
- 调用 FastDFS Client 上传该临时文件(或内存 buffer);
- 上传成功后,将 MD5 值和其他字段一起写入日志。
这样做的好处是:即使 FastDFS 上传失败,你也有原始文件的 MD5,可以用于幂等判断(比如用户重试上传,发现 MD5 已存在,直接返回旧的file_id)。
注意:MD5 计算必须关闭文件的任何自动编码转换。比如 Python 的
open(file, 'rb')读二进制,不能用'r';Node.js 用fs.readFileSync(file, null),不能用fs.readFileSync(file, 'utf8')。我曾在一个项目里因为用了'utf8'导致图片文件 MD5 错误,上传后文件损坏,排查了两天。
3.3 “软删”机制与文件生命周期管理
日志表里有个status字段,值为 3 表示“已删除”。这不是物理删除,而是标记删除。为什么?因为 FastDFS 本身不提供“根据 file_id 批量删除”的原子操作,它只支持单个文件删除(delete_file)。而业务上常有“用户注销账号,清空所有附件”的需求。如果每次删都调用 FastDFS 删除接口,一是慢(网络 IO),二是失败风险高(storage 节点宕机)。
我的做法是:当业务触发“删除附件”时,只更新日志表的status=3,并记录deleted_at时间;同时,启动一个后台定时任务(比如每 5 分钟跑一次),扫描status=3且deleted_at < NOW() - INTERVAL 1 HOUR的记录,批量调用 FastDFS 删除接口。删除成功后,再更新日志表的status=4(已清理)。
这样就把高风险的外部依赖操作,变成了可重试、可监控、可降级的内部任务。哪怕 FastDFS 一直不可用,日志状态也清晰,不会影响前端展示(前端查日志表status != 3就认为文件有效)。
4. 实操过程详解:从 SDK 初始化到日志落库的完整链路
4.1 FastDFS Client 初始化:连接池与超时设置的血泪教训
FastDFS 官方 Java Client 和 Python Client(fdfs_client-py)都支持连接池,但默认配置极其保守。我见过默认最大连接数只有 3,结果在压测时,所有上传请求都在等连接,TPS 直接卡死。
以 Python 为例,fdfs_client-py的配置文件client.conf至少要调整这三项:
# client.conf connect_timeout=5 network_timeout=30 # 关键!连接池大小,根据你的并发量设 max_connections=20 # 心跳检测,避免连接假死 heart_beat_interval=30而 Java Client(fastdfs-client-java)更需要手动配置GenericObjectPoolConfig:
GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); poolConfig.setMaxTotal(50); // 最大总连接数 poolConfig.setMaxIdle(20); // 最大空闲连接数 poolConfig.setMinIdle(5); // 最小空闲连接数 poolConfig.setBlockWhenExhausted(true); // 连接池满时阻塞等待 poolConfig.setMaxWaitMillis(3000); // 最大等待时间3秒,超时抛异常 // TrackerServer 列表,建议至少2个,提高可用性 InetSocketAddress[] trackerServers = { new InetSocketAddress("192.168.5.100", 22122), new InetSocketAddress("192.168.5.101", 22122) }; TrackerClient trackerClient = new TrackerClient(trackerServers, poolConfig);实测心得:
maxWaitMillis设为 3000ms 是黄金值。设太短(如 500ms),上传失败率飙升;设太长(如 10000ms),用户请求卡死,体验极差。3 秒内拿不到连接,说明你的 tracker 负载已经到极限,应该扩容或限流,而不是让用户无限等待。
4.2 上传核心代码:如何把“上传”和“日志”真正做成一个事务?
严格来说,FastDFS 上传和数据库写入无法放在一个 ACID 事务里——它们是跨系统的。但我们可以通过“本地事务 + 补偿”来模拟强一致性。核心思路是:先写日志(status=0,表示“上传中”),再上传文件,上传成功则更新 status=1,失败则更新 status=2 + error_message。
以下是 Python(Django)的简化版伪代码,展示了关键控制流:
from django.db import transaction from myapp.models import FileUploadLog from fdfs_client.client import Fdfs_client def upload_to_fastdfs(file_obj, user_id, app_code, business_type): # 1. 计算MD5(此处省略具体计算逻辑) file_md5 = calculate_md5(file_obj) # 2. 生成唯一trace_id,用于链路追踪 trace_id = generate_trace_id() # 3. 在数据库中预写一条"上传中"记录 log_entry = FileUploadLog.objects.create( trace_id=trace_id, user_id=user_id, app_code=app_code, business_type=business_type, original_filename=file_obj.name, file_size_bytes=file_obj.size, mime_type=file_obj.content_type, file_md5=file_md5, status=0, # 0=上传中 upload_start_time=timezone.now(), ) try: # 4. 初始化FastDFS Client(复用连接池) client = Fdfs_client('client.conf') # 5. 执行上传,获取file_id result = client.upload_by_buffer( file_obj.read(), # 读取整个文件流 file_ext_name=get_extension(file_obj.name), meta_dict={ 'user_id': str(user_id), 'app_code': app_code, 'business_type': business_type, 'md5': file_md5 } ) if result.get('Status') != 'Upload successed.': raise Exception(f"FastDFS upload failed: {result}") file_id = result.get('Remote file_id') storage_ip = result.get('Storage IP') tracker_ip = result.get('Tracker IP') # 6. 解析file_id,填充日志字段 group_name, filename = parse_file_id(file_id) # 7. 更新日志为"成功",写入所有FastDFS相关字段 log_entry.status = 1 log_entry.fastdfs_group_name = group_name log_entry.fastdfs_filename = filename log_entry.fastdfs_full_path = file_id log_entry.storage_ip = storage_ip log_entry.tracker_ip = tracker_ip log_entry.upload_end_time = timezone.now() log_entry.upload_duration_ms = int((log_entry.upload_end_time - log_entry.upload_start_time).total_seconds() * 1000) log_entry.save() return { 'success': True, 'file_id': file_id, 'url': f"http://cdn.example.com/{file_id}", # 假设你有CDN 'log_id': log_entry.id } except Exception as e: # 8. 上传失败,更新日志为"失败" log_entry.status = 2 log_entry.error_message = str(e)[:500] # 截断,防止超长 log_entry.upload_end_time = timezone.now() log_entry.upload_duration_ms = int((log_entry.upload_end_time - log_entry.upload_start_time).total_seconds() * 1000) log_entry.save() return { 'success': False, 'error': str(e) }关键点:整个流程包裹在 Django 的
transaction.atomic()里(代码中省略了装饰器),确保create和save()是原子的。即使save()失败,也不会留下脏数据。而 FastDFS 上传失败,只会让这条日志变成status=2,不影响其他请求。
4.3 日志异步落库:Kafka 生产者与消费者的最小可行实现
既然决定用 Kafka,那生产者和消费者就得轻量、可靠、易维护。我用 Go 写了一个极简的消费者,150 行搞定,部署为 systemd 服务,稳定运行一年没出过问题。
Kafka 生产者(Python,集成在上传函数末尾):
from kafka import KafkaProducer import json producer = KafkaProducer( bootstrap_servers=['kafka1:9092', 'kafka2:9092'], value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode('utf-8'), acks='all', # 确保消息不丢 retries=3, max_in_flight_requests_per_connection=1, # 避免乱序 ) def send_log_to_kafka(log_data: dict): producer.send('file-upload-log', value=log_data) producer.flush() # 强制刷盘,确保发送Kafka 消费者(Go,main.go):
package main import ( "context" "database/sql" "encoding/json" "log" "time" "github.com/segmentio/kafka-go" _ "github.com/go-sql-driver/mysql" ) type LogMessage struct { ID int64 `json:"id"` TraceID string `json:"trace_id"` UserID int64 `json:"user_id"` // ... 其他字段,与数据库表一一对应 } func main() { // 1. 初始化Kafka reader reader := kafka.NewReader(kafka.ReaderConfig{ Brokers: []string{"kafka1:9092", "kafka2:9092"}, Topic: "file-upload-log", GroupID: "file-log-consumer", MinBytes: 10e3, // 10KB MaxBytes: 10e6, // 10MB }) // 2. 初始化MySQL连接 db, err := sql.Open("mysql", "user:pass@tcp(mysql:3306)/mydb?parseTime=true") if err != nil { log.Fatal(err) } defer db.Close() // 3. 启动消费循环 for { msg, err := reader.ReadMessage(context.Background()) if err != nil { log.Printf("read error: %v", err) continue } var logMsg LogMessage if err := json.Unmarshal(msg.Value, &logMsg); err != nil { log.Printf("unmarshal error: %v", err) continue } // 4. 批量插入,每100条或每2秒提交一次 _, err = db.Exec(` INSERT INTO file_upload_log (id, trace_id, user_id, ...) VALUES (?, ?, ?, ...)`, logMsg.ID, logMsg.TraceID, logMsg.UserID, ...) if err != nil { log.Printf("insert error: %v", err) continue } // 5. 提交offset,表示这条消息已处理 if err := reader.CommitMessages(context.Background(), msg); err != nil { log.Printf("commit error: %v", err) } } }实操心得:消费者一定要用
CommitMessages显式提交 offset,否则重启后会重复消费。我最初忘了这行,导致日志表里出现大量重复记录,花了半天才清理干净。另外,MaxBytes设为 10MB 是为了适配大文件上传日志(比如带 base64 编码的 thumbnail),避免单条消息被截断。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题速查表:上传失败的 7 种典型原因与定位方法
| 现象 | 可能原因 | 快速定位方法 | 解决方案 |
|---|---|---|---|
Connection refusedon tracker | tracker 进程未启动,或防火墙拦截 22122 端口 | telnet 192.168.5.100 22122;检查ps aux | grep fdfs_trackerd | 启动 tracker:/usr/bin/fdfs_trackerd /etc/fdfs/tracker.conf;开放防火墙端口 |
No route to host | storage 节点宕机,或 network_timeout 设置过短 | 查看 storage 日志tail -f /var/log/fdfs/storage.log;增大network_timeout | 重启 storage:/usr/bin/fdfs_storaged /etc/fdfs/storage.conf;调大超时至 30s |
Upload fail, errno=2 | 文件大小超过 storage 配置的max_size(默认 2GB) | grep max_size /etc/fdfs/storage.conf | 修改max_size=10G,重启 storage |
File not foundwhen downloading | file_id字符串被 URL 编码破坏,如/变成%2F | 检查 Nginx access log 中的请求 URI | Nginx 配置location ~* ^/group([0-9])/M00/ { ... },禁用 decode |
MD5 mismatchafter download | 上传时用了文本模式读取二进制文件 | 检查代码中open(file, 'r')是否应为'rb' | 统一用二进制模式读取所有文件流 |
Too many connections | FastDFS client 连接池耗尽 | netstat -an | grep :22122 | wc -l;对比max_connections | 增大max_connections;增加 tracker 节点分担压力 |
Slow upload > 5s | storage 磁盘 IO 饱和,或网络带宽打满 | iostat -x 1查看%util;iftop -P 23000查看流量 | 升级 SSD;增加 storage 节点;限制单次上传文件大小 |
5.2 “文件重复上传”问题的终极解法:基于 MD5 的幂等控制
用户手抖点了两次上传按钮,后端收到了两个几乎一样的请求,怎么办?很多人第一反应是加 Redis 锁:SETNX upload_lock:{md5} 1 EX 60。但这个方案在高并发下有缺陷:如果第一个请求还没写完日志就挂了,锁释放了,第二个请求进来,还是会产生两条记录。
我的方案是:在数据库层面做唯一约束 + 应用层重试。在file_upload_log表上,加一个联合唯一索引:
ALTER TABLE `file_upload_log` ADD UNIQUE KEY `uk_file_md5_business_type` (`file_md5`, `business_type`);然后在上传逻辑里,捕获这个唯一键冲突异常:
try: log_entry = FileUploadLog.objects.create( # ... 所有字段 status=0, ) except IntegrityError as e: if 'uk_file_md5_business_type' in str(e): # 发现MD5+业务类型已存在,查出旧记录 old_log = FileUploadLog.objects.filter( file_md5=file_md5, business_type=business_type, status=1 # 只找成功的 ).first() if old_log: return { 'success': True, 'file_id': old_log.fastdfs_full_path, 'url': f"http://cdn.example.com/{old_log.fastdfs_full_path}", 'log_id': old_log.id, 'reused': True # 标记为复用 } raise e这样,无论多少个并发请求带着同一个 MD5 来,最终只会有一条成功记录,其他请求要么复用,要么报错。而且这个方案不依赖外部缓存,数据库自身就是真理源。
5.3 日志查询性能优化:当单表突破千万行后该怎么办?
我们有个项目,file_upload_log表半年涨到 2800 万行,SELECT * FROM file_upload_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 20查询从 50ms 涨到 1200ms。优化步骤如下:
确认索引有效性:
EXPLAIN显示走了idx_user_id_created_at,但rows很高。原因是user_id区分度低(比如管理员上传了 10 万次),MySQL 估算错误。添加覆盖索引:把查询中用到的所有字段都包含进索引,避免回表:
ALTER TABLE `file_upload_log` ADD INDEX `idx_user_id_created_at_covering` (`user_id`, `created_at`, `fastdfs_full_path`, `original_filename`, `file_size_bytes`);分区表(Range Partitioning):按
created_at月分区,让查询只扫一个分区:ALTER TABLE `file_upload_log` PARTITION BY RANGE (TO_DAYS(created_at)) ( PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')), PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')), PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')), PARTITION p_future VALUES LESS THAN MAXVALUE );冷热分离:把 6 个月前的日志归档到历史表
file_upload_log_archive,主表只保留近期数据。归档脚本每月初执行:INSERT INTO file_upload_log_archive SELECT * FROM file_upload_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH); DELETE FROM file_upload_log WHERE created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH);
经过这三步,查询时间从 1200ms 降到 45ms,且后续增长平缓。记住:索引不是越多越好,分区不是银弹,归档才是长期主义。
6. 扩展思考:日志之后,还能做什么?
做到“上传+日志”只是起点。我在某客户的项目里,基于这张日志表,又延伸出了三个高价值模块,都是低成本、高回报的:
6.1 文件健康度看板:用日志数据驱动运维决策
我们用 Grafana + Prometheus 搭建了一个“文件健康度看板”,核心指标全部来自file_upload_log表:
- 上传成功率趋势图:
COUNT(CASE WHEN status=1 THEN 1 END) / COUNT(*),按小时聚合。一旦跌到 99% 以下,自动钉钉告警。 - **storage 节点