📁 设计一个文件上传和存储服务:云盘的秘密!

📖 开场:上传照片

想象你用百度网盘上传照片 📷:

小文件上传(简单)

markdown 复制代码
照片大小:2MB
    ↓
直接上传 → 服务器 → 存储 ✅

实现:HTTP POST,简单!

大文件上传(复杂)

markdown 复制代码
视频大小:2GB
    ↓
问题:
1. 上传太慢(1小时)⏰
2. 网络中断 → 重新上传 😱
3. 服务器内存爆了 💥

需求:
1. 断点续传(中断后继续)
2. 分片上传(并发上传)
3. 秒传(相同文件不重复上传)

这就是文件上传和存储服务的挑战!


🤔 核心需求

业务场景

场景 说明 难度
网盘 百度网盘、阿里云盘 ⭐⭐⭐
社交 微信、QQ发文件 ⭐⭐
视频网站 B站、YouTube ⭐⭐⭐
图床 图片托管服务 ⭐⭐

核心功能

功能 说明 重要性
分片上传 大文件拆分上传 ⭐⭐⭐
断点续传 中断后继续 ⭐⭐⭐
秒传 相同文件不重复上传 ⭐⭐⭐
下载 支持断点下载 ⭐⭐
CDN加速 全球加速 ⭐⭐

🎯 技术方案

方案1:简单上传(小文件)📤

前端代码

html 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>文件上传</title>
</head>
<body>
    <input type="file" id="fileInput" />
    <button onclick="upload()">上传</button>
    <div id="progress"></div>
    
    <script>
        function upload() {
            const file = document.getElementById('fileInput').files[0];
            if (!file) {
                alert('请选择文件');
                return;
            }
            
            // ⭐ 创建FormData
            const formData = new FormData();
            formData.append('file', file);
            
            // ⭐ 创建XMLHttpRequest
            const xhr = new XMLHttpRequest();
            
            // ⭐ 监听进度
            xhr.upload.addEventListener('progress', (e) => {
                if (e.lengthComputable) {
                    const percent = (e.loaded / e.total * 100).toFixed(2);
                    document.getElementById('progress').innerText = '上传进度: ' + percent + '%';
                }
            });
            
            // ⭐ 监听完成
            xhr.addEventListener('load', () => {
                if (xhr.status === 200) {
                    const result = JSON.parse(xhr.responseText);
                    alert('上传成功!文件URL: ' + result.url);
                } else {
                    alert('上传失败');
                }
            });
            
            // ⭐ 发送请求
            xhr.open('POST', '/api/upload');
            xhr.send(formData);
        }
    </script>
</body>
</html>

后端代码(Spring Boot)

java 复制代码
@RestController
@RequestMapping("/api")
public class FileUploadController {
    
    @Value("${upload.path}")
    private String uploadPath;  // 上传目录
    
    /**
     * ⭐ 简单上传
     */
    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            return Result.fail("文件为空");
        }
        
        try {
            // 1. 生成文件名(UUID)
            String originalFilename = file.getOriginalFilename();
            String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
            String filename = UUID.randomUUID().toString() + extension;
            
            // 2. 保存文件
            String filePath = uploadPath + "/" + filename;
            File dest = new File(filePath);
            
            // 创建目录
            if (!dest.getParentFile().exists()) {
                dest.getParentFile().mkdirs();
            }
            
            // 保存
            file.transferTo(dest);
            
            // 3. 返回URL
            String url = "/files/" + filename;
            
            log.info("文件上传成功: filename={}, size={}", filename, file.getSize());
            
            return Result.success(url);
            
        } catch (IOException e) {
            log.error("文件上传失败", e);
            return Result.fail("上传失败");
        }
    }
}

问题

  • 大文件(>100MB)上传慢 ❌
  • 网络中断需要重新上传 ❌
  • 服务器内存占用大 ❌

方案2:分片上传 + 断点续传 ⭐⭐⭐

原理

markdown 复制代码
大文件:1GB
    ↓
拆分成100个分片(每片10MB)
    ↓
并发上传多个分片 🚀
    ↓
全部上传完成 → 合并分片 ✅

优点:
- 并发上传,速度快 ✅
- 网络中断,只需重传失败的分片 ✅
- 服务器内存占用小(每次只处理10MB)✅

前端代码

javascript 复制代码
class FileUploader {
    constructor() {
        this.CHUNK_SIZE = 10 * 1024 * 1024;  // 分片大小:10MB
        this.MAX_CONCURRENT = 3;              // 最大并发数
    }
    
    /**
     * ⭐ 上传文件
     */
    async uploadFile(file) {
        // 1. 计算文件MD5(用于秒传和断点续传)
        const fileMd5 = await this.calculateMD5(file);
        console.log('文件MD5:', fileMd5);
        
        // 2. 检查是否已上传(秒传)
        const checkResult = await this.checkFileExists(fileMd5);
        if (checkResult.exists) {
            console.log('文件已存在,秒传成功!');
            return checkResult.url;
        }
        
        // 3. 计算分片数量
        const chunks = Math.ceil(file.size / this.CHUNK_SIZE);
        console.log('总分片数:', chunks);
        
        // 4. 查询已上传的分片(断点续传)
        const uploadedChunks = await this.getUploadedChunks(fileMd5);
        console.log('已上传分片:', uploadedChunks);
        
        // 5. 上传未完成的分片
        const tasks = [];
        for (let i = 0; i < chunks; i++) {
            if (uploadedChunks.includes(i)) {
                continue;  // 跳过已上传的分片
            }
            
            const start = i * this.CHUNK_SIZE;
            const end = Math.min(start + this.CHUNK_SIZE, file.size);
            const chunk = file.slice(start, end);
            
            // ⭐ 创建上传任务
            const task = () => this.uploadChunk(fileMd5, i, chunk, chunks);
            tasks.push(task);
        }
        
        // 6. ⭐ 并发上传(最多3个并发)
        await this.concurrentUpload(tasks, this.MAX_CONCURRENT);
        
        // 7. ⭐ 合并分片
        const mergeResult = await this.mergeChunks(fileMd5, file.name, chunks);
        console.log('上传完成!URL:', mergeResult.url);
        
        return mergeResult.url;
    }
    
    /**
     * ⭐ 计算文件MD5
     */
    async calculateMD5(file) {
        return new Promise((resolve) => {
            // 使用spark-md5计算MD5
            const reader = new FileReader();
            const spark = new SparkMD5.ArrayBuffer();
            
            reader.onload = (e) => {
                spark.append(e.target.result);
                resolve(spark.end());
            };
            
            reader.readAsArrayBuffer(file);
        });
    }
    
    /**
     * ⭐ 检查文件是否已存在(秒传)
     */
    async checkFileExists(fileMd5) {
        const response = await fetch('/api/upload/check', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ md5: fileMd5 })
        });
        
        return await response.json();
    }
    
    /**
     * ⭐ 查询已上传的分片(断点续传)
     */
    async getUploadedChunks(fileMd5) {
        const response = await fetch('/api/upload/chunks?md5=' + fileMd5);
        const result = await response.json();
        return result.data || [];
    }
    
    /**
     * ⭐ 上传单个分片
     */
    async uploadChunk(fileMd5, chunkIndex, chunk, totalChunks) {
        const formData = new FormData();
        formData.append('file', chunk);
        formData.append('md5', fileMd5);
        formData.append('chunkIndex', chunkIndex);
        formData.append('totalChunks', totalChunks);
        
        const response = await fetch('/api/upload/chunk', {
            method: 'POST',
            body: formData
        });
        
        const result = await response.json();
        
        if (result.success) {
            console.log(`分片 ${chunkIndex + 1}/${totalChunks} 上传成功`);
        } else {
            throw new Error(`分片 ${chunkIndex} 上传失败`);
        }
        
        return result;
    }
    
    /**
     * ⭐ 并发上传(控制并发数)
     */
    async concurrentUpload(tasks, maxConcurrent) {
        const results = [];
        const executing = [];
        
        for (const task of tasks) {
            const promise = task().then(() => {
                executing.splice(executing.indexOf(promise), 1);
            });
            
            results.push(promise);
            executing.push(promise);
            
            if (executing.length >= maxConcurrent) {
                await Promise.race(executing);
            }
        }
        
        return Promise.all(results);
    }
    
    /**
     * ⭐ 合并分片
     */
    async mergeChunks(fileMd5, filename, totalChunks) {
        const response = await fetch('/api/upload/merge', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                md5: fileMd5,
                filename: filename,
                totalChunks: totalChunks
            })
        });
        
        return await response.json();
    }
}

// 使用示例
const uploader = new FileUploader();

document.getElementById('fileInput').addEventListener('change', async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    
    try {
        const url = await uploader.uploadFile(file);
        alert('上传成功!URL: ' + url);
    } catch (error) {
        alert('上传失败: ' + error.message);
    }
});

后端代码

java 复制代码
@RestController
@RequestMapping("/api/upload")
@Slf4j
public class ChunkUploadController {
    
    @Value("${upload.chunk.path}")
    private String chunkPath;  // 分片临时目录
    
    @Value("${upload.file.path}")
    private String filePath;   // 最终文件目录
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * ⭐ 检查文件是否已存在(秒传)
     */
    @PostMapping("/check")
    public Result<Map<String, Object>> checkFileExists(@RequestBody Map<String, String> params) {
        String md5 = params.get("md5");
        
        // 查询Redis(缓存)
        String cacheKey = "file:md5:" + md5;
        String cachedUrl = redisTemplate.opsForValue().get(cacheKey);
        
        if (cachedUrl != null) {
            // ⭐ 文件已存在,秒传成功
            log.info("秒传成功: md5={}", md5);
            
            Map<String, Object> result = new HashMap<>();
            result.put("exists", true);
            result.put("url", cachedUrl);
            
            return Result.success(result);
        }
        
        // 文件不存在
        Map<String, Object> result = new HashMap<>();
        result.put("exists", false);
        
        return Result.success(result);
    }
    
    /**
     * ⭐ 查询已上传的分片(断点续传)
     */
    @GetMapping("/chunks")
    public Result<List<Integer>> getUploadedChunks(@RequestParam String md5) {
        String key = "chunks:" + md5;
        
        // 从Redis获取已上传的分片列表
        Set<String> chunks = redisTemplate.opsForSet().members(key);
        
        if (chunks == null || chunks.isEmpty()) {
            return Result.success(Collections.emptyList());
        }
        
        List<Integer> uploadedChunks = chunks.stream()
            .map(Integer::parseInt)
            .sorted()
            .collect(Collectors.toList());
        
        log.info("已上传分片: md5={}, chunks={}", md5, uploadedChunks);
        
        return Result.success(uploadedChunks);
    }
    
    /**
     * ⭐ 上传分片
     */
    @PostMapping("/chunk")
    public Result<?> uploadChunk(
            @RequestParam("file") MultipartFile file,
            @RequestParam("md5") String md5,
            @RequestParam("chunkIndex") int chunkIndex,
            @RequestParam("totalChunks") int totalChunks) {
        
        try {
            // 1. 保存分片到临时目录
            String chunkDir = chunkPath + "/" + md5;
            File dir = new File(chunkDir);
            if (!dir.exists()) {
                dir.mkdirs();
            }
            
            String chunkFilename = chunkDir + "/" + chunkIndex;
            File dest = new File(chunkFilename);
            file.transferTo(dest);
            
            log.info("分片上传成功: md5={}, chunk={}/{}", md5, chunkIndex + 1, totalChunks);
            
            // 2. ⭐ 记录已上传的分片(Redis Set)
            String key = "chunks:" + md5;
            redisTemplate.opsForSet().add(key, String.valueOf(chunkIndex));
            
            // 设置过期时间(1天)
            redisTemplate.expire(key, 1, TimeUnit.DAYS);
            
            return Result.success("分片上传成功");
            
        } catch (IOException e) {
            log.error("分片上传失败", e);
            return Result.fail("上传失败");
        }
    }
    
    /**
     * ⭐ 合并分片
     */
    @PostMapping("/merge")
    public Result<Map<String, Object>> mergeChunks(@RequestBody Map<String, Object> params) {
        String md5 = (String) params.get("md5");
        String filename = (String) params.get("filename");
        int totalChunks = (int) params.get("totalChunks");
        
        log.info("开始合并分片: md5={}, totalChunks={}", md5, totalChunks);
        
        try {
            // 1. ⭐ 检查所有分片是否都已上传
            String key = "chunks:" + md5;
            Set<String> uploadedChunks = redisTemplate.opsForSet().members(key);
            
            if (uploadedChunks == null || uploadedChunks.size() != totalChunks) {
                return Result.fail("分片未全部上传");
            }
            
            // 2. ⭐ 合并分片
            String extension = filename.substring(filename.lastIndexOf("."));
            String finalFilename = md5 + extension;
            String finalFilePath = filePath + "/" + finalFilename;
            
            File finalFile = new File(finalFilePath);
            if (finalFile.getParentFile() != null && !finalFile.getParentFile().exists()) {
                finalFile.getParentFile().mkdirs();
            }
            
            // ⭐ 按顺序合并分片
            try (FileOutputStream fos = new FileOutputStream(finalFile)) {
                for (int i = 0; i < totalChunks; i++) {
                    String chunkFilename = chunkPath + "/" + md5 + "/" + i;
                    File chunkFile = new File(chunkFilename);
                    
                    try (FileInputStream fis = new FileInputStream(chunkFile)) {
                        byte[] buffer = new byte[8192];
                        int bytesRead;
                        while ((bytesRead = fis.read(buffer)) != -1) {
                            fos.write(buffer, 0, bytesRead);
                        }
                    }
                }
            }
            
            log.info("分片合并成功: filename={}", finalFilename);
            
            // 3. ⭐ 删除分片文件
            String chunkDir = chunkPath + "/" + md5;
            FileUtils.deleteDirectory(new File(chunkDir));
            
            // 4. ⭐ 删除Redis中的分片记录
            redisTemplate.delete(key);
            
            // 5. ⭐ 缓存文件MD5和URL的映射(秒传)
            String url = "/files/" + finalFilename;
            String cacheKey = "file:md5:" + md5;
            redisTemplate.opsForValue().set(cacheKey, url, 7, TimeUnit.DAYS);
            
            // 6. 返回结果
            Map<String, Object> result = new HashMap<>();
            result.put("url", url);
            result.put("filename", finalFilename);
            
            return Result.success(result);
            
        } catch (IOException e) {
            log.error("合并分片失败", e);
            return Result.fail("合并失败");
        }
    }
}

方案3:对象存储(OSS)☁️

为什么用OSS?

问题

diff 复制代码
自建存储:
- 成本高(服务器、带宽)💰
- 运维难(扩容、备份)⚙️
- 性能差(单机限制)❌

OSS优势

diff 复制代码
- 无限容量 ✅
- 高可用(99.9999999%)✅
- 全球CDN加速 ✅
- 按量付费 ✅

阿里云OSS示例

java 复制代码
@Service
@Slf4j
public class OSSUploadService {
    
    @Value("${aliyun.oss.endpoint}")
    private String endpoint;
    
    @Value("${aliyun.oss.accessKeyId}")
    private String accessKeyId;
    
    @Value("${aliyun.oss.accessKeySecret}")
    private String accessKeySecret;
    
    @Value("${aliyun.oss.bucketName}")
    private String bucketName;
    
    private OSS ossClient;
    
    @PostConstruct
    public void init() {
        // 创建OSS客户端
        ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    }
    
    /**
     * ⭐ 上传文件到OSS
     */
    public String uploadFile(MultipartFile file) throws IOException {
        // 1. 生成文件名
        String originalFilename = file.getOriginalFilename();
        String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        String filename = UUID.randomUUID().toString() + extension;
        
        // 2. 上传到OSS
        ossClient.putObject(bucketName, filename, file.getInputStream());
        
        // 3. 返回URL
        String url = "https://" + bucketName + "." + endpoint + "/" + filename;
        
        log.info("文件上传到OSS成功: filename={}, url={}", filename, url);
        
        return url;
    }
    
    /**
     * ⭐ 分片上传到OSS
     */
    public String multipartUpload(File file) {
        String filename = UUID.randomUUID().toString() + ".mp4";
        
        // ⭐ 1. 初始化分片上传
        InitiateMultipartUploadRequest initRequest = 
            new InitiateMultipartUploadRequest(bucketName, filename);
        InitiateMultipartUploadResult initResult = ossClient.initiateMultipartUpload(initRequest);
        String uploadId = initResult.getUploadId();
        
        // ⭐ 2. 上传分片
        List<PartETag> partETags = new ArrayList<>();
        long fileLength = file.length();
        long partSize = 10 * 1024 * 1024;  // 10MB
        int partCount = (int) (fileLength / partSize);
        
        if (fileLength % partSize != 0) {
            partCount++;
        }
        
        try (FileInputStream fis = new FileInputStream(file)) {
            for (int i = 0; i < partCount; i++) {
                long startPos = i * partSize;
                long curPartSize = Math.min(partSize, fileLength - startPos);
                
                // 上传分片
                UploadPartRequest uploadPartRequest = new UploadPartRequest();
                uploadPartRequest.setBucketName(bucketName);
                uploadPartRequest.setKey(filename);
                uploadPartRequest.setUploadId(uploadId);
                uploadPartRequest.setInputStream(fis);
                uploadPartRequest.setPartSize(curPartSize);
                uploadPartRequest.setPartNumber(i + 1);
                
                UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
                partETags.add(uploadPartResult.getPartETag());
                
                log.info("分片上传成功: {}/{}", i + 1, partCount);
            }
        } catch (IOException e) {
            log.error("分片上传失败", e);
            return null;
        }
        
        // ⭐ 3. 完成分片上传
        CompleteMultipartUploadRequest completeRequest = 
            new CompleteMultipartUploadRequest(bucketName, filename, uploadId, partETags);
        ossClient.completeMultipartUpload(completeRequest);
        
        String url = "https://" + bucketName + "." + endpoint + "/" + filename;
        
        log.info("分片上传完成: url={}", url);
        
        return url;
    }
    
    @PreDestroy
    public void destroy() {
        if (ossClient != null) {
            ossClient.shutdown();
        }
    }
}

📊 架构总结

markdown 复制代码
       文件上传和存储系统架构

┌──────────────────────────────────────┐
│           客户端                      │
│                                      │
│  - 文件选择                          │
│  - 分片上传                          │
│  - 进度显示                          │
└─────────────┬────────────────────────┘
              │
              ↓
┌──────────────────────────────────────┐
│         应用服务器                    │
│                                      │
│  - 秒传判断(MD5)                   │
│  - 分片接收                          │
│  - 分片合并                          │
└───────┬──────────────┬───────────────┘
        │              │
        ↓              ↓
┌──────────────┐  ┌──────────────────┐
│    Redis     │  │   OSS/本地存储   │
│              │  │                  │
│ - 分片记录   │  │ - 文件存储       │
│ - MD5缓存    │  │ - CDN加速        │
└──────────────┘  └──────────────────┘

🎓 面试题速答

Q1: 如何实现大文件上传?

A : 分片上传

markdown 复制代码
1. 文件拆分:每10MB一个分片
2. 并发上传:最多3个并发
3. 分片保存:保存到临时目录
4. 合并分片:按顺序合并

优点

  • 并发上传,速度快
  • 网络中断,只需重传失败的分片
  • 服务器内存占用小

Q2: 如何实现断点续传?

A : Redis记录已上传的分片

java 复制代码
// 上传分片时,记录到Redis
redisTemplate.opsForSet().add("chunks:" + md5, String.valueOf(chunkIndex));

// 继续上传时,查询已上传的分片
Set<String> uploaded = redisTemplate.opsForSet().members("chunks:" + md5);

// 跳过已上传的分片
if (uploaded.contains(chunkIndex)) {
    continue;
}

Q3: 如何实现秒传?

A : MD5去重

markdown 复制代码
1. 前端计算文件MD5
2. 调用接口检查MD5是否已存在
3. 如果存在,直接返回URL(秒传)
4. 如果不存在,正常上传
5. 上传完成后,缓存MD5和URL的映射
java 复制代码
// 检查文件是否已存在
String cacheKey = "file:md5:" + md5;
String cachedUrl = redisTemplate.opsForValue().get(cacheKey);

if (cachedUrl != null) {
    // 秒传成功
    return cachedUrl;
}

Q4: 为什么用对象存储(OSS)而不是自建存储?

A : 四个优势

  1. 成本低:按量付费,无需购买服务器
  2. 无限容量:自动扩容
  3. 高可用:99.9999999%可靠性
  4. CDN加速:全球加速,下载快

自建存储的问题

  • 成本高(服务器、带宽)
  • 运维难(扩容、备份)
  • 性能差(单机限制)

Q5: 如何防止恶意上传?

A : 五种防护

  1. 文件类型限制

    java 复制代码
    if (!isAllowedType(file.getContentType())) {
        return Result.fail("不支持的文件类型");
    }
  2. 文件大小限制

    java 复制代码
    if (file.getSize() > 1024 * 1024 * 1024) {  // 1GB
        return Result.fail("文件过大");
    }
  3. 用户限流

    • 每个用户每天最多上传100个文件
    • 使用Redis计数器
  4. 病毒扫描

    • 调用杀毒软件API
    • 检测病毒和木马
  5. 登录验证

    • 必须登录才能上传
    • JWT Token验证

Q6: 如何优化上传速度?

A : 五种优化

  1. 分片上传 + 并发上传

    • 10MB一个分片
    • 最多3个并发
  2. CDN加速

    • 就近上传
    • 减少延迟
  3. 压缩上传

    • 图片压缩(WebP格式)
    • 视频压缩(H.264编码)
  4. HTTP/2

    • 多路复用
    • 头部压缩
  5. 断点续传

    • 避免重复上传

🎬 总结

markdown 复制代码
     文件上传核心技术

┌────────────────────────────────────┐
│ 分片上传                           │
│ - 10MB一个分片                     │
│ - 并发上传(3个并发)              │
│ - 合并分片                         │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 断点续传                           │
│ - Redis记录已上传的分片            │
│ - 跳过已上传的分片                 │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 秒传                               │
│ - 计算文件MD5                      │
│ - 检查MD5是否已存在                │
│ - 存在则直接返回URL                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 对象存储(OSS)                    │
│ - 无限容量                         │
│ - 高可用                           │
│ - CDN加速                          │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了文件上传和存储服务的设计!🎊

核心要点

  1. 分片上传:10MB一片,并发上传
  2. 断点续传:Redis记录已上传的分片
  3. 秒传:MD5去重
  4. 对象存储:OSS > 自建存储

下次面试,这样回答

"文件上传服务采用分片上传 + 断点续传 + 秒传的方案。

分片上传是将大文件拆分成10MB的小分片,前端最多3个并发上传,上传完成后服务端按顺序合并分片。这样网络中断时只需重传失败的分片,服务器内存占用也小。

断点续传通过Redis记录已上传的分片,继续上传时跳过已上传的分片。秒传通过计算文件MD5实现,如果MD5已存在则直接返回URL,不需要重复上传。

存储方面使用阿里云OSS,相比自建存储有无限容量、高可用(99.9999999%)、CDN加速等优势,而且按量付费,成本更低。

我们项目的视频上传服务采用这套方案,支持2GB大文件上传,平均上传速度10MB/s,运行稳定。"

面试官:👍 "很好!你对文件上传服务的设计理解很透彻!"


本文完 🎬

上一篇 : 201-设计一个实时排行榜系统.md
下一篇 : 203-设计一个搜索引擎系统.md

作者注 :写完这篇,我都想去百度做网盘了!📁

如果这篇文章对你有帮助,请给我一个Star⭐!

复制代码
相关推荐
Merrick3 小时前
亲手操作Java抽象语法树
java·后端
今天没ID3 小时前
高阶函数
后端
_光光3 小时前
大文件上传服务实现(后端篇)
后端·node.js·express
初级程序员Kyle3 小时前
开始改变第三天 Java并发(1)
java·后端
无名之辈J4 小时前
GC Overhead 排查
后端
倚栏听风雨4 小时前
jackson @JsonAnyGetter @JsonAnySetter 使用说明
后端
Mintopia4 小时前
🚀 Next.js 16 新特性深度解析:当框架开始思考人生
前端·后端·全栈