去年我接手一个在线教育项目,核心功能之一是用户上传课件图片。初期为了快速上线,直接存本地服务器目录,配合Nginx静态访问。用户量几百时没问题,但到几千并发时,磁盘I/O飙升,单点故障频发,甚至出现过一次磁盘写满导致服务宕机。这迫使我不得不重新设计整个图片上传链路。

第一轮重构:本地存储的瓶颈与初步改造

最初代码很简单:接收MultipartFile,写入服务器磁盘,返回URL。但很快发现三个问题:一是磁盘空间有限,扩容麻烦;二是多个实例部署时,文件不同步导致用户访问404;三是大图片上传阻塞Tomcat线程。第一轮我做了三个优化:改用异步写入、限制文件大小、引入NFS共享存储。但NFS性能差,网络抖动时写入失败率高,且依然存在单点。

// 初始版本:同步写入本地@PostMapping("/upload")public String upload(@RequestParam("file") MultipartFile file) { String path = "/data/images/" + UUID.randomUUID() + ".jpg"; file.transferTo(new File(path)); // 阻塞,磁盘写满时抛异常 return "/images/" + filename;}

这个阶段,我统计了数据:单台服务器磁盘4TB,每天新增约20GB图片,理论上能用200天。但用户增长曲线告诉我,半年后就会耗尽。更重要的是,并发写入时磁盘IO利用率达到80%,接口响应从50ms飙升到2s。

第二轮重构:引入MinIO分布式存储

调研了FastDFS、Ceph和MinIO后,我选了MinIO。原因:兼容S3 API,部署简单(单二进制文件),性能足够(号称55GB/s读),且支持纠删码。部署了3个节点,每节点挂载2块4TB SSD,用Nginx做负载均衡。

核心洞见:选择MinIO而非FastDFS,是因为团队更熟悉HTTP API,且MinIO的客户端库成熟,后续扩展CDN也方便。

迁移过程踩了一个坑:MinIO默认使用纠删码模式,如果文件小于阈值(默认4MB),会分成data和parity分片。我一开始没注意,上传小图片时发现存储空间被放大(实际1KB文件占用4MB)。后来改为单盘模式,或者调整MINIO_STORAGE_CLASS_STANDARD参数。代码改造很简单,引入AWS SDK,替换本地写入:

// MinIO客户端AmazonS3 s3Client = AmazonS3ClientBuilder.standard() .withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration("http://minio-cluster:9000", "us-east-1")) .withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("access", "secret"))) .build();public String upload(byte[] data, String fileName) { ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentLength(data.length); s3Client.putObject("images", fileName, new ByteArrayInputStream(data), metadata); return s3Client.getUrl("images", fileName).toString();}

上线后,磁盘IO不再是瓶颈,但新问题出现:大文件上传(>10MB)时,用户端等待时间过长,且网络中断导致重传。于是进入第三轮。

第三轮重构:分片上传与断点续传

分片上传思路:前端将文件切成2MB一片,并行上传,后端合并。MinIO原生支持分片上传:initiateMultipartUpload、uploadPart、completeMultipartUpload。关键实现:

// 初始化分片上传InitiateMultipartUploadRequest initRequest = new InitiateMultipartUploadRequest("images", fileName);InitiateMultipartUploadResult initResult = s3Client.initiateMultipartUpload(initRequest);String uploadId = initResult.getUploadId();// 上传每个分片List partETags = new ArrayList<>();for (int i = 0; i < partCount; i++) { UploadPartRequest uploadRequest = new UploadPartRequest() .withBucketName("images") .withKey(fileName) .withUploadId(uploadId) .withPartNumber(i + 1) .withInputStream(partStream) .withPartSize(partSize); UploadPartResult result = s3Client.uploadPart(uploadRequest); partETags.add(result.getPartETag());}// 完成上传CompleteMultipartUploadRequest compRequest = new CompleteMultipartUploadRequest("images", fileName, uploadId, partETags);s3Client.completeMultipartUpload(compRequest);

断点续传依赖前端记录已上传的分片列表,失败后只传未完成的分片。后端需要提供一个接口查询已上传分片:listParts。踩坑:分片大小必须大于5MB(除了最后一片),否则MinIO会拒绝。文档里写了,但我没注意,导致部分小分片上传失败,排查了半天。

性能数据:分片后,10MB图片上传从8秒降到2秒(并发5片),且网络中断恢复后只需补传失败片,用户体验大幅提升。

第四轮重构:CDN加速

MinIO直接暴露给用户,虽然内网快,但用户遍布全国,延迟高。我决定加一层CDN。选型:用阿里云CDN,源站指向MinIO集群的Nginx入口。配置简单:在CDN控制台添加域名,源站填MinIO域名,回源Host填Bucket域名。注意:需要设置CDN缓存规则,对图片缓存30天,并开启Range回源(支持分片)。

效果:全国平均延迟从120ms降到25ms,首字节时间缩短80%。CDN命中率95%,回源流量很少,MinIO压力大减。

总结与经验

回顾整个演进,从本地存储到MinIO再到CDN,每一步都解决特定问题:本地存储快速验证,MinIO解决容量和可靠性,分片上传解决大文件体验,CDN解决全球延迟。如果一开始就上全套方案,可能过度设计。但演进过程中,我学到了几点:

  • 文件上传一定要用异步处理,避免阻塞Web线程。
  • 选择存储方案时,考虑未来CDN对接的便利性,MinIO的S3兼容是个巨大优势。
  • 分片上传的分片大小要符合对象存储约束,MinIO要求大于5MB(最后一片除外)。
  • CDN缓存策略要配合文件版本号或签名URL,避免更新后用户访问旧资源。

最后,这套架构已经稳定运行一年,支撑日均10万次上传,存储容量超过100TB。如果你也在做类似项目,可以参考这个演进路径,但建议根据实际并发和存储量选择合适的起点。顺便提一句,我们团队目前也在用类似思路为客户设计文件系统,比如「时光智行」小程序中的课件存储就采用了MinIO+CDN方案。