文件存储是业务系统中普遍存在的场景,包括用户头像、商品图片、附件上传、视频存储等。在分布式环境下,本地文件系统已无法满足需求,对象存储(Object Storage)成为标准方案。
一、存储方案选型
1.1 方案对比
| 特性 | 本地文件系统 | NFS | 对象存储(MinIO/OSS) |
|---|---|---|---|
| 扩展性 | 差 | 一般 | 水平无限扩展 |
| 可靠性 | 低 | 中 | 多副本/纠删码 |
| 成本 | 低 | 中 | 按需付费 |
| CDN 集成 | 难 | 难 | 原生支持 |
| 适用场景 | 单机/临时文件 | 局域网共享 | 生产环境首选 |
1.2 常见对象存储
| 产品 | 厂商 | 特点 |
|---|---|---|
| 阿里云 OSS | 阿里云 | 国内生态最完善 |
| 腾讯云 COS | 腾讯云 | 与微信生态结合好 |
| Amazon S3 | AWS | 事实标准,兼容性强 |
| MinIO | 开源 | 私有化部署,S3 兼容 |
| Ceph | 开源 | 超大规模,复杂性高 |
二、MinIO 私有化部署
2.1 Docker 快速启动
# docker-compose.yml
version: '3.8'
services:
minio:
image: minio/minio:latest
ports:
- "9000:9000" # API 端口
- "9001:9001" # Console 端口
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: changeme123
volumes:
- ./data:/data
command: server /data --console-address ":9001"
2.2 Spring Boot 集成
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
# application.yml
minio:
endpoint: http://localhost:9000
access-key: admin
secret-key: changeme123
bucket-name: myapp
@Configuration
public class MinioConfig {
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}
2.3 基础操作封装
@Service
@Slf4j
public class MinioStorageService {
@Autowired
private MinioClient minioClient;
@Value("${minio.bucket-name}")
private String bucketName;
@PostConstruct
public void init() throws Exception {
// 自动创建 bucket
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder().bucket(bucketName).build()
);
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
// 设置 bucket 策略为只读(公共可访问图片)
String policy = """
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Principal":"*","Action":"s3:GetObject",
"Resource":"arn:aws:s3:::%s/*"}
]}
""".formatted(bucketName);
minioClient.setBucketPolicy(
SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build()
);
}
}
public String upload(MultipartFile file, String folder) {
String originalName = file.getOriginalFilename();
String ext = FilenameUtils.getExtension(originalName);
String fileName = folder + "/" + UUID.randomUUID() + "." + ext;
try (InputStream is = file.getInputStream()) {
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.stream(is, file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
return fileName;
} catch (Exception e) {
log.error("文件上传失败: {}", originalName, e);
throw new StorageException("文件上传失败");
}
}
public InputStream download(String fileName) {
try {
return minioClient.getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.build()
);
} catch (Exception e) {
throw new StorageException("文件下载失败: " + fileName);
}
}
public String getPresignedUrl(String fileName, int expiryMinutes) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(fileName)
.expiry(expiryMinutes, TimeUnit.MINUTES)
.build()
);
} catch (Exception e) {
throw new StorageException("生成预签名 URL 失败");
}
}
public void delete(String fileName) {
try {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(fileName)
.build()
);
} catch (Exception e) {
log.error("文件删除失败: {}", fileName, e);
}
}
}
三、阿里云 OSS 集成
3.1 SDK 配置
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
aliyun:
oss:
endpoint: oss-cn-hangzhou.aliyuncs.com
access-key-id: ${OSS_ACCESS_KEY_ID}
access-key-secret: ${OSS_ACCESS_KEY_SECRET}
bucket-name: myapp-bucket
3.2 服务端签名直传
@Service
public class OssUploadService {
@Autowired
private OSS ossClient;
@Value("${aliyun.oss.bucket-name}")
private String bucketName;
/**
* 生成前端直传的签名信息
*/
public Map<String, String> generatePostPolicy(String dir, long maxSize) {
try {
long expireEndTime = System.currentTimeMillis() + 3600 * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, maxSize);
policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
byte[] binaryData = postPolicy.getBytes(StandardCharsets.UTF_8);
String encodedPolicy = BinaryUtil.toBase64String(binaryData);
String postSignature = ossClient.calculatePostSignature(postPolicy);
Map<String, String> result = new HashMap<>();
result.put("accessid", accessKeyId);
result.put("policy", encodedPolicy);
result.put("signature", postSignature);
result.put("dir", dir);
result.put("host", "https://" + bucketName + "." + endpoint);
result.put("expire", String.valueOf(expireEndTime / 1000));
return result;
} catch (Exception e) {
throw new StorageException("生成上传签名失败");
}
}
/**
* 断点续传
*/
public String uploadWithCheckpoint(MultipartFile file, String objectName) {
try {
UploadFileRequest request = new UploadFileRequest(bucketName, objectName);
request.setUploadFile(file.getInputStream());
request.setTaskNum(3); // 并发数
request.setPartSize(1024 * 1024L); // 分片大小 1MB
request.setEnableCheckpoint(true); // 开启断点续传
request.setCheckpointFile("/tmp/checkpoint/" + objectName);
UploadFileResult result = ossClient.uploadFile(request);
return result.getMultipartUploadResult().getLocation();
} catch (Exception e) {
throw new StorageException("断点上传失败");
}
}
}
3.3 前端直传示例
// Vue/React 前端直传
async function uploadToOSS(file, policy) {
const formData = new FormData();
formData.append('OSSAccessKeyId', policy.accessid);
formData.append('policy', policy.policy);
formData.append('signature', policy.signature);
formData.append('key', policy.dir + '${filename}');
formData.append('success_action_status', '200');
formData.append('file', file);
const response = await fetch(policy.host, {
method: 'POST',
body: formData
});
if (response.status === 200) {
return `${policy.host}/${policy.dir}${file.name}`;
}
throw new Error('上传失败');
}
四、文件上传高级场景
4.1 大文件分片上传
@RestController
@RequestMapping("/api/upload")
public class ChunkUploadController {
@PostMapping("/chunk")
public ResponseEntity<Void> uploadChunk(
@RequestParam("chunk") MultipartFile chunk,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("identifier") String identifier
) {
String chunkDir = "/tmp/chunks/" + identifier;
File dir = new File(chunkDir);
dir.mkdirs();
try {
chunk.transferTo(new File(dir, String.valueOf(chunkNumber)));
} catch (IOException e) {
throw new UploadException("分片上传失败");
}
return ResponseEntity.ok().build();
}
@PostMapping("/merge")
public ResponseEntity<String> mergeChunks(
@RequestParam("identifier") String identifier,
@RequestParam("filename") String filename,
@RequestParam("totalChunks") int totalChunks
) {
String chunkDir = "/tmp/chunks/" + identifier;
String mergedFile = "/tmp/merged/" + filename;
try (FileOutputStream fos = new FileOutputStream(mergedFile)) {
for (int i = 1; i <= totalChunks; i++) {
File chunk = new File(chunkDir, String.valueOf(i));
Files.copy(chunk.toPath(), fos);
chunk.delete(); // 清理分片
}
// 上传到对象存储
String url = minioStorageService.upload(new FileSystemResource(mergedFile), "uploads");
// 清理临时文件
new File(mergedFile).delete();
new File(chunkDir).delete();
return ResponseEntity.ok(url);
} catch (IOException e) {
throw new UploadException("文件合并失败");
}
}
}
4.2 图片裁剪与水印
@Service
public class ImageProcessService {
public byte[] resize(InputStream input, int width, int height) {
try {
BufferedImage original = ImageIO.read(input);
BufferedImage resized = Thumbnails.of(original)
.size(width, height)
.crop(Positions.CENTER)
.outputQuality(0.85)
.asBufferedImage();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(resized, "jpg", baos);
return baos.toByteArray();
} catch (IOException e) {
throw new ImageProcessException("图片处理失败");
}
}
public byte[] addWatermark(InputStream input, String watermarkText) {
try {
BufferedImage image = ImageIO.read(input);
Graphics2D g = image.createGraphics();
g.setColor(new Color(255, 255, 255, 128));
g.setFont(new Font("Arial", Font.BOLD, 36));
g.rotate(Math.toRadians(-30), image.getWidth() / 2, image.getHeight() / 2);
g.drawString(watermarkText, image.getWidth() / 4, image.getHeight() / 2);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
return baos.toByteArray();
} catch (IOException e) {
throw new ImageProcessException("水印添加失败");
}
}
}
五、安全策略
5.1 文件类型白名单
@Component
public class FileValidator {
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
private static final Map<String, byte[]> MAGIC_NUMBERS = Map.of(
"image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
"image/png", new byte[]{0x89, 0x50, 0x4E, 0x47},
"image/gif", new byte[]{0x47, 0x49, 0x46, 0x38},
"application/pdf", new byte[]{0x25, 0x50, 0x44, 0x46}
);
public boolean validate(MultipartFile file) {
String contentType = file.getContentType();
if (!ALLOWED_TYPES.contains(contentType)) {
return false;
}
// 魔数校验,防止伪装扩展名
try {
byte[] header = file.getBytes(0, 4);
byte[] expected = MAGIC_NUMBERS.get(contentType);
if (expected != null) {
for (int i = 0; i < expected.length; i++) {
if (header[i] != expected[i]) return false;
}
}
} catch (IOException e) {
return false;
}
return true;
}
}
5.2 防盗链与访问控制
@Service
public class SecureUrlService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 生成一次性下载链接
*/
public String generateOneTimeUrl(String objectName, int ttlSeconds) {
String token = UUID.randomUUID().toString();
String key = "download:" + token;
redisTemplate.opsForValue().set(key, objectName, ttlSeconds, TimeUnit.SECONDS);
return "/api/download?token=" + token;
}
public InputStream downloadByToken(String token) {
String key = "download:" + token;
String objectName = redisTemplate.opsForValue().get(key);
if (objectName == null) {
throw new SecurityException("下载链接已过期或无效");
}
// 删除 token,确保一次性
redisTemplate.delete(key);
return minioStorageService.download(objectName);
}
}
六、性能优化
6.1 CDN 回源策略
用户请求 → CDN(缓存命中直接返回)
↓ 未命中
回源到 OSS/MinIO
↓
返回 + CDN 缓存
6.2 图片自适应
@Service
public class AdaptiveImageService {
public String getOptimizedUrl(String originalUrl, int viewportWidth) {
// 根据视口宽度返回不同尺寸
String size;
if (viewportWidth <= 320) size = "@!small";
else if (viewportWidth <= 768) size = "@!medium";
else if (viewportWidth <= 1200) size = "@!large";
else size = "";
return originalUrl + size;
}
}
七、总结
| 场景 | 推荐方案 | 关键配置 |
|---|---|---|
| 开发/测试 | MinIO Docker | 本地部署,S3 API 兼容 |
| 生产环境 | 阿里云 OSS | CDN + 图片处理 + 防盗链 |
| 敏感文件 | 预签名 URL | 限时访问,权限最小化 |
| 大文件上传 | 断点续传 | 分片 + checksum |
| 图片服务 | 自适应裁剪 | 多尺寸 + CDN 缓存 |
对象存储不仅是文件仓库,更是现代应用的核心基础设施。合理选择存储方案、实施安全策略、优化访问性能,是构建可靠文件服务的关键。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。