如何从 MinIO 底层磁盘手动恢复中断的 Multipart 视频分片?
在日常的监控视频或大文件存储架构中,MinIO 是一个极受欢迎的 S3 兼容对象存储方案。但在某些极端网络或断电场景下,大文件(如 MP4 监控视频)的分片上传(Multipart Upload)可能会意外中断。
当你登录服务器底层磁盘,满怀希望地找到了对应的目录,却发现里面只有 xl.meta 和一堆 part.1, part.2, part.3... 文件时,真正的噩梦才刚刚开始。
这篇文章将带你完整重演一次从“无脑拼接”到“精准剥离底层校验头”的硬核数据恢复之旅。
💥 踩坑一:为什么官方客户端 mc cp 会报错?
发现底层碎片后,很多人的第一直觉(包括我)是:既然这是 S3 协议的数据,那我直接用 MinIO 官方客户端 mc 去拷贝不就行了?
于是我们执行了命令:
mc cp nas/cdmp/aicamera/recordings/.../2026-04-16_14-05-58-292047.mp4 ./
结果却被无情打脸:
mc: <ERROR> Unable to prepare URL for copying. Unable to guess the type of copy operation.
为什么会报错?原因在于对象存储的“事务一致性”机制。
在 MinIO(以及 AWS S3)的底层逻辑中,当你发起一个分片上传时,系统会在磁盘上创建一个临时目录(通常带有一个 UUID 或以 / 结尾的文件夹名)来存放碎片。只要客户端没有发送最终的 CompleteMultipartUpload(完成上传)指令,这个视频在逻辑上就是不存在的。
mc cp 工具的作用是下载已完结的合法对象。当它试图访问这个由于中断而遗留的临时目录时,发现它既不是一个普通的对象文件,也不是一个标准的文件夹,而是一个“未完结的幽灵事务”。因此,客户端无法判断你是要复制文件还是复制目录,最终抛出 Unable to guess the type of copy operation(无法猜测复制操作类型)的错误。
结论: 官方 API 拒绝服务,我们只能通过底层物理文件进行硬核数据恢复。
💥 踩坑二:简单的二进制拼接为什么无法播放?
既然 API 走不通,那就写个 Python 脚本,把 part.1、part.2 按数字顺序以纯二进制(rb/wb)模式首尾相连拼接起来总行了吧?
结果:视频依然无法播放,甚至连格式都无法被识别。
通过使用 Hex 编辑器(如 WinHex)强行读取 part.1 的二进制头部,我们发现了惊人的秘密:分片文件里不仅有视频流,还夹杂了 MinIO 的私有校验数据。
在 part.1 的最开头,存在 32 个字节的杂乱数据,跳过这 32 字节后,才出现了标准 MP4 文件的合法标识头 ftyp。
这 32 个字节,是 MinIO 底层为了防止硬盘静默数据损坏(Bitrot)而强制追加的 Blake2b 校验和。单纯的二进制拼接把这些非视频字节混入了视频流,直接摧毁了 MP4 的封装格式。
💥 踩坑三:“半成功”的陷阱
发现校验头后,我们迅速修改了脚本:在读取每一个 part.X 文件时,先 read(32) 跳过开头,再把剩下的合并。
这次合成的视频可以打开了,但出现了更诡异的情况:能看,但不能完全看。视频播放了一小段后,突然画面花屏、马赛克,随后播放器直接崩溃卡死。
这是整个恢复过程中最大的盲点:MinIO 并不是只在文件头加一次校验,而是基于数据块(Block)进行校验的!
在 MinIO 中,存在一个 EcBSize(Erasure Code Block Size,通常是 1MB 或 5MB)的设定。文件的真实物理结构其实是这样的切片循环:
[32字节校验] + [1MB视频数据] + [32字节校验] + [1MB视频数据] + [32字节校验] ...
我们的第二版脚本只删掉了最开头的 32 字节,导致视频播放到 1MB 的位置时,解码器一头撞上了内部后续残留的校验乱码,导致播放器当场暴毙。
🚀 终极方案:解析元数据,全盘精准剥离
找到了根本原因,最终的抢救方案也就呼之欲出了:
- 解析
xl.meta: 用代码读取这个 MessagePack 格式的元数据文件,提取出当前系统设定的真实块大小(EcBSize)。 - 剥洋葱式提取: 以设定的块大小为周期,全盘遍历分片,遇到 32 字节就丢弃,遇到数据块就保留。
- 重建时间轴: 由于视频是意外中断的,其物理画面长度与文件头记录的预期长度不符。最后必须调用 FFmpeg 重写时间轴(Timeline),避免进度条卡死。
最终 Python 恢复脚本
将此脚本放在包含碎片的同级目录下执行(需提前安装 FFmpeg):
import os
import subprocess
def get_ec_block_size(xl_meta_path):
"""从 xl.meta 中动态解析数据块大小 (默认 1MB)"""
default_size = 1048576
try:
if not os.path.exists(xl_meta_path): return default_size
with open(xl_meta_path, 'rb') as f:
data = f.read()
idx = data.find(b'EcBSize') # 搜索 MessagePack 关键字
if idx != -1:
val_type = data[idx+7]
if val_type == 0xce: return int.from_bytes(data[idx+8:idx+12], 'big')
elif val_type == 0xcd: return int.from_bytes(data[idx+8:idx+10], 'big')
elif val_type == 0xcf: return int.from_bytes(data[idx+8:idx+16], 'big')
except Exception:
pass
return default_size
def merge_minio_parts_perfect():
base_dir = "."
output_dir = os.path.join(base_dir, "merged")
if not os.path.exists(output_dir): os.makedirs(output_dir)
for folder_name in os.listdir(base_dir):
if os.path.isdir(folder_name) and folder_name.endswith('.mp4'):
print(f"[*] 处理视频目录: {folder_name}")
xl_meta_path = os.path.join(base_dir, folder_name, 'xl.meta')
raw_output = os.path.join(output_dir, f"raw_{folder_name}")
final_output = os.path.join(output_dir, folder_name)
parts_dir = next((root for root, _, files in os.walk(folder_name) if any(f.startswith('part.') for f in files)), None)
if parts_dir:
ec_block_size = get_ec_block_size(xl_meta_path)
part_files = sorted([f for f in os.listdir(parts_dir) if f.startswith('part.')], key=lambda x: int(x.split('.')[-1]))
# 核心逻辑:按块大小循环剥离 32 字节校验头
with open(raw_output, 'wb') as outfile:
for part_name in part_files:
part_path = os.path.join(parts_dir, part_name)
file_size = os.path.getsize(part_path)
with open(part_path, 'rb') as infile:
bytes_read = 0
while bytes_read < file_size:
header = infile.read(32) # 吃掉并丢弃 32 字节的垃圾头
if not header: break
bytes_read += len(header)
chunk_data = infile.read(ec_block_size) # 读取纯净视频流
if not chunk_data: break
outfile.write(chunk_data)
bytes_read += len(chunk_data)
# 修复由于异常中断导致的 Timeline 和索引错误
print(f" -> 正在通过 FFmpeg 重建视频时间轴...")
try:
subprocess.run(["ffmpeg", "-y", "-i", raw_output, "-c", "copy", final_output],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
os.remove(raw_output)
print(f"[+] 完美修复!文件位于: {final_output}\n")
except Exception:
print(f"[!] 索引重建失败,保留纯净基础流: {raw_output}\n")
if __name__ == "__main__":
merge_minio_parts_perfect()
总结
这次恢复过程不仅是一次成功的抢救,更让我们对对象存储的底层结构有了更深的认识:
- API 不是万能的:处于未完结事务状态的对象,官方客户端无能为力。
- 底层存储并不纯粹:为了保证数据安全,MinIO 在磁盘上直接注入了区块级别的校验逻辑(Bitrot Checksum),这让传统的数据恢复手段失效。
- 遇到问题,直接看二进制:当常规逻辑走不通时,使用 Hex 编辑器查看文件头,往往能发现突破口。
如果你也遇到了大文件上传中断导致的 MinIO 存储碎片问题,这套方案将是你在底层捞回数据的最后一道保险。