起因

在将练手小项目Spring Boot2 升级到 Spring Boot3的时候发现在外网打开minio非常慢,之前每个章节一个txt文件存储在mino中,不知不觉章节文件数量已经达到了63W+

每个文件平均大小均在10K左右,NAS的随机读写太慢了,所以趁代码重构的机会将整本小说所有章节合并成Minio的一个文件,大大减少碎片文件的数量

方案

  1. 将每个章节通过Zstd压缩
  2. 计算每个章节压缩后的字节,生成整本小说压缩后的MetaIndex信息
  3. 将压缩后的所有字节合并成一个文件存储在MinIO中
  4. 通过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());
    }

如此存储的优点

通过contentBlockIdcontent_block表关联,章节有更新时候只需要在MinIO的文件尾部append新章节的压缩后内容且content_block表增加一条新数据即可,无须重新刷新所有MetaIndex信息

读取单章内容时候无须拉取整个小说文件,只需通过MinIO的接口读取部分数据即可

缺点

实际依然是每章压缩,压缩比并不高

MinIO这类对象存储不支持append每次更新需要拿出整个文件append后再重新覆盖回去(适合读多写少的场景)

效果

原63w文件合并后只有3000左右,并且通过调整Zstd压缩比,整个磁盘占用从3.8G下降到了2.6G左右