腾讯云cos大文件上传服务端实现一篇搞定

本次记录一下大文件上传腾讯云cos自己的踩坑记录,首先的技术方案是大文件经过前端的分片,后端根据前端分片传递固定大小的文件流。后端则是根据腾讯云cos的SDK版本要求进行存储。本次的亮点之一可以实现10G的存储,当然这是笔者自己的测试,上限肯定远远不止!

写在前面

腾讯云官方推荐最好存储形式还是使用端到端的形式,COS主要推荐后端直传 或者前端直传COS方案。对于前端->后端->COS的上传架构涉及多个链路和业务,目前不推荐。暂时没有对应的成熟方案。(当前maven中腾讯元cos的SDK版本号:5.6.245)

总体交互逻辑介绍

从下述交互图中可以发现,本次上传过程采用前端分片的方式。将一个大文件通过md5进行去重并根据文件大小,按照每100MB进行分片。假设此时上传文件的大小为660MB,那么就会分片为7片。一口气进行打给后端请求。并且会在请求头信息中进行设置相关信息。分别是:

  1. 文件名称:MultipartFile file
  2. 分片索引:int chunkIndex
  3. 分片总数:int totalChunks
  4. 当前任务id:String taskId
  5. 文件名称:String fileName
  6. 当前分片大小:Long currentChunkSize
  7. 文件总大小:Long totalFileSize
  8. 文件类型:String fileType
  9. 文件类型:String contentType
  10. 文件md5值:String md5
  11. 所属文档编码:String pageCode

大文件上传会遇到的问题

由于前端进行分片,较大文件会分成多个请求并发请求后端。假如说此时上传10G文件,差不多会产生103个请求一并请求后端,在腾讯云cos目前文章版本的SDK中不支持对分片需要乱序上传 并且每上传一个文件要求对上传任务初始化uploadId值唯一,不然会上传失败(报文件只上传某个片段的错误)。

如何上传详细解决方案

本次上传中实现的逻辑类,在代码中是中游调用角色,起着承上启下的作用,因此我下属的方案会对文件的上传过程中设置一个overallState 字段,默认是Integer类型进行对FilePartStatusEnum分片处理状态中的值进行判断,其中该值存在四种状态: 0: 处理中(默认) ,3:合并完成 (真正上传完成),4:合并失败。只有当合并完成之后,下游调用者才会进行插入数据库操作。

存储每个任务分片信息

使用ConcurrentHashMap 是 Java 中一个线程安全的哈希表实现。它允许多个线程同时读写数据,而不需要外部同步机制(如 synchronized 块)。这使得它非常适合在多线程环境中使用,能够提高程序的并发性能。

  1. 外层的**ConcurrentHashMap**健和值:标识一个任务的上传ID,虽然在同一次上传任务中id一样。主要用于解决多端上传问题。value值主要用于存储与该上传任务相关的分片信息。

  2. 内层的**ConcurrentHashMap**健和值主要用于存储在分片上传过程中,大文件会被分片成多个小块,每个分片会有需要,腾讯云cos要求分片的需要必须从1开始,且必须顺序升序排列。值value用于存储PartETag,存储分片的元数据。采用AI解释一下就是:

swift 复制代码
private static final ConcurrentHashMap<String, ConcurrentHashMap<Integer, PartETag>> UPLOAD_PARTS = new ConcurrentHashMap<>();

存储每个任务的上传ID

示例值:

arduino 复制代码
private static final ConcurrentHashMap<String, String> UPLOAD_IDS = new ConcurrentHashMap<>();

存储每个任务已上传的分片数量

AtomicInteger 是 Java 中的一个线程安全的整数类,它提供了原子操作(如递增、递减等),确保在多线程环境中对整数的操作是线程安全的。

示例值:

arduino 复制代码
private static final ConcurrentHashMap<String, AtomicInteger> UPLOAD_COUNTS = new ConcurrentHashMap<>();

存储每个任务的总分片数

示例:

arduino 复制代码
private static final ConcurrentHashMap<String, Integer> TOTAL_PARTS = new ConcurrentHashMap<>();

存储每个任务的完成状态

AtomicBoolean 是 Java 中的一个线程安全的布尔类,它提供了原子操作(如设置值、获取值等),确保在多线程环境中对布尔值的操作是线程安全的。

示例值:

arduino 复制代码
private static final ConcurrentHashMap<String, AtomicBoolean> TASK_COMPLETED = new ConcurrentHashMap<>();

存储任务创建时间,用于清理过期任务

arduino 复制代码
private static final ConcurrentHashMap<String, Long> TASK_CREATION_TIME = new ConcurrentHashMap<>();

全局锁,用于操作共享数据结构

ReentrantLock 是 Java 中的一个可重入锁(也称为互斥锁),它提供了比内置同步机制(synchronized)更灵活和强大的功能。它允许线程在进入同步代码块之前获取锁,并在退出时释放锁。ReentrantLock 支持公平锁和非公平锁,并且可以尝试非阻塞地获取锁(通过 tryLock 方法)。

java 复制代码
private static final ReentrantLock GLOBAL_LOCK = new ReentrantLock();

具体如何玩转上述静态变量

初始化或获取上传任务信息

初始化或获取上传任务的相关信息。主要功能是检查任务是否已经存在,如果存在则返回已有的上传 ID,如果不存在则初始化任务所需的数据结构,并返回 null 表示需要创建新任务

  • 使用了全局锁 GLOBAL_LOCK 来确保对共享数据结构的操作是线程安全的。这是因为多个线程可能同时尝试初始化或获取同一个任务的信息。
  • 锁的获取和释放使用了 try-finally 块,确保即使在发生异常的情况下,锁也能被正确释放。
typescript 复制代码
    /**
         * 初始化或获取上传任务信息
         * @param taskId 任务ID
         * @param totalParts 总分片数
         * @return 上传ID,如果存在则返回已有ID,否则返回null表示需要创建新任务
         */
        public static String getOrInitTask(String taskId, int totalParts) {
            GLOBAL_LOCK.lock();
            try {
                // 检查任务是否已存在
                if (UPLOAD_IDS.containsKey(taskId)) {
                    return UPLOAD_IDS.get(taskId);
                }
                
                // 任务不存在,需要初始化数据结构但不设置uploadId
                UPLOAD_PARTS.putIfAbsent(taskId, new ConcurrentHashMap<>());
                UPLOAD_COUNTS.putIfAbsent(taskId, new AtomicInteger(0));
                TOTAL_PARTS.put(taskId, totalParts);
                TASK_COMPLETED.putIfAbsent(taskId, new AtomicBoolean(false));
                TASK_CREATION_TIME.put(taskId, System.currentTimeMillis());
                
                return null; // 返回null表示需要创建新任务
            } finally {
                GLOBAL_LOCK.unlock();
            }
        }
上述代码也并不是万能的。还是存在问题,由于我使用了全局锁,在高并发场景下。如果任务初始化的频率很高,可能会导致多个线程阻塞等待锁。可以考虑使用更加细力度的锁,比如说基于任务 ID 的锁来提高并发性能。
    由于上述在上传一个任务的时候,任务id是唯一的,因此可以采用上述方案。下述是示例代码:
    private static final Map<String, ReentrantLock> TASK_LOCKS = new ConcurrentHashMap<>();
ReentrantLock taskLock = TASK_LOCKS.computeIfAbsent(taskId, k -> new ReentrantLock());
taskLock.lock();
try {
    // 操作任务数据
} finally {
    taskLock.unlock();
}

设置任务的上传ID

这里的方法使用其实和上述大同小异,下面我简单说说细节可以存在的优化点:

typescript 复制代码
/**
         * 设置任务的上传ID
         * @param taskId 任务ID
         * @param uploadId 上传ID
         */
        public static void setUploadId(String taskId, String uploadId) {
            GLOBAL_LOCK.lock();
            try {
                UPLOAD_IDS.put(taskId, uploadId);
            } finally {
                GLOBAL_LOCK.unlock();
            }
            
        }

1.如果任务 ID 是唯一的,可以使用基于任务 ID 的锁来替代全局锁。例如:

arduino 复制代码
private static final ConcurrentHashMap<String, ReentrantLock> TASK_LOCKS = new ConcurrentHashMap<>();

可以在操作任务时候,根据任务id获取锁:

ini 复制代码
ReentrantLock taskLock = TASK_LOCKS.computeIfAbsent(taskId, k -> new ReentrantLock());
taskLock.lock();
try {
    UPLOAD_IDS.put(taskId, uploadId);
} finally {
    taskLock.unlock();
}

2.使用 ConcurrentHashMap 的原子操作

UPLOAD_IDS 是一个 ConcurrentHashMap,可以利用其原子操作方法(如 putIfAbsentcompute)来避免使用锁

ini 复制代码
UPLOAD_IDS.putIfAbsent(taskId, uploadId);

3.使用**compute**方法

kotlin 复制代码
UPLOAD_IDS.compute(taskId, (key, existingValue) -> {
    if (existingValue == null) {
        return uploadId; // 如果任务 ID 不存在,则设置上传 ID
    }
    return existingValue; // 如果任务 ID 已存在,则保留原有值
});

添加已上传的分片信息

下述方法会在一种情况下爆出NLP,那就是在任务id不存在时候,但是以及在初始化阶段避免了整个问题。

csharp 复制代码
     /**
         * 添加已上传的分片信息
         * @param taskId 任务ID
         * @param partNumber 分片序号
         * @param partETag 分片ETag
         * @return 是否所有分片都已上传完成
         */
        public static boolean addPart(String taskId, int partNumber, PartETag partETag) {
            // 存储分片信息
            UPLOAD_PARTS.get(taskId).put(partNumber, partETag);
            
            // 增加计数
            int currentCount = UPLOAD_COUNTS.get(taskId).incrementAndGet();
            int totalParts = TOTAL_PARTS.get(taskId);
            
            log.info("任务[{}]添加分片[{}],当前进度: {}/{}", taskId, partNumber, currentCount, totalParts);
            
            // 检查是否所有分片都已上传
            if (currentCount == totalParts) {
                TASK_COMPLETED.get(taskId).set(true);
                return true;
            }
            return false;
        }
单独使用需要在存储分片信息之前加一下下述代码
     // 检查任务是否存在
    if (!UPLOAD_PARTS.containsKey(taskId) || !UPLOAD_COUNTS.containsKey(taskId) || !TOTAL_PARTS.containsKey(taskId)) {
        throw new IllegalArgumentException("任务不存在: " + taskId);
    }

获取排序后的所有分片ETag列表

下述代码也是存在一个风险点就是假如说在实际场景中,分片序号可能不连续(例如,某些分片可能未上传成功)。如果分片序号不连续,排序后的列表可能不符合预期。

ini 复制代码
    /**
         * 获取排序后的所有分片ETag列表
         * @param taskId 任务ID
         * @return 排序后的分片ETag列表
         */
        public static List<PartETag> getSortedPartETags(String taskId) {
            ConcurrentHashMap<Integer, PartETag> partMap = UPLOAD_PARTS.get(taskId);
            if (partMap == null) {
                return Collections.emptyList();
            }
            
            List<PartETag> sortedTags = new ArrayList<>(partMap.size());
            // 按分片序号排序
            List<Integer> partNumbers = new ArrayList<>(partMap.keySet());
            // 腾讯云cos不支持乱序分片
            Collections.sort(partNumbers);
            
            for (Integer partNumber : partNumbers) {
                sortedTags.add(partMap.get(partNumber));
            }
            
            return sortedTags;
        }

清理任务资源

ini 复制代码
 /**
         * 清理任务资源
         * @param taskId 任务ID
         */
        public static void cleanupTask(String taskId) {
            GLOBAL_LOCK.lock();
            try {
                UPLOAD_PARTS.remove(taskId);
                UPLOAD_IDS.remove(taskId);
                UPLOAD_COUNTS.remove(taskId);
                TOTAL_PARTS.remove(taskId);
                TASK_COMPLETED.remove(taskId);
                TASK_CREATION_TIME.remove(taskId);
                log.info("清理任务资源: {}", taskId);
            } finally {
                GLOBAL_LOCK.unlock();
            }
        }
此处代码也是存在优化空间,比如说可以采用基于任务id的锁粒度更小
    private static final ConcurrentHashMap<String, ReentrantLock> TASK_LOCKS = new ConcurrentHashMap<>();
ReentrantLock taskLock = TASK_LOCKS.computeIfAbsent(taskId, k -> new ReentrantLock());
taskLock.lock();
try {
    UPLOAD_PARTS.remove(taskId);
    UPLOAD_IDS.remove(taskId);
    UPLOAD_COUNTS.remove(taskId);
    TOTAL_PARTS.remove(taskId);
    TASK_COMPLETED.remove(taskId);
    TASK_CREATION_TIME.remove(taskId);
    log.info("清理任务资源: {}", taskId);
} finally {
    taskLock.unlock();
}
或者采用computeIfAbsent 如果任务 ID 不存在,可以直接跳过清理操作,避免不必要的锁操作。
    ReentrantLock taskLock = TASK_LOCKS.computeIfAbsent(taskId, k -> new ReentrantLock());
if (taskLock != null) {
    taskLock.lock();
    try {
        UPLOAD_PARTS.remove(taskId);
        UPLOAD_IDS.remove(taskId);
        UPLOAD_COUNTS.remove(taskId);
        TOTAL_PARTS.remove(taskId);
        TASK_COMPLETED.remove(taskId);
        TASK_CREATION_TIME.remove(taskId);
        log.info("清理任务资源: {}", taskId);
    } finally {
        taskLock.unlock();
    }
}

检查任务是否完成

typescript 复制代码
     /**
         * 检查任务是否完成
         * @param taskId 任务ID
         * @return 是否完成
         */
        public static boolean isTaskCompleted(String taskId) {
            AtomicBoolean completed = TASK_COMPLETED.get(taskId);
            return completed != null && completed.get();
        }
        

清理过期任务

下述代码中的过期时间可以进行适当延长针对与多个上传任务场景,目前是默认了一小时。

ini 复制代码
  /**
         * 清理过期任务
         * @param expireHours 过期时间(小时)
         */
        public static void cleanupExpiredTasks(int expireHours) {
            long expireTime = System.currentTimeMillis() - expireHours * 60 * 60 * 1000L;
            GLOBAL_LOCK.lock();
            try {
                Iterator<Map.Entry<String, Long>> iterator = TASK_CREATION_TIME.entrySet().iterator();
                while (iterator.hasNext()) {
                    Map.Entry<String, Long> entry = iterator.next();
                    if (entry.getValue() < expireTime) {
                        String taskId = entry.getKey();
                        cleanupTask(taskId);
                        log.info("清理过期任务: {}", taskId);
                    }
                }
            } finally {
                GLOBAL_LOCK.unlock();
            }
        }
上述代码的锁粒度还是过大,可以考虑降低锁粒度。任务ID一致的情况下,使用基于任务 ID 的锁来替代全局锁。
    private static final ConcurrentHashMap<String, ReentrantLock> TASK_LOCKS = new ConcurrentHashMap<>();
清理对应任务ID的锁
    Iterator<Map.Entry<String, Long>> iterator = TASK_CREATION_TIME.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, Long> entry = iterator.next();
    if (entry.getValue() < expireTime) {
        String taskId = entry.getKey();
        ReentrantLock taskLock = TASK_LOCKS.computeIfAbsent(taskId, k -> new ReentrantLock());
        taskLock.lock();
        try {
            cleanupTask(taskId);
            log.info("清理过期任务: {}", taskId);
        } finally {
            taskLock.unlock();
        }
    }
}
​
或者使用ConcurrentHashMap 的 removeIf 方法。TASK_CREATION_TIME 是一个 ConcurrentHashMap,可以使用 removeIf 方法来清理过期任务,避免显式迭代:
    long expireTime = System.currentTimeMillis() - expireHours * 60 * 60 * 1000L; // 默认时间是一小时
TASK_CREATION_TIME.entrySet().removeIf(entry -> {
    if (entry.getValue() < expireTime) {
        String taskId = entry.getKey();
        cleanupTask(taskId);
        log.info("清理过期任务: {}", taskId);
        return true;
    }
    return false;
});

业务侧代码逻辑思路

代码逻辑过程示意图

scss 复制代码
/**
     * 分片上传实现
     *
     * @param tenantId 租户ID
     * @param pageUploadFileChunkReq 分片上传请求
     * @return 上传结果响应
     */
    @Override
    public SopFileChunkDataResponse uploadChunk(String tenantId, PageUploadFileChunkReq pageUploadFileChunkReq) throws IOException {
        // 获取任务id、总分片数
        String taskId = xxxxxxxxxxxxx
        int totalChunks = xxxxxxxxxxxxx
        // 构建任务下游返回响应实例
        xxxxxxxxx
        
        // 清理过期任务
        MultipartUploadManager.cleanupExpiredTasks(EXPIRE_HOURS);
        
        // 1. 初始化腾讯云COS客户端
        COSCredentials cred = new BasicCOSCredentials(sopProperties.getTencent().getAccessKeyId(),             sopProperties.getTencent().getAccessKeySecret());
        Region region = new Region(sopProperties.getTencent().getRegion());
        ClientConfig clientConfig = new ClientConfig(region);
        // 设置socket 读取超时,默认 30s 此处23分钟
        clientConfig.setSocketTimeout(230 * 1000);
        // 设置建立连接超时,默认 30s 此处23分钟
        clientConfig.setConnectionTimeout(230 * 1000);
        // 设置最大重试次数4次
        clientConfig.setMaxErrorRetry(4);
        COSClient cosClient = new COSClient(cred, clientConfig);
        
        try {
            // 2. 获取或初始化上传任务
            String uploadId = MultipartUploadManager.getOrInitTask(taskId, totalChunks);
            
            // 加锁为了解决大文件上传多个请求同时发送导致的同一个文件uploadId不一致问题
            if (uploadId == null) {
                MultipartUploadManager.GLOBAL_LOCK.lock();
                try {
                    uploadId = initMultipartUpload(cosClient, pageUploadFileChunkReq);
                } finally {
                    MultipartUploadManager.GLOBAL_LOCK.unlock();
                }
                MultipartUploadManager.setUploadId(taskId, uploadId);
                log.info("创建新上传任务: taskId={}, uploadId={}", taskId, uploadId);
            }
            
            // 3. 上传当前分片
            PartETag partETag = uploadPart(cosClient, uploadId, pageUploadFileChunkReq);
            
            // 4. 添加分片记录,并检查是否所有分片都已上传
            boolean isAllPartsUploaded = MultipartUploadManager.addPart(
                    taskId,
                    // 值存储用不加1了没用上
                    pageUploadFileChunkReq.getChunkIndex(),
                    partETag);
            
            // 5. 如果所有分片都已上传,执行合并操作
            if (isAllPartsUploaded) {
                List<PartETag> allPartETags = MultipartUploadManager.getSortedPartETags(taskId);
                completeMultipartUpload(cosClient, uploadId, allPartETags, pageUploadFileChunkReq);
                log.info("任务[{}]所有分片上传完成,已执行合并操作", taskId);
            }
            
            // 6. 设置响应状态
            if (MultipartUploadManager.isTaskCompleted(taskId)) {
                response.setOverallState(FilePartStatusEnum.MERGE_COMPLETED.getCode());
                // 清理任务资源(仅当任务完成时)
                MultipartUploadManager.cleanupTask(taskId);
            } else {
                response.setOverallState(FilePartStatusEnum.PART_UPLOAD_COMPLETED.getCode());
            }
            
        } catch (CosClientException e) {
            log.error("腾讯云COS服务异常: {}", e.getMessage(), e);
            throw e;
        } catch (Exception e) {
            log.error("腾讯云COS服务分片上传异常: {}", e.getMessage(), e);
            throw e;
        } finally {
            cosClient.shutdown();
        }
        
        response.setFileUrl(sopProperties.getTencent().getBaseUrl() + "/" + pageUploadFileChunkReq.getFileName());
        return response;
    }
​
​
  /**
     * 上传单个分片
     */
    private PartETag uploadPart(COSClient cosClient, String uploadId, PageUploadFileChunkReq pageUploadFileChunkReq) throws IOException {
        // 业务侧响应实例构建
        xxxxxxxxxxx 
        // 腾讯云cos要求必须partNumber值为1计数
        int partNumber = pageUploadFileChunkReq.getChunkIndex() + 1;
        uploadPartRequest.setPartNumber(partNumber);
        
        try {
            UploadPartResult uploadPartResult = cosClient.uploadPart(uploadPartRequest);
            PartETag partETag = uploadPartResult.getPartETag();
            log.info("分片上传成功: fileName={}, partNumber={}/{}",
                    pageUploadFileChunkReq.getFileName(),
                    partNumber,
                    pageUploadFileChunkReq.getTotalChunks());
            return partETag;
        } catch (CosClientException e) {
            log.error("分片上传失败: fileName={}, partNumber={}, error={}",
                    pageUploadFileChunkReq.getFileName(),
                    partNumber,
                    e.getMessage(), e);
            throw e;
        }
    }
​
​
   /**
     * 完成分片上传并合并分片
     */
    private void completeMultipartUpload(COSClient cosClient, String uploadId, List<PartETag> partETags, PageUploadFileChunkReq pageUploadFileChunkReq) {
        CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
                sopProperties.getTencent().getBucketName(),
                pageUploadFileChunkReq.getFileName(),
                uploadId,
                partETags);
        
        try {
            CompleteMultipartUploadResult completeResult = cosClient.completeMultipartUpload(completeRequest);
            log.info("完成分片上传并合并成功: fileName={}, eTag={}",
                    pageUploadFileChunkReq.getFileName(),
                    completeResult.getETag());
        } catch (CosClientException e) {
            log.error("完成分片上传并合并失败: fileName={}, error={}",
                    pageUploadFileChunkReq.getFileName(),
                    e.getMessage(), e);
            throw e;
        }
    }
​
/**
     * 生成预签名URL
     * @param objectPath 对象存储路径
     * @param expiration 过期时间
     * @return
     */
    @Override
    public URL generatePresignedUrl(String objectPath, Date expiration) {
        // 初始化客户端
        COSCredentials cred = new BasicCOSCredentials(
                sopProperties.getTencent().getAccessKeyId(),
                sopProperties.getTencent().getAccessKeySecret());
        ClientConfig clientConfig = new ClientConfig(new Region(sopProperties.getTencent().getRegion()));
        COSClient cosClient = new COSClient(cred, clientConfig);
        // 请求的 HTTP 方法,下载请求用 GET
        HttpMethodName method = HttpMethodName.GET;
        // 生成带有签名的 URL
        GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(sopProperties.getTencent().getBucketName(), objectPath, method);
        request.setExpiration(expiration);
        URL signedUrl = cosClient.generatePresignedUrl(request);
        log.info("腾讯云cos生成的预签名 URL 为: {}", signedUrl.toString());
        return signedUrl;
    }
​

总结

  1. 关于本次使用腾讯云官方SDK进行对象存储上传,进行开发之前需要仔细阅读官方说明文档。
  2. 有相关问题可以联系腾讯云客服进行日志相关获取帮助定位问题所在。
  3. 大文件上传,前端分片之后会有多个请求并发访问,需要使用加锁的方式保证一个任务的上传ID唯一 不然会最终手动合并失败
相关推荐
京东云开发者18 分钟前
如何使用wireshark进行远程抓包
程序员
京东云开发者23 分钟前
InheritableThreadLocal从入门到放弃
程序员
京东云开发者27 分钟前
🔥1篇搞懂AI通识:大白话拆解核心点
程序员
Libby博仙31 分钟前
Spring Boot 条件化注解深度解析
java·spring boot·后端
源代码•宸1 小时前
Golang原理剖析(Map 源码梳理)
经验分享·后端·算法·leetcode·golang·map
小周在成长1 小时前
动态SQL与MyBatis动态SQL最佳实践
后端
瓦尔登湖懒羊羊1 小时前
TCP的自我介绍
后端
小周在成长1 小时前
MyBatis 动态SQL学习
后端
子非鱼9211 小时前
SpringBoot快速上手
java·spring boot·后端
掘金安东尼1 小时前
向大家介绍《开发者博主联盟》🚀
前端·程序员·github