基于JS Ajax与Django的2GB大视频分片上传及合并问题求助
问题根因分析
1. 分片无法拼接为完整文件的原因
- 前端使用
readAsDataURL将分片转成了Base64编码的DataURL字符串,后端直接通过bytes(chunk_data, 'ascii')转换写入,内容已经被篡改,不是原始二进制视频数据 - 后端每次打开文件使用
wb+模式,会覆盖已有内容,只有最后一次上传的分片会被保留,自然无法拼接成完整文件 - 缺少分片序号、总分片数的参数传递,后端无法判断分片顺序和是否全部上传完成
2. 上传耗时过长的原因
- 前端代码中存在
videoform.append("video_file", file)的逻辑,每次上传分片都会把整个2G的完整文件提交到后端,相当于2G的文件被重复上传了几十到上百次,带宽被完全浪费 - 使用Base64编码传输分片会让数据体积膨胀33%左右,额外增加了传输量
- 递归串行上传分片,没有利用多请求并发能力
修正后的实现代码
前端JS代码
(function($) { var file = {}; // 分片大小调整为20M,可根据网络情况调整为10M-50M var slice_size = 20 * 1024 * 1024; // 并发上传的请求数,建议设为3-5 var concurrent_num = 3; $(document).on('click', '.video_form_submit', function(event){ event.preventDefault(); const form_id = $(this).data("form-id") const course_id = $(this).data("course-id") const video_title = $("#video_title_"+form_id).val() file = $("#video_id_"+form_id)[0].files[0] const csrf_token = $(this).data("csrf-token") $("#progress_process_"+form_id).css('display', 'block'); $("#progress-bar1_"+form_id).css("width", "0%"); $("#progress-text1_"+form_id).html("0 %"); // 计算总分片数 const total_chunks = Math.ceil(file.size / slice_size); start_upload(file, csrf_token, course_id, video_title, form_id, total_chunks); }); async function start_upload(file, csrf_token, course_id, video_title, form_id, total_chunks) { let uploaded_count = 0; const queue = []; for (let chunk_index = 0; chunk_index < total_chunks; chunk_index++) { const start = chunk_index * slice_size; const end = Math.min(start + slice_size, file.size); const blob = file.slice(start, end); // 控制并发数 if (queue.length >= concurrent_num) { await Promise.race(queue); } const upload_promise = upload_chunk(blob, chunk_index, total_chunks, csrf_token, course_id, video_title, form_id); upload_promise.then(() => { uploaded_count++; const percent_done = Math.floor((uploaded_count / total_chunks) * 100); $("#progress-text1_"+form_id).text(percent_done + '%'); $("#progress-bar1_"+form_id).css('width', percent_done + '%'); // 移除已完成的请求 const idx = queue.indexOf(upload_promise); if (idx > -1) queue.splice(idx, 1); }); queue.push(upload_promise); } // 等待所有分片上传完成 await Promise.all(queue); $("#dbi-upload-progress_"+form_id).html('上传完成!'); } function upload_chunk(blob, chunk_index, total_chunks, csrf_token, course_id, video_title, form_id) { return new Promise((resolve, reject) => { const videoform = new FormData(); // 只传当前分片,不要传完整文件! videoform.append("chunk_file", blob); videoform.append("course_id", course_id); videoform.append("video_title", video_title); videoform.append("video_file_name", file.name); videoform.append("csrfmiddlewaretoken", csrf_token); videoform.append("chunk_index", chunk_index); videoform.append("total_chunks", total_chunks); $.ajax({ url: "/video_upload/", type: 'POST', data: videoform, cache: false, contentType: false, processData: false, error: function(jqXHR, textStatus, errorThrown) { console.log(jqXHR, textStatus, errorThrown); // 上传失败重试,可根据需求调整重试次数 upload_chunk(blob, chunk_index, total_chunks, csrf_token, course_id, video_title, form_id).then(resolve); }, success: function(data) { resolve(data); } }); }); } })(jQuery);
后端views.py代码
import os import time from django.conf import settings from django.http import JsonResponse from .forms import VideosForm def video_upload(request): video_upload_response = {"status": "success"} if request.method == 'POST': form = VideosForm(request.POST, request.FILES) if form.is_valid(): course_id = request.POST.get("course_id") video_title = request.POST.get("video_title") video_file_name = request.POST.get("video_file_name") chunk_index = int(request.POST.get("chunk_index")) total_chunks = int(request.POST.get("total_chunks")) chunk_file = request.FILES['chunk_file'] # 临时文件存储路径,按文件名区分,避免重名冲突,生产环境建议用文件MD5作为唯一标识 temp_dir = os.path.join(settings.MEDIA_ROOT, "temp_chunks", video_file_name.rsplit('.',1)[0]) os.makedirs(temp_dir, exist_ok=True) temp_chunk_path = os.path.join(temp_dir, f"{chunk_index}.part") # 写入当前分片 with open(temp_chunk_path, 'wb+') as f: for chunk in chunk_file.chunks(): f.write(chunk) # 判断是否所有分片都上传完成 uploaded_chunks = len(os.listdir(temp_dir)) if uploaded_chunks == total_chunks: # 拼接所有分片 final_file_path = os.path.join(settings.MEDIA_ROOT, "videos", video_file_name) os.makedirs(os.path.dirname(final_file_path), exist_ok=True) with open(final_file_path, 'wb+') as final_f: for i in range(total_chunks): part_path = os.path.join(temp_dir, f"{i}.part") with open(part_path, 'rb') as part_f: final_f.write(part_f.read()) # 拼接完成后删除分片 os.remove(part_path) # 删除临时目录 os.rmdir(temp_dir) # 这里可以添加保存视频信息到数据库的逻辑 video_upload_response["final_path"] = final_file_path return JsonResponse(video_upload_response, safe=False)
额外优化建议
- 生产环境建议前端先计算文件的MD5值作为唯一标识,避免不同用户上传同名文件导致的冲突,同时可以实现秒传功能
- 可以添加断点续传逻辑,上传前先向后端查询已上传的分片,跳过已经上传完成的分片
- 大文件上传建议配置Nginx等反向代理的客户端最大上传大小限制,避免请求被拦截
内容的提问来源于stack exchange,提问作者Subhra Kanti Ray
相关产品推荐
相关产品推荐

