从 “业务背景”、“技术方案”、“为何用Redis+Kafka”、“如何保证最终一致性” 四个角度来分析:
在实现大文件 分片上传、断点续传 功能时,有以下问题:
所以:必须有一种机制自动清理这些未完成上传的文件碎片。
核心目标:每个文件片上传完后,给它设置一个“延时任务”,比如:如果 5 秒内没有继续上传,那就清理这个文件的所有碎片。
Redis
:用来快速缓存上传状态、延时任务(高性能读写);Kafka
:作为可靠的异步消息中间件,负责延时任务的可靠下发和最终落地执行(高可靠性);MySQL
:真正的数据持久化落地(低频写、稳定性要求高)。ZSet
(有序集合)存储所有文件上传任务及其过期时间;Redis 保证快速更新和调度精度,但本身不做复杂业务处理。
Kafka 确保任务“最终会被执行”,即使 Redis/调度器短暂异常,消息也不会丢。
这是句子中的关键——“最终一致性”的意思是:即使中间发生延迟、系统抖动等,只要系统恢复,MySQL 里的数据最终会被正确更新,和 Redis 的状态保持一致。
阶段 | 数据存储层 | 行为 |
---|---|---|
上传过程中 | Redis | 每次上传一个文件片,就更新 Redis 中该任务的延迟时间;同时更新 Redis 中该用户“未完成上传空间”字段 |
任务过期 | Kafka | Redis 将过期任务发到 Kafka 任务队列 |
任务执行 | 消费端处理 | 1. 删除磁盘碎片; 2. 将 Redis 中统计的空间同步到 MySQL(更新“未完成上传空间”字段) 3. 删除 Redis 中该任务记录 |
上传完成 | 应用服务 | 合并文件,Redis 中清除上传状态,同时从 Redis 和数据库中扣除“未完成上传空间”并加到“已完成上传空间” |
只要 Kafka 不丢消息,消费端一定会最终完成这个任务 → 保证 MySQL 和 Redis 中的数据最终一致。
“对于未上传完的文件片占用的磁盘空间,则是通过Redis+Kafka实现动态延时任务的存储与下发执行,保证与MySQL的最终一致性”
可以如下理解:
为应对大文件上传中因中断或恶意攻击导致的磁盘碎片堆积问题,设计并实现基于
Redis ZSet + Kafka
的动态延时清理机制:
- 使用 Redis 高性能缓存上传任务及延时状态;
- 超时任务通过 Kafka 异步下发,保障任务可靠落地执行;
- 任务执行过程更新用户未完成上传空间并持久化至 MySQL,最终实现数据库与缓存状态一致。
当然可以,下面我通过一个实际例子,完整演示如何用 Redis + Kafka
来管理未上传完成的文件片所引发的磁盘空间占用问题,以及如何保证最终一致性。
假设用户 user123 在上传一个视频文件,文件大小为 100MB,分成了 10个片段(每片10MB)。系统配置:
/tmp/upload/2025-06/user123/abcd1234/
用户上传第1片(10MB),服务器接收后将它缓存在 /tmp/upload/2025-06/user123/abcd1234/part1
。
系统计算 md5 = abcd1234
(该文件的唯一标识)。
系统将任务写入 Redis:
ZSet 名称:zset_upload_tasks_1
key: abcd1234+user123
score: 当前时间戳 + 5秒(延时清理时间)
在 Redis 的另一个 Hash
中记录:
user123 -> unfinishedSize = 10MB
任务调度器每秒扫描一次 ZSet,发现该任务未超时 → 不处理。
unfinishedSize
累加至 20MB。假设用户上传完第3片(共30MB)后就关闭浏览器了,系统检测不到任何新的上传。
5秒过去,调度器再次扫描 ZSet:
abcd1234+user123
的任务已超时。系统将该任务信息通过 Kafka 发送出去:
{
"type": "upload_timeout",
"userId": "user123",
"fileMd5": "abcd1234",
"size": 30MB,
"tmpPath": "/tmp/upload/2025-06/user123/abcd1234/"
}
Kafka 消费者监听到这条消息,进行以下操作:
删除临时目录 /tmp/upload/2025-06/user123/abcd1234/
下所有文件片(节省磁盘空间)。
将 Redis 中 unfinishedSize = 30MB
减去该任务对应的大小。
同步更新 MySQL 数据库中:
use_space_unfinished = use_space_unfinished - 30MB
日志记录该任务已完成,确保幂等。
时间点 | 状态 | 磁盘使用 | Redis 状态 | 数据库状态 |
---|---|---|---|---|
上传中 | 正常写入 | +30MB | unfinishedSize = 30MB | unchanged |
超时后 | Kafka触发 | -30MB | unfinishedSize = 0MB | use_space_unfinished - 30MB |
“用户上传文件中断后,Redis 中的延时任务自动触发清理逻辑,通过 Kafka 下发异步清理任务,释放磁盘空间并同步更新 MySQL 中的已用空间,确保缓存和持久化层的最终一致性。”
在开发 SmartDrive 云盘系统 时,我们遇到一个潜在的系统稳定性问题:如果用户上传文件过程中中断(例如恶意攻击、频繁取消上传等),未完成的文件分片会持续占用服务器磁盘空间。由于这些文件尚未合并,数据库中的用户“已使用空间”不会更新,长此以往可能导致服务器磁盘资源被占满,影响服务可用性。
为了解决这一问题,我们设计并实现了一个基于 Redis + Kafka 的动态延时任务系统,实现文件片清理、用户空间回收以及数据一致性保障。
文件上传分片记录与限时清理机制
文件MD5 + 用户ID
构造唯一标识,并将该上传任务存入 Redis 的分布式 ZSet(有序集合)中,设置延迟时间(如5秒)作为 score。空间占用统计
use_space_finished
(已上传完毕)和 use_space_unfinished
(上传中)。use_space_unfinished
累加对应片段大小,但不立即写入数据库,以减少数据库压力。Kafka 异步任务分发
use_space_unfinished
,同时记录日志确保幂等性。一致性保障
假设用户 user123
上传一个 100MB 的视频,被分成 10 片。上传前3片后中断,系统记录:
use_space_unfinished = 30MB
当用户5秒内未继续上传,调度器触发 Kafka 任务:
/tmp/upload/.../user123/md5xyz/
下所有片段use_space_unfinished -= 30MB
最终,系统磁盘被及时释放,数据状态一致,避免了无效数据积压。
这个机制目前已经稳定运行在我们云盘系统的上传链路中,极大地提升了系统的健壮性与可维护性。如果大家有类似文件上传、延时处理或一致性问题,也可以参考我们这套 Redis + Kafka 架构模式。
ZSet + score + timestamp
实现),适合存储上传任务及调度时间。架构模式 | 优势 | 缺点 |
---|---|---|
✅ Redis + Kafka | 高性能、高解耦、最终一致性强 | 系统设计复杂度略高 |
❌ 直接写数据库 | 简单直观 | 并发高时容易写崩库,写放大严重,影响主业务 |
❌ 仅用 Redis 实现延时清理 | 写性能好 | 无法可靠落盘,易丢失任务;需要自行实现幂等与持久化 |
❌ Quartz / ScheduledExecutorService | 精度较低,线程消耗高 | 不适合大规模任务调度,任务量大时调度不稳定 |
❌ RabbitMQ / 延迟队列 | 可替代 Kafka | 但吞吐与可靠性不如 Kafka,且不易追踪任务执行状态 |
Redis 解决了高频访问场景下的快速读写 + 精准延时调度,Kafka 解决了异步、解耦、幂等、高吞吐处理的问题,两者结合:
这套架构非常适合大型上传系统中复杂的上传状态追踪、用户空间管控与数据一致性需求,能够在面对高并发、突发流量、异常上传行为时保持系统稳定。
当用户上传大文件时,通常会进行分片上传。但如果恶意用户仅上传部分文件片(不合并完成),这些碎片可能长时间占用磁盘资源,导致服务器空间耗尽。
为此,系统需监控这些“未合并文件片”的生命周期,并在长时间未完成上传的情况下及时清理无效文件片。
这就引出了 Redis + Kafka 的组合使用:
upload_timeout_task_bucket_{n}
(分桶方案)md5_userid
ZADD upload_timeout_task_bucket_1 1718178000 md5_1234
表示用户1234上传的某个文件片,在 1718178000
(约5分钟后)仍未完成,则视为超时。
md5+userid → zset桶编号
映射,提高查找性能。score ≤ 当前时间戳
的任务)。{
"taskId": "md5_1234",
"userId": "1234",
"action": "clean_unfinished_chunks",
"timestamp": 1718178000
}
upload_timeout_task_bucket_0
到 _9
,按哈希值取模分配。使用 Redis ZSet 精准调度未完成上传的文件片生命周期,Kafka 异步可靠下发清理任务,两者协作实现了高并发场景下的磁盘保护、状态可追踪、任务幂等、最终一致性处理,有效防止恶意上传攻击,保障系统稳定性。
“ZSet 分桶”是一个在高并发或大规模数据处理场景下的性能优化策略。它的核心思想是:将原本存储在一个 Redis 有序集合(ZSet)中的大量任务,拆分成多个 ZSet 存储,分散访问压力,提高查询效率与调度精度。
当你把所有延迟任务都放在一个 Redis ZSet 里,比如叫 upload_timeout_tasks
,随着时间推移,这个集合会变得非常大。ZSet 的查询效率虽然不错,但:
ZRANGEBYSCORE
查询的是有序数据,任务一多,扫描就慢;为了解决这个问题,我们“分桶”。
假设我们要存储 100 万个上传文件的延时清理任务,不再用一个 ZSet,而是:
upload_timeout_task_bucket_0
upload_timeout_task_bucket_1
...
upload_timeout_task_bucket_9
共 10 个“桶”(ZSet)。我们把任务“均匀分布”到这些桶中。
可以根据任务的哈希值取模分桶,例如:
int bucketIndex = (md5 + userId).hashCode() % 10;
将这个任务放入第 bucketIndex
个桶中。
这样每个 ZSet 只维护一小部分任务,大大减少了单个桶的查询开销。
原来调度器每秒只扫描一个 ZSet:
ZRANGEBYSCORE upload_timeout_tasks 0 currentTime
现在变成轮询每个桶:
for i in 0..9:
ZRANGEBYSCORE upload_timeout_task_bucket_i 0 currentTime
这样每次每个桶扫描的数据量变小了,调度延迟更小、吞吐量更高,也方便并发处理。
特性 | 说明 |
---|---|
目的 | 降低单个 Redis ZSet 的压力,提高调度效率和查询性能 |
⚙️ 方式 | 将任务分散放入多个 ZSet(桶)中,按照哈希取模分配 |
优势 | 并发性能更强、查询更快、调度更精准、避免单点瓶颈 |
适用场景 | 上传分片延时清理、定时任务调度、过期资源管理等场景 |
Kafka 是一个高吞吐、可持久化的分布式消息队列系统,主要特点:
特性 | 说明 |
---|---|
发布-订阅模式 | 生产者发布消息,消费者订阅处理消息 |
高吞吐 | 每秒处理百万级消息 |
持久化 | 数据写入磁盘,支持消息持久保存 |
可扩展性 | 支持多 Broker 组成集群,水平扩展 |
容错性强 | 支持副本机制,节点宕机也不会丢消息 |
在我们的系统中,用户上传文件是通过分片方式进行的:
[用户上传文件片]
↓
[Redis ZSet 生成延时任务](上传超时5分钟)
↓
[调度器扫描 ZSet,到期后将任务发送到 Kafka Topic]
↓
[Kafka 消费者消费消息]
↓
[清理临时分片 + 更新 Redis/MySQL 空间使用数据]
当某个文件片超时未合并:
// 伪代码:发送清理任务
kafkaTemplate.send("file-cleanup-topic", msg);
消息内容一般包括:
@KafkaListener(topics = "file-cleanup-topic")
public void cleanupHandler(String msgJson) {
// 解析消息
// 删除磁盘中的分片
// 更新 Redis 的未完成空间大小
// 更新数据库(最终一致性)
}
Kafka 提供:
消息持久化:不怕宕机
消息重复消费:你需要在处理逻辑中加入幂等性设计,比如:
优势 | 说明 |
---|---|
解耦调度与处理逻辑 | Redis 延时任务调度只负责“发通知”,真正清理由 Kafka 消费者异步处理 |
提升系统性能与可扩展性 | 通过 Kafka 实现异步批量处理,避免同步阻塞 |
高可用保障 | Kafka 的持久化机制确保任务不会丢失 |
支持幂等处理 | 可以防止重复删除、误删等操作 |
在文件上传场景中,为防止未完成分片长期占用磁盘空间,我们基于 Redis ZSet 构建延迟任务调度机制,并结合 Kafka 进行任务异步下发和消费,实现高吞吐、高可用的碎片清理架构,同时通过唯一任务 ID 实现幂等处理与最终一致性保障。
用户上传大文件时,会被切分为多个文件片。为防止用户上传未完成就退出(或恶意攻击),我们使用Redis + Kafka架构,实现:
上传分片 → Redis ZSet 记录延时任务 → 到期 → 发送 Kafka 消息 → Kafka 消费者执行任务
↓ ↓
更新 Redis 缓存使用量(未完成) 清理临时文件 + 更新 Redis/MySQL 空间使用情况
我们使用 Spring Boot + Kafka 的集成方式(Spring for Apache Kafka)。
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
public void sendCleanupTask(String userId, String fileMd5, long unfinishedSize) {
JSONObject task = new JSONObject();
task.put("userId", userId);
task.put("fileMd5", fileMd5);
task.put("unfinishedSize", unfinishedSize);
kafkaTemplate.send("file-cleanup-topic", task.toJSONString());
}
当 Redis 的 ZSet 检测到任务到期(即当前时间 > score),就调用该方法将任务发到 Kafka 的 file-cleanup-topic
主题中。
你可以通过 @KafkaListener
注解监听某个 Topic:
@KafkaListener(topics = "file-cleanup-topic", groupId = "file-cleaner-group")
public void consumeCleanupTask(String messageJson) {
JSONObject task = JSONObject.parseObject(messageJson);
String userId = task.getString("userId");
String fileMd5 = task.getString("fileMd5");
long size = task.getLong("unfinishedSize");
// 1. 删除 Redis 中对应的文件分片记录
redisTemplate.delete("chunk:" + userId + ":" + fileMd5);
// 2. 删除磁盘临时分片
fileService.deleteTempChunks(userId, fileMd5);
// 3. 更新 Redis 中的 use_space_unfinished 字段
redisTemplate.opsForHash().increment("user:" + userId, "use_space_unfinished", -size);
// 4. 将最终结果异步持久化到数据库
userMapper.decreaseUnfinishedSize(userId, size);
}
我们使用 Hash 存储用户空间信息:
Key: user:{userId}
Field: use_space // 已上传完毕的空间
Field: use_space_unfinished // 未上传完毕的空间
use_space_unfinished += chunkSize
use_space_unfinished -= chunkSize
use_space_unfinished -= totalSize
, use_space += totalSize
UPDATE user_space
SET use_space_unfinished = use_space_unfinished - #{size}
WHERE user_id = #{userId};
这一步是为了防止 Redis 异常丢失数据时,系统还能恢复一致性。
步骤 | 描述 |
---|---|
1. 上传分片 | 用户上传某个分片时,记录上传大小,更新 Redis 中的 use_space_unfinished |
2. 创建延迟任务 | 使用 Redis ZSet 记录(fileMd5+userId)+ 上传时间戳 |
3. 定时扫描任务 | 到期后调用 KafkaTemplate.send() 发送清理任务到 Kafka |
4. Kafka 消费者处理 | 监听 topic,执行任务:清文件 + 更新 Redis + 更新数据库 |
5. 最终一致性 | Redis 快速缓存写,MySQL 异步持久化,确保数据准确 |
为了防止文件片上传未完成导致磁盘资源被长期占用,我们使用 Redis ZSet 实现延时任务调度,通过 Kafka 实现任务异步消费。Redis 记录用户未完成空间信息以减轻数据库压力,Kafka 消费者在任务触发后清理磁盘分片并更新 Redis 与数据库,最终实现空间使用信息的一致性同步和系统高性能处理。