断点续传进度持久化:上传中断后从头开始?Redis 记录分片状态,秒级续传!

公司做视频平台,用户上传一个 2GB 的素材文件,进度条跑到 95%,浏览器崩了。刷新页面重新上传,进度条又从 0% 开始。用户直接关了页面,再也没回来。

后来加了断点续传。用户重新打开页面,后端一问 Redis——"这个文件你上次传了 38 个分片,还差 2 个",直接从第 39 片开始传。3 秒搞定,用户甚至没注意到断过。

大文件上传这种事,不怕慢,怕的是断了之后要从头再来。今天聊聊怎么用 Redis 记录分片上传状态,实现真正的秒级续传。


先说清楚:为什么大文件上传非得分片

一个 2GB 的文件你不可能一口气传上去。网络稍微波动一下,整个 HTTP 请求就废了,2GB 白传。

所以业内的标准做法是分片上传:客户端把文件切成小块(比如每片 5MB),一片一片发。服务端收齐所有分片后,按顺序合并成完整文件。

客户端:
    文件 2GB → 切成 400 片,每片 5MB

    上传过程:
    上传第 1 片 ✓
    上传第 2 片 ✓
    ...
    上传第 399 片(网络断了!)✗
    上传第 400 片 ✗

服务端:
    收到 398 片
    等待第 399、400 片(永远等不到)

分片之后,问题就变成了:怎么知道哪些片已经传了、哪些还没传?


方案演进:从客户端自报到家到 Redis 集中管理

最粗糙的做法:客户端自己记

客户端每上传一片就在本地存个状态。断了重来的时候,先看本地记录,把没传的片补上。

问题很明显:换个浏览器、清个缓存、换个设备,记录全没了。而且客户端不可信,它说"传完了"你就信?

进一步:数据库记

服务端收到一片就在 MySQL 里插一条记录。续传的时候查表,找到缺失的分片号。

能用,但有两个问题。一是上传是个高频写入操作,每片 5MB、总共 400 片就是 400 次 INSERT,对数据库压力不小。二是续传查询虽然能走索引,但这个场景天然更适合用缓存而不是关系型数据库。

最优解:Redis 记

分片状态的特点:临时、高频、键值对、有过期需求。

这简直是 Redis 的舒适区。


Redis 怎么记分片状态

核心思路:用 Redis 的 BitmapSet 记录每个文件的分片完成情况。

方案 A:Bitmap(省内存,推荐)

每个文件分配一个 Redis key,用一个 Bitmap 标记每个分片是否已上传。

文件 ID:file:upload:abc123
总分片数:400

上传第 27 片成功后:
    Redis SETBIT file:upload:abc123 27 1

查询哪些片还没传:
    for i in 0..399:
        if Redis GETBIT file:upload:abc123 i == 0:
            missingChunks.add(i)

一个 400 分片的文件,Bitmap 只占 50 字节。内存开销几乎可以忽略。

方案 B:Set(直观,适合调试)

如果想直观一点,也可以用 Set:

上传第 27 片成功后:
    Redis SADD file:chunks:abc123 27

查询已传分片数:
    Redis SCARD file:chunks:abc123 → 398

查询缺失分片:
    全集 [0..399] - SMEMBERS file:chunks:abc123

Set 的好处是可以用 SMEMBERS 直接看到哪些片传了,排查问题的时候一眼就能看清楚。缺点是内存占用比 Bitmap 大一些。


完整的续传流程

把上面的逻辑串起来,一个完整的断点续传流程长这样:

==================== 初始化阶段 ====================

客户端:
    POST /api/upload/init
    Body: { fileName: "video.mp4", fileSize: 2147483648, chunkSize: 5242880 }

服务端:
    fileId = UUID.randomUUID()
    totalChunks = ceil(fileSize / chunkSize)    // = 400

    Redis:
        SET file:upload:{fileId}:total {totalChunks}
        SET file:upload:{fileId}:name {fileName}
        EXPIRE file:upload:{fileId}:* 86400     // 24小时后自动清理

    返回: { fileId: "abc123", totalChunks: 400 }


==================== 上传阶段 ====================

客户端:
    POST /api/upload/chunk
    Body: 第 N 片二进制数据
    Header: X-File-Id: abc123, X-Chunk-Index: 27

服务端:
    // 1. 把分片存到临时目录
    write("/tmp/uploads/abc123/chunk_27", chunkData)

    // 2. 标记分片已完成
    Redis SETBIT file:upload:abc123 27 1

    // 3. 返回当前进度
    doneCount = Redis BITCOUNT file:upload:abc123
    返回: { chunkIndex: 27, doneCount: 398, totalChunks: 400 }


==================== 续传阶段(断线重连后)=====================

客户端:
    POST /api/upload/resume
    Body: { fileId: "abc123" }

服务端:
    totalChunks = Redis GET file:upload:abc123:total  → 400

    // 用 BITCOUNT 拿到已完成数
    doneCount = Redis BITCOUNT file:upload:abc123      → 398

    // 用 BITFIELD 或遍历找出缺失分片
    missingChunks = []
    for i in 0..totalChunks-1:
        if Redis GETBIT file:upload:abc123 i == 0:
            missingChunks.add(i)

    返回: {
        doneCount: 398,
        totalChunks: 400,
        missingChunks: [399, 400]
    }

客户端:
    只上传第 399、400 片 → 搞定


==================== 合并阶段 ====================

客户端:
    POST /api/upload/merge
    Body: { fileId: "abc123" }

服务端:
    // 1. 校验:BITCOUNT 是否等于 totalChunks
    if Redis BITCOUNT file:upload:abc123 != totalChunks:
        return "分片不完整"

    // 2. 按顺序合并所有分片
    for i in 0..totalChunks-1:
        读取 /tmp/uploads/abc123/chunk_{i}
        追加写入最终文件

    // 3. 清理
    删除 /tmp/uploads/abc123/
    Redis DEL file:upload:abc123*

这个流程里有一个容易被忽略的细节:初始化的时候不要检查文件是否已存在。 而是把"新建上传"和"续传"做成两个独立接口。客户端先尝试 /resume,如果返回 fileId 不存在,再走 /init 新建。这样逻辑更清晰,不会出现同一个接口做两件事的混乱。


三个细节决定体验好坏

分片太小,请求太密;分片太大,失败代价高

  • 分片 1MB:一个 2GB 文件切 2000 片。HTTP 请求开销大,进度条更新太密。
  • 分片 20MB:一个 2GB 文件只切 100 片。但如果第 99 片上传失败,20MB 白传。

一般 2~5MB 是个比较平衡的选择。具体可以根据业务场景调整——视频类可以大一点,弱网环境下调小一点。

Redis key 要设过期时间

上传状态是临时的。文件合并完之后这个状态就没用了。如果不设过期时间,Redis 里会堆满僵尸 key。

EXPIRE file:upload:{fileId}:* 86400   // 24 小时

一天的时间窗口足够覆盖绝大多数上传场景。如果业务允许的超时时间更长,可以适当延长。

合并的时候要按顺序读

分片上传的时候可能是多线程并发的,第 200 片可能比第 100 片先到。但合并的时候必须从 0 到 N 按顺序拼接,不然文件就坏了。

这个没啥技巧,就是个循环:

for i in 0..totalChunks-1:
    data = read("chunk_" + i)
    write(finalFile, data)

要不要引入消息队列?

如果你的上传量很小(每分钟几十个),上面的方案完全够用。

但如果是大并发场景——几百个用户同时上传,每个人 400 个分片,那每秒的 Redis 操作量会很大。而且合并操作是 IO 密集型的,同步执行会阻塞上传线程。

这时候可以把合并操作丢到消息队列里,异步处理:

客户端请求合并:
    POST /api/upload/merge

服务端:
    if BITCOUNT == totalChunks:
        发送消息到 MQ: { fileId: "abc123" }
        返回: "合并任务已提交"

消费者:
    从 MQ 拿到 fileId
    执行合并
    更新 DB 文件状态

不过对于大多数场景来说,同步合并就够用了。别一上来就上 MQ,先看有没有这个必要。


总结

断点续传这件事,本质就两件事搞定:

分片上传 —— 大文件切成小块,每片独立上传,失败只影响一片。

状态持久化 —— 用 Redis Bitmap 记录每片的完成状态。BITCOUNT 查进度、GETBIT 找缺口、SETBIT 标记完成。三个命令搞完。

至于为什么用 Redis 而不是 MySQL,说白了就是——这个场景的数据特征跟 Redis 的定位完美匹配:临时数据、高频写入、简单键值查询、需要过期自动清理。MySQL 能干这事,但属于大材小用,还帮倒忙。

下次用户上传 2GB 文件断了重连,别再让人从头开始。


有用的话转给还在让用户重传 2GB 文件的后端。


标题:断点续传进度持久化:上传中断后从头开始?Redis 记录分片状态,秒级续传!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/03/1780410849886.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消