在数字化办公日益普及的今天,我们每天都会产生大量的电子文件。从工作文档到个人照片,从项目资料到学习笔记,这些数据的安全存储和便捷访问成为刚需。虽然市面上有各种公有云盘服务,但企业用户常常面临以下痛点:
基于这些痛点,我们决定开发一个私有化部署的云存储系统。这个系统将具备以下核心价值:
在技术选型上,我们采用了当前Java生态中最成熟稳定的技术组合:
后端框架:Spring Boot 2.7+
安全框架:Spring Security + JWT
持久层:Spring Data JPA + MySQL
缓存:Redis
文件存储:本地文件系统(可扩展至OSS)
消息队列:RabbitMQ
搜索:Elasticsearch(可选)
前端:Vue.js + Element Plus
技术选型心得:在核心框架的选择上,我们倾向于使用经过大规模生产验证的成熟技术,避免追求新潮而引入不稳定因素。同时保持架构的扩展性,为未来可能的功能扩展预留接口。
系统采用经典的分层架构设计,各层职责明确:
code复制表示层(前端)
├── Vue.js SPA应用
└── 文件上传组件(支持分片上传、拖拽上传)
应用层(Spring Boot)
├── Controller:RESTful API接口
│ ├── 认证控制器(/api/auth)
│ ├── 文件控制器(/api/file)
│ └── 用户控制器(/api/user)
├── Service:业务逻辑实现
│ ├── 文件服务(上传、下载、分享等)
│ ├── 用户服务(注册、登录、配额等)
│ └── 分享服务(生成、验证分享链接)
└── Repository:数据访问
├── JPA接口
└── 自定义查询
基础设施层
├── MySQL:存储用户和文件元数据
├── Redis:缓存热点数据
└── 文件存储:本地磁盘/OSS
这种分层设计带来了以下优势:
sql复制CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL COMMENT '用户名',
`password` varchar(255) NOT NULL COMMENT '密码(BCrypt加密)',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`avatar` varchar(500) DEFAULT NULL COMMENT '头像URL',
`total_capacity` bigint(20) DEFAULT 10737418240 COMMENT '总容量(10GB)',
`used_capacity` bigint(20) DEFAULT 0 COMMENT '已用容量',
`status` tinyint(4) DEFAULT 1 COMMENT '状态:0-禁用,1-正常',
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点:
sql复制CREATE TABLE `file` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL COMMENT '所属用户ID',
`parent_id` bigint(20) DEFAULT 0 COMMENT '父目录ID(0表示根目录)',
`file_name` varchar(500) NOT NULL COMMENT '显示文件名',
`file_path` varchar(1000) NOT NULL COMMENT '物理存储路径',
`file_size` bigint(20) DEFAULT 0 COMMENT '文件大小(字节)',
`file_type` varchar(50) DEFAULT NULL COMMENT '文件MIME类型',
`file_hash` varchar(255) DEFAULT NULL COMMENT '文件内容哈希(MD5)',
`is_directory` tinyint(1) DEFAULT 0 COMMENT '是否为目录',
`is_deleted` tinyint(1) DEFAULT 0 COMMENT '是否已删除',
`delete_time` datetime DEFAULT NULL COMMENT '删除时间',
`share_code` varchar(50) DEFAULT NULL COMMENT '分享码(UUID)',
`share_expire_time` datetime DEFAULT NULL COMMENT '分享过期时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_parent` (`user_id`,`parent_id`),
KEY `idx_share_code` (`share_code`),
KEY `idx_file_hash` (`file_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键设计决策:
sql复制CREATE TABLE `file_chunk` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`chunk_md5` varchar(255) NOT NULL COMMENT '分片内容哈希',
`chunk_number` int(11) NOT NULL COMMENT '分片序号(从0开始)',
`chunk_size` bigint(20) NOT NULL COMMENT '分片实际大小',
`total_chunks` int(11) NOT NULL COMMENT '总分片数',
`file_hash` varchar(255) DEFAULT NULL COMMENT '所属文件哈希',
`user_id` bigint(20) NOT NULL COMMENT '所属用户ID',
`status` tinyint(4) DEFAULT 0 COMMENT '状态:0-上传中,1-上传完成',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_chunk_md5` (`chunk_md5`),
KEY `idx_file_hash` (`file_hash`),
KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
分片上传实现要点:
java复制@Component
public class JwtTokenUtil {
// 令牌有效期默认7天(单位:秒)
@Value("${cloud.disk.jwt.expiration}")
private Long expiration;
// 生成包含用户名的令牌
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", userDetails.getUsername()); // JWT标准主题声明
claims.put("created", new Date()); // 令牌生成时间
return Jwts.builder()
.setClaims(claims)
.setExpiration(generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
// 从令牌中解析用户名
public String getUsernameFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
// 验证令牌有效性
public boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsernameFromToken(token);
return (username.equals(userDetails.getUsername())
&& !isTokenExpired(token));
}
// 刷新令牌(延长有效期)
public String refreshToken(String token) {
Claims claims = getClaimsFromToken(token);
claims.put("created", new Date()); // 更新生成时间
return generateToken(claims);
}
private Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
}
安全配置关键点:
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // 禁用CSRF(使用JWT不需要)
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
.and()
.authorizeRequests()
.antMatchers("/api/auth/**").permitAll() // 认证接口开放
.antMatchers("/api/file/share/**").permitAll() // 分享链接开放
.anyRequest().authenticated(); // 其他接口需要认证
// 添加JWT过滤器
http.addFilterBefore(
jwtAuthenticationTokenFilter(),
UsernamePasswordAuthenticationFilter.class);
}
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
return new JwtAuthenticationTokenFilter();
}
}
安全实践建议:
- 令牌有效期不宜过长,建议7-30天
- 敏感操作(如密码修改)应要求重新认证
- 实现令牌黑名单机制,支持主动注销
- HTTPS加密传输,防止令牌被截获
java复制public interface StorageService {
/**
* 存储完整文件
* @param file 上传的文件
* @param path 存储路径(不含文件名)
* @return 文件存储路径(包含文件名)
*/
String store(MultipartFile file, String path) throws IOException;
/**
* 存储文件分片
* @param chunk 文件分片
* @param chunkMd5 分片内容MD5
* @return 分片存储路径
*/
String storeChunk(MultipartFile chunk, String chunkMd5) throws IOException;
/**
* 合并文件分片
* @param fileHash 文件整体哈希
* @param fileName 原始文件名
* @param path 存储路径
* @param totalChunks 总分片数
* @return 合并是否成功
*/
boolean mergeChunks(String fileHash, String fileName,
String path, Integer totalChunks) throws IOException;
/**
* 加载文件资源
* @param filePath 文件存储路径
* @return 可下载的资源对象
*/
Resource loadAsResource(String filePath) throws FileNotFoundException;
/**
* 获取文件访问URL
* @param filePath 文件存储路径
* @return 可公开访问的URL
*/
String getFileUrl(String filePath);
}
java复制@Service("localStorageService")
public class LocalStorageServiceImpl implements StorageService {
@Value("${cloud.disk.storage.base-path}")
private String basePath; // 基础存储路径
@Override
public String store(MultipartFile file, String path) throws IOException {
// 1. 创建存储目录
Path storagePath = Paths.get(basePath, path);
if (!Files.exists(storagePath)) {
Files.createDirectories(storagePath);
}
// 2. 生成唯一文件名(MD5_原始文件名)
String fileHash = DigestUtils.md5DigestAsHex(file.getInputStream());
String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());
String newFilename = fileHash + "_" + originalFilename;
// 3. 存储文件
Path targetLocation = storagePath.resolve(newFilename);
Files.copy(file.getInputStream(), targetLocation,
StandardCopyOption.REPLACE_EXISTING);
// 4. 返回相对路径
return path + "/" + newFilename;
}
@Override
public boolean mergeChunks(String fileHash, String fileName,
String path, Integer totalChunks) throws IOException {
// 1. 创建目标文件
Path outputPath = Paths.get(basePath, path);
if (!Files.exists(outputPath)) {
Files.createDirectories(outputPath);
}
Path outputFile = outputPath.resolve(fileHash + "_" + fileName);
// 2. 按顺序合并所有分片
try (FileOutputStream fos = new FileOutputStream(outputFile.toFile(), true)) {
for (int i = 0; i < totalChunks; i++) {
Path chunkFile = getChunkPath(fileHash, i);
if (Files.exists(chunkFile)) {
Files.copy(chunkFile, fos);
Files.delete(chunkFile); // 删除已合并的分片
}
}
}
// 3. 验证合并后的文件哈希
String mergedFileHash = DigestUtils.md5DigestAsHex(
new FileInputStream(outputFile.toFile()));
return mergedFileHash.equals(fileHash);
}
private Path getChunkPath(String fileHash, int chunkNumber) {
// 分片按哈希前两位分组存储
String chunkDir = tempPath + "/chunks/" + fileHash.substring(0, 2);
return Paths.get(chunkDir, fileHash + "_" + chunkNumber);
}
}
存储优化技巧:
- 文件命名采用"MD5_原始文件名"形式,既保证唯一性又保留原始文件名信息
- 分片文件按哈希前缀分组存储,避免单个目录文件过多导致的性能问题
- 定期清理临时分片文件,防止磁盘空间被占满
- 大文件合并时使用缓冲流,减少内存消耗
文件选择:用户选择文件后,前端计算文件MD5哈希(使用spark-md5等库)
初始化上传:
javascript复制// 请求后端检查文件是否已存在
POST /api/file/upload/init
{
"fileHash": "a1b2c3d4...",
"fileName": "example.pdf",
"fileSize": 104857600 // 100MB
}
// 响应示例(秒传)
{
"skipUpload": true,
"fileId": 123
}
// 响应示例(需要上传)
{
"skipUpload": false,
"chunkSize": 10485760, // 10MB/分片
"uploadId": "uuid-xxxx"
}
分片上传:
javascript复制// 按照chunkSize将文件切分后顺序上传
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('chunkNumber', i);
formData.append('chunkMd5', calculateChunkMd5(chunk));
await axios.post('/api/file/upload/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
}
合并请求:
javascript复制POST /api/file/upload/merge
{
"fileHash": "a1b2c3d4...",
"fileName": "example.pdf",
"totalChunks": 10
}
java复制@PostMapping("/upload/init")
public FileChunkInitDTO initChunkUpload(@RequestBody FileChunkInitDTO initDTO) {
// 1. 检查是否已存在相同文件
File existingFile = fileRepository.findByUserIdAndFileHashAndIsDeletedFalse(
getCurrentUserId(), initDTO.getFileHash());
if (existingFile != null) {
// 秒传:直接返回文件信息
initDTO.setSkipUpload(true);
initDTO.setFileId(existingFile.getId());
return initDTO;
}
// 2. 检查存储空间
User user = userRepository.findById(getCurrentUserId())
.orElseThrow(() -> new BusinessException("用户不存在"));
if (user.getUsedCapacity() + initDTO.getFileSize() > user.getTotalCapacity()) {
throw new BusinessException("存储空间不足");
}
// 3. 初始化分片上传
initDTO.setChunkSize(chunkSize);
initDTO.setUploadId(UUID.randomUUID().toString());
return initDTO;
}
分片上传注意事项:
- 前端计算文件哈希可能耗时较长,大文件建议使用Web Worker避免界面卡顿
- 分片大小建议设置为5-10MB,过小会增加请求次数,过大可能导致上传失败
- 网络不稳定时实现自动重试机制,提升上传成功率
- 后端需要记录分片上传状态,支持断点续传
java复制@Transactional
public String createShareLink(Long fileId, Integer expireDays, Long userId) {
// 1. 验证文件存在且属于当前用户
File file = fileRepository.findByIdAndUserId(fileId, userId)
.orElseThrow(() -> new BusinessException("文件不存在"));
// 2. 如果已分享且未过期,返回现有分享码
if (file.getShareCode() != null
&& file.getShareExpireTime().after(new Date())) {
return file.getShareCode();
}
// 3. 生成新的分享码(UUID去掉短横线)
String shareCode = UUID.randomUUID().toString().replace("-", "");
// 4. 计算过期时间(默认7天)
if (expireDays == null || expireDays <= 0) {
expireDays = defaultShareExpireDays;
}
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, expireDays);
// 5. 更新文件分享信息
file.setShareCode(shareCode);
file.setShareExpireTime(calendar.getTime());
fileRepository.save(file);
return shareCode;
}
java复制@Component
public class ShareAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1. 获取分享码
String shareCode = request.getParameter("code");
if (StringUtils.isEmpty(shareCode)) {
throw new BusinessException("分享码不能为空");
}
// 2. 查询分享文件
File file = fileRepository.findByShareCode(shareCode);
if (file == null) {
throw new BusinessException("分享链接无效");
}
// 3. 检查是否过期
if (file.getShareExpireTime().before(new Date())) {
throw new BusinessException("分享链接已过期");
}
// 4. 将文件信息存入请求属性
request.setAttribute("sharedFile", file);
return true;
}
}
分享安全建议:
- 分享链接设置有效期,避免永久有效的分享带来安全隐患
- 敏感文件分享需要密码验证
- 记录分享下载日志,便于审计
- 提供分享撤销功能,随时终止分享
基础配置:
软件环境:
打包可执行JAR:
bash复制mvn clean package -DskipTests
启动脚本(带JVM调优参数):
bash复制#!/bin/bash
JAVA_OPTS="-server -Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC -XX:MaxGCPauseMillis=200 \
-XX:ParallelGCThreads=4 -XX:ConcGCThreads=2"
nohup java $JAVA_OPTS -jar cloud-disk.jar > app.log 2>&1 &
配置反向代理(Nginx示例):
nginx复制server {
listen 80;
server_name cloud.example.com;
# 文件上传大小限制
client_max_body_size 10G;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 支持WebSocket
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 静态文件直接由Nginx处理
location /static/ {
alias /data/cloud-disk/static/;
expires 30d;
}
}
索引优化:
连接池配置:
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 20 # 根据并发量调整
minimum-idle: 5
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 30000
JPA优化:
IO性能优化:
存储策略优化:
并发控制:
java复制@RestController
@RequestMapping("/actuator")
public class HealthController {
@Autowired
private DataSource dataSource;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@GetMapping("/health")
public Map<String, Object> healthCheck() {
Map<String, Object> result = new HashMap<>();
// 数据库检查
try (Connection conn = dataSource.getConnection()) {
result.put("database", "UP");
} catch (Exception e) {
result.put("database", "DOWN");
}
// Redis检查
try {
redisConnectionFactory.getConnection().ping();
result.put("redis", "UP");
} catch (Exception e) {
result.put("redis", "DOWN");
}
// 存储空间检查
File store = new File(basePath);
result.put("storage", Map.of(
"total", store.getTotalSpace(),
"free", store.getFreeSpace(),
"usable", store.getUsableSpace()
));
return result;
}
}
java复制@Configuration
public class MetricsConfig {
@Bean
MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags(
"application", "cloud-disk",
"region", System.getProperty("region", "unknown")
);
}
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}
// 在Service方法上添加监控注解
@Timed(value = "file.upload.time", description = "Time taken to upload files")
@Transactional
public File uploadFile(MultipartFile file, Long parentId, Long userId) {
// ...
}
ELK Stack配置:
日志配置文件示例:
xml复制<configuration>
<appender name="JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.json</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.json.gz</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<customFields>{"app":"cloud-disk","env":"${spring.profiles.active}"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</configuration>
可能原因及解决方案:
网络带宽不足:
服务器IO瓶颈:
bash复制# 检查磁盘IO性能
iostat -x 1
# 检查磁盘使用情况
df -h
分片大小不合适:
排查步骤:
检查Nginx配置:
nginx复制client_max_body_size 10G; # 必须大于分片大小
client_body_buffer_size 1M;
检查Spring Boot配置:
yaml复制spring:
servlet:
multipart:
max-file-size: 10GB
max-request-size: 10GB
server:
tomcat:
max-swallow-size: -1 # 取消上传大小限制
检查服务器内存:
bash复制free -h
开启慢查询日志:
sql复制SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; # 超过1秒的记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';
使用mysqldumpslow分析:
bash复制mysqldumpslow -s t /var/log/mysql/mysql-slow.log
常见优化方案:
生成堆转储分析:
bash复制# 获取进程ID
jps -l
# 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>
分析GC日志:
bash复制-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps
常见JVM问题:
API安全:
文件安全:
系统安全:
部门与角色管理:
共享空间:
在线预览增强:
版本控制:
Docker镜像构建:
dockerfile复制FROM openjdk:11-jre
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
Kubernetes部署:
yaml复制apiVersion: apps/v1
kind: Deployment
metadata:
name: cloud-disk
spec:
replicas: 3
selector:
matchLabels:
app: cloud-disk
template:
spec:
containers:
- name: app
image: registry.example.com/cloud-disk:1.0.0
ports:
- containerPort: 8080
resources:
limits:
cpu: "2"
memory: 4Gi
服务划分:
技术栈升级:
基于内容的分类:
自动化工作流:
智能分层存储:
重复文件检测:
在开发私有云存储系统的过程中,我总结了以下架构设计经验: