Java + MinIO:将小说章节压缩合并为单文件,提升存储效率
文章目录
起因
在将练手小项目从Spring Boot2 升级到 Spring Boot3的时候发现在外网打开minio非常慢,之前每个章节一个txt文件存储在mino中,不知不觉章节文件数量已经达到了63W+
每个文件平均大小均在10K左右,NAS的随机读写太慢了,所以趁代码重构的机会将整本小说所有章节合并成Minio的一个文件,大大减少碎片文件的数量
方案
- 将每个章节通过Zstd压缩
- 计算每个章节压缩后的字节,生成整本小说压缩后的MetaIndex信息
- 将压缩后的所有字节合并成一个文件存储在MinIO中
- 通过MetaIndex读取部分内容(单章)并解压缩回显
为什么用Zstd而不是使用其他的压缩算法
之前单个章节是通过Snappy进行的压缩,本身也是练手项目,纯粹是想换个别的压缩方案试试
具体代码
MetaIndex信息存储表设计
/**
* 章节内容块
*
* @author p_x_c
*/
@Data
@Accessors(chain = true)
@TableName("content_block")
public class ContentBlock implements Serializable {
@Serial
private static final long serialVersionUID = -2030322987706728700L;
/**
* 自增ID
*/
@TableId(type = IdType.AUTO)
private Long id;
/**
* 数据起始位置
*/
private Integer startPosition;
/**
* 数据长度
*/
private Integer length;
/**
* 书章节ID
*/
private Long bookMenuId;
/**
* 创建时间
*/
private Long createTime = System.currentTimeMillis();
}
原章节表修改
/**
* 书章节
*
* @author p_x_c
*/
@Data
@Accessors(chain = true)
@TableName("book_menu")
public class BookMenu implements Serializable {
@Serial
private static final long serialVersionUID = -782519373131425171L;
/**
* Id基于IdUtils生成64位长整形
*/
@TableId
private Long id;
/**
* 状态(基础状态:-1 异常,0 未审核,1 正常,10 非正文,99 结束。其他状态自行扩充)
*/
private Integer status;
/**
* 创建时间
*/
private Long createTime = System.currentTimeMillis();
/**
* 是否已索引(0 未索引,1 已索引)
*/
private Integer indexed = 0;
/**
* 书ID
*/
private Long bookId;
/**
* 名称
*/
private String name;
/**
* 字数
*/
private Long wordsNumb;
/**
* 爬取来源URL
*/
private String url;
/**
* 内容区块ID
*/
private Long contentBlockId;
}
连载章节更新
BookMenu item = id > 0 ? super.getById(id) : init(bookId, url);
if (Objects.isNull(item)) {
item = init(bookId, url);
}
val contentBlockId = StringUtils.isBlank(content) ? 0 : contentBlockService.appendContent(item.getId(), bookId, content);
item.setName(name)
.setWordsNumb((long) length)
.setStatus(status)
.setContentBlockId(contentBlockId);
super.saveOrUpdate(item);
return item.getId();
@Override
@SneakyThrows
public long appendContent(long id, long bookId, String content) {
val toAppend = ZstdUtils.compress(content);
val objectName = getObjectName(bookId);
var originalBytes = repository.exist(BUCKET, objectName) ? IOUtils.toByteArray(repository.getObject(BUCKET, objectName)) : SerializationUtils.EMPTY_ARRAY;
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byteArrayOutputStream.write(originalBytes);
byteArrayOutputStream.write(toAppend);
repository.saveObject(BUCKET, objectName, new ByteArrayInputStream(byteArrayOutputStream.toByteArray()));
var item = new ContentBlock()
.setStartPosition((int) (originalBytes.length))
.setLength(toAppend.length)
.setBookMenuId(id)
.setCreateTime(System.currentTimeMillis());
super.save(item);
return item.getId();
}
读取单章内容
/**
* 从minio获取章节内容
*
* @param bookId 作品ID
* @param offset 起始位置
* @param length 长度
* @return 章节内容
*/
@SneakyThrows
private String getContent(long bookId, Integer offset, Integer length) {
if (Objects.isNull(offset) || Objects.isNull(length)) {
return "";
}
val objectName = getObjectName(bookId);
val bytes = IOUtils.toByteArray(repository.getObjectByPart(BUCKET, objectName, length, (long) offset));
return ZstdUtils.decompress(bytes);
}
/**
* 获取对象部分内容
*
* @param bucketName
* @param objectName
* @param length 长度
* @param offset 偏移量
* @return
*/
@SneakyThrows
public InputStream getObjectByPart(String bucketName, String objectName, long length, Long offset) {
return minioConnectionFactory.getConnection()
.getObject(GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.length(length)
.offset(offset)
.build());
}
如此存储的优点
通过contentBlockId
和content_block
表关联,章节有更新时候只需要在MinIO的文件尾部append新章节的压缩后内容且content_block
表增加一条新数据即可,无须重新刷新所有MetaIndex信息
读取单章内容时候无须拉取整个小说文件,只需通过MinIO的接口读取部分数据即可
缺点
实际依然是每章压缩,压缩比并不高
MinIO这类对象存储不支持append每次更新需要拿出整个文件append后再重新覆盖回去(适合读多写少的场景)
效果
原63w文件合并后只有3000左右,并且通过调整Zstd压缩比,整个磁盘占用从3.8G下降到了2.6G左右
文章作者 pengxiaochao
上次更新 2025-03-18
许可协议 不允许任何形式转载。