RocketMQ源码系列(10) — 消息存储之文件过期与恢复机制

760 阅读22分钟

文件过期清理

消息写入 commitlog 文件,每个 commitlog 文件默认 1GB 大小,文件数量会不断增加,不久就会占满系统磁盘,所以需要一个机制来清理过期的 commitlog 文件。DefaultMessageStore 会启动一个后台的线程服务 CleanCommitLogService 来清理过期的文件。

清理文件线程服务

1、CleanCommitLogService

CleanCommitLogService 就是在执行清理过期文件的操作,它第一步是删除过期的文件,第二步是再次删除已经不可用的文件。这里需要记住它的几个属性:

  • diskSpaceWarningLevelRatio:磁盘空间占用比例达到 0.9 的时候警告
  • diskSpaceCleanForciblyRatio:磁盘空间占用比例达到 0.85 的时候强制清理
  • lastRedeleteTimestamp:上一次 Redelete 的时间
  • cleanImmediately:是否立即执行清理
class CleanCommitLogService {
    private final double diskSpaceWarningLevelRatio = Double.parseDouble(System.getProperty("rocketmq.broker.diskSpaceWarningLevelRatio", "0.90"));
    private final double diskSpaceCleanForciblyRatio = Double.parseDouble(System.getProperty("rocketmq.broker.diskSpaceCleanForciblyRatio", "0.85"));
    private long lastRedeleteTimestamp = 0;
    private volatile boolean cleanImmediately = false;

    public void run() {
        // 删除过期的文件
        this.deleteExpiredFiles();
        // 删除已经不可用的文件
        this.redeleteHangedFile();
    }
}

2、删除过期的文件

首先是删除过期文件的时机,如果当前时间是早晨 4-5 点的时候,这个时候一般使用的人比较少,系统比较空闲,或者磁盘空间快满了的时候才去删除过期文件。

调用 CommitLog 删除过期文件时,传入了四个参数:

  • fileReservedTime:表示文件要保留72小时后才会删除

  • deletePhysicFilesInterval: 这个参数表示删除多个 commitlog 文件之间的间隔时间,默认是100毫秒,增加间隔时间主要是避免磁盘IO太频繁,影响程序IO性能。

  • destroyMapedFileIntervalForcibly:要强制销毁 MappedFile 的间隔时间,默认是120秒,这个参数是控制如果第一次 MappedFile 因某些原因没有被清理,那么过了 120秒后就必须强制清理。

  • cleanAtOnce:是否立即清理,默认是开启了强制清理文件的,在判断磁盘剩余空间比例时,如果超过阈值后,就会要求立即清理 cleanImmediately=true,这个时候就不会管是否满足间隔时间 fileReservedTime 了。

private void deleteExpiredFiles() {
    int deleteCount = 0;
    // 文件被删除前保留多少小时,默认 72小时
    long fileReservedTime = getMessageStoreConfig().getFileReservedTime();
    // 删除磁盘中多个 commitlog 文件的间隔时间,100毫秒
    int deletePhysicFilesInterval = getMessageStoreConfig().getDeleteCommitLogFilesInterval();
    // 强制销毁 MappedFile 的间隔时间,120秒
    int destroyMapedFileIntervalForcibly = getMessageStoreConfig().getDestroyMapedFileIntervalForcibly();
    // 早晨 4-5 点删除文件
    boolean timeup = this.isTimeToDelete();
    // 空间快满了,要清理文件
    boolean spacefull = this.isSpaceToDelete();

    if (timeup || spacefull) {
        // 开启了强制清理文件,且要立即清理
        boolean cleanAtOnce = getMessageStoreConfig().isCleanFileForciblyEnable() && this.cleanImmediately;
        // 转换成毫秒
        fileReservedTime *= 60 * 60 * 1000;
        // 删除过期的文件
        deleteCount = commitLog.deleteExpiredFile(fileReservedTime, deletePhysicFilesInterval,
                destroyMapedFileIntervalForcibly, cleanAtOnce);
        if (deleteCount <= 0) {
            log.warn("disk space will be full soon, but delete file failed.");
        }
    }
}

3、删除不可用的MappedFile

这个重新删除不可用的文件,主要是为了解决 MappedFile 之前可能被引用没完全释放成功的一个补偿机制。它会每隔 120 秒执行一次,删除第一个已经不可用的 MappedFile。如果距离 MappedFile 第一次 shutdown 超过 120秒之后,会强制关闭 MappedFile。这块看了后面 MappedFile 的销毁逻辑之后就清楚了。

private void redeleteHangedFile() {
    // 120 秒
    int interval =  geMessageStoreConfig().getRedeleteHangedFileInterval();
    long currentTimestamp = System.currentTimeMillis();
    if ((currentTimestamp - this.lastRedeleteTimestamp) > interval) {
        this.lastRedeleteTimestamp = currentTimestamp;
        // 强制销毁 MappedFile 的间隔时间,120秒
        int destroyMapedFileIntervalForcibly = getMessageStoreConfig().getDestroyMapedFileIntervalForcibly();
        // 删除第一个不可用的文件
        commitLog.retryDeleteFirstFile(destroyMapedFileIntervalForcibly)
    }
}

磁盘剩余空间计算

随着消息不断写入 commitlog 文件,commitlog 文件数量会不断增长,如果没有及时清理,用不了多久就会把磁盘打满,导致程序直接不可用,这就是非常严重的生产事故了。那RocketMQ是如何避免磁盘被打满的情况出现呢,宁可不写入数据,也不能说Broker不可用了。

1、磁盘文件清理时机判断

首先,它设定了几个磁盘使用比例:

  • diskMaxUsedSpaceRatio:磁盘最大使用比例,默认 0.75
  • diskSpaceWarningLevelRatio:磁盘空间使用比例达到 0.9 的时候警告
  • diskSpaceCleanForciblyRatio:磁盘空间使用比例达到 0.85 的时候强制清理

可以看到它会遍历 commitlog 的存储根路径,commitlog 支持存储在多个路径下,不同的路径可能在不同的磁盘分区,所以要分别检查每个路径下的磁盘使用情况。

接着它会计算每个路径下的磁盘分区已使用空间的比例,然后取最小的一个使用比例 minPhysicRatio,如果最小使用比例超过 85%,说明这个路径下的 commitlog 需要强制清理。

如果最小的一个分区使用比例都大于了警告阈值,也就是90%,这时就会直接标记磁盘满了(makeDiskFull),commitlog 就不能继续写入消息了。然后打印一个磁盘快满了的错误日志,并标记立即执行清理(cleanImmediately)。如果 minPhysicRatio 只是大于强制清理阈值 85%,就标记立即执行清理(cleanImmediately)。如果并没有超过阈值,比如已经清理了一些文件后,磁盘有足够的空间,这时就会标记磁盘可用(makeDiskOK)。

而磁盘使用比例超过 75%,就会返回true,表示空间快不够了,可以去删一些文件了。

所以可以看到 isSpaceToDelete() 这个方法,主要就是判断 commitlog 文件所在磁盘分区,已使用的磁盘空间比例是否超过某些阈值,然后做相应的标记处理。使用比例超过 90% 直接标记磁盘不可以,要立即清理;超过 85% 要立即清理,磁盘还是可用的;超过 75% 可以开始清理文件,但不一定立即清理。

private boolean isSpaceToDelete() {
    // 磁盘最大使用比例,默认 0.75
    double ratio = getMessageStoreConfig().getDiskMaxUsedSpaceRatio() / 100.0;
    // 立即清理磁盘
    cleanImmediately = false;

    // commitlog 存储路径
    String commitLogStorePath = DefaultMessageStore.this.getStorePathPhysic();
    // 多个
    String[] storePaths = commitLogStorePath.trim().split(MessageStoreConfig.MULTI_PATH_SPLITTER);
    // 快满了的分区路径
    Set<String> fullStorePath = new HashSet<>();
    // 最小的分区使用比例
    double minPhysicRatio = 100;
    // 最小的分区使用比例
    String minStorePath = null;
    // 遍历每个路径
    for (String storePathPhysic : storePaths) {
        // 计算磁盘分区使用比例,不同路径可能分区不同
        double physicRatio = UtilAll.getDiskPartitionSpaceUsedPercent(storePathPhysic);
        if (minPhysicRatio > physicRatio) {
            minPhysicRatio = physicRatio;
            minStorePath = storePathPhysic;
        }
        // 使用比例大于强制清理的阈值,0.85
        if (physicRatio > diskSpaceCleanForciblyRatio) {
            fullStorePath.add(storePathPhysic);
        }
    }
    DefaultMessageStore.this.commitLog.setFullStorePaths(fullStorePath);
    // 磁盘使用比例 大于 告警阈值 0.9
    if (minPhysicRatio > diskSpaceWarningLevelRatio) {
        // 表示磁盘已经满了,就不能再继续写入消息了
        boolean diskok = DefaultMessageStore.this.runningFlags.getAndMakeDiskFull();
        if (diskok) {
            log.error("physic disk maybe full soon " + minPhysicRatio + ", so mark disk full, storePathPhysic=" + minStorePath);
        }
        // 需要立即清理文件
        cleanImmediately = true;
    }
    // 大于了强制清理的阈值,0.85,需要立即清理文件
    else if (minPhysicRatio > diskSpaceCleanForciblyRatio) {
        cleanImmediately = true;
    } else {
        // 磁盘可用
        boolean diskok = DefaultMessageStore.this.runningFlags.getAndMakeDiskOK();
        if (!diskok) {
            log.info("physic disk space OK " + minPhysicRatio + ", so mark disk ok, storePathPhysic=" + minStorePath);
        }
    }
    if (minPhysicRatio < 0 || minPhysicRatio > ratio) {
        // 打日志,磁盘快满了
        log.info("physic disk maybe full soon, so reclaim space, " + minPhysicRatio + ", storePathPhysic=" + minStorePath);
        return true;
    }
    return false;
}

2、磁盘使用比例计算

再来看计算磁盘使用比例的方法,可以看到就是通过 File 的几个方法计算出来的,首先我们要明确这几个方法的差别。

  • getTotalSpace():返回文件系统上分区或卷的总大小,表示文件系统的总容量 totalSpace。
  • getFreeSpace():返回文件系统的剩余空间大小,表示文件系统当前可用的剩余容量 freeSpace。
  • getUsableSpace():返回文件系统中可用的空间大小,考虑了当前用户的权限限制,表示当前用户可以自由使用的剩余容量 usableSpace。

首先计算已使用的空间 usedSpace = totalSpace - freeSpace,因为 freeSpace 受用户权限限制可能并不准确,但通过 totalSpace - freeSpace 计算出已使用的空间是准确的。

然后计算分区整个可使用的空间 entireSpace = usedSpace + usableSpace,已使用的空间再加上用户确实可以自由使用的空间就能表述出用户使用的整个空间大小。

最后计算磁盘分区使用比例 physicRatio = usedSpace * 100 / entireSpace / 100.0。

public static double getDiskPartitionSpaceUsedPercent(final String path) {
    try {
        File file = new File(path);
        // 文件系统的总空间大小
        long totalSpace = file.getTotalSpace();
        // 总大小 - 剩余空间大小 = 已使用空间大小
        long usedSpace = totalSpace - file.getFreeSpace();
        // 文件系统中可用的空间大小
        long usableSpace = file.getUsableSpace();
        // 整个可使用的空间
        long entireSpace = usedSpace + usableSpace;
        // 计算已使用比例
        long result = usedSpace * 100 / entireSpace;
        return result / 100.0;
    } catch (Exception e) {}
    return -1;
}

引用资源计数器

在看 MappedFile 的资源清理前,先来看引用资源类 ReferenceResource 的设计,MappedFile 继承自 ReferenceResource。MappedFile 就是通过它来管理资源引用,判断是否可以被销毁。

ReferenceResource 提供了 hold() 方法来持有一个引用计数;然后提供了 release() 方法来释放高一个引用计数,当释放后没有资源引用时,就会调用 cleanup() 清理资源,这个是由子类来实现的。

然后提供了 shutdown 方法来关闭资源,shutdown 会先将可用标识 available 设置为 false,表示资源不可用了,并记录了第一次调用 shutdown 的时间。然后调用 release() 释放当前资源,但此时可能还有其它地方引用了资源,可能不会立即清理资源(cleanup())。所以没有释放的资源,后面可以多次调用 shutdown,如果距离第一次关闭的时间超过了一个阈值,就会直接强制释放和清理资源。

public abstract class ReferenceResource {
    // 对象引用计数,初始值为 1,创建时就被引用了,直到销毁
    protected final AtomicLong refCount = new AtomicLong(1);
    // 是否可用标记
    protected volatile boolean available = true;
    // 清理是否结束标记
    protected volatile boolean cleanupOver = false;
    // 第一次关闭时间戳
    private volatile long firstShutdownTimestamp = 0;

    // 挂起资源,引用计数器加1
    public synchronized boolean hold() {
        if (this.isAvailable()) {
            if (this.refCount.getAndIncrement() > 0) {
                return true;
            } else {
                this.refCount.getAndDecrement();
            }
        }
        return false;
    }
    // 资源是否可用
    public boolean isAvailable() {
        return this.available;
    }
    // 关闭资源,设置为不可用,然后释放资源
    public void shutdown(final long intervalForcibly) {
        if (this.available) {
            this.available = false;
            // 首次关闭时间
            this.firstShutdownTimestamp = System.currentTimeMillis();
            // 释放资源,可能存在引用的地方,就不会立即释放
            this.release();
        } else if (this.getRefCount() > 0) {
            // 距离上一次关闭的时间超过了强制关闭的间隔时间,就强制关闭
            if ((System.currentTimeMillis() - this.firstShutdownTimestamp) >= intervalForcibly) {
                this.refCount.set(-1000 - this.getRefCount());
                this.release();
            }
        }
    }
    // 释放资源,引用计数递减,引用计数降为0之后,清理资源
    public void release() {
        // 引用技术递减
        long value = this.refCount.decrementAndGet();
        if (value > 0)
            return;
        // 没有地方引用就清理资源
        synchronized (this) {
            this.cleanupOver = this.cleanup(value);
        }
    }
    // 获取引用计数
    public long getRefCount() {
        return this.refCount.get();
    }
    // 清理资源
    public abstract boolean cleanup(final long currentRef);
    // 是否清理完毕:引用计数降为0 + 清理完毕标识
    public boolean isCleanupOver() {
        return this.refCount.get() <= 0 && this.cleanupOver;
    }
}

清理过期文件

1、删除过期的文件

CommitLog 删除过期文件,就是调用 MappedFileQueue 删除过期的 MappedFile。

MappedFileQueue 在删除过期的 MappedFile 时,会保留最后一个 MappedFile 不动。然后依次遍历前面的 MappedFile,判断距离 MappedFile 最后一次更新时间(lastModified())是否超过了72小时,或者是要求立即清理文件(cleanImmediately)释放磁盘空间的时候,这时就会去销毁 MappedFile(destroy)。

如果 MappedFile 没有过期,或者 MappedFile 被其它地方引用了,则销毁不成功,就不会继续处理后面的 MappedFile 了,因为前面的 MappedFile 都达不到清理的条件,那后面的更不用说了。

如果销毁成功,会限制一次最多销毁 10 个 MappedFile,主要是为了避免一次性删除文件过多,导致磁盘IO过高,机器负载增加。然后每个 MappedFile 清理的间隔是 100 毫秒,同样也是避免避免频繁的删除文件。

最后,再移除 MappedFileQueue 中 mappedFiles 列表中的已销毁的文件。

public int deleteExpiredFileByTime(long expiredTime, // 文件保留 72 小时
                                   int deleteFilesInterval, //多个 MappedFile 清理的间隔时间(休眠),100毫秒
                                   long intervalForcibly, // 强制释放资源的间隔时间,120boolean cleanImmediately // 是否立即清理
) {
    // 拷贝 mappedFiles
    Object[] mfs = this.copyMappedFiles(0);
    // 至少保留最后一个MappedFile
    int mfsLength = mfs.length - 1;
    int deleteCount = 0;
    List<MappedFile> files = new ArrayList<>();
    // 遍历每一个 MappedFile
    for (int i = 0; i < mfsLength; i++) {
        MappedFile mappedFile = (MappedFile) mfs[i];
        // 最近的修改时间 + 过期时间
        long liveMaxTimestamp = mappedFile.getLastModifiedTimestamp() + expiredTime;
        // 超时,或者立即清理
        if (System.currentTimeMillis() >= liveMaxTimestamp || cleanImmediately) {
            // 销毁和释放
            if (mappedFile.destroy(intervalForcibly)) {
                files.add(mappedFile);
                deleteCount++;
                // 一次最多清理10个
                if (files.size() >= DELETE_FILES_BATCH_MAX) {
                    break;
                }
                // 清理间隔时间
                if (deleteFilesInterval > 0 && (i + 1) < mfsLength) {
                    Thread.sleep(deleteFilesInterval);
                }
            } else {
                // 并没有销毁成功
                break;
            }
        } else {
            // 前面的文件没过期,后面的文件就不必判断了
            break;
        }
    }
    // 移除 mappedFiles 中的 MappedFile
    deleteExpiredFile(files);

    return deleteCount;
}

public long getLastModifiedTimestamp() {
    return this.file.lastModified();
}

2、销毁MappedFile

再来看 MappedFile 是如何被销毁的,MappedFile 继承自 ReferenceResource,MappedFile 在任何使用的地方,都会先通过 hold() 持有一个引用计数,使用完了之后就会调用 release() 释放引用计数。销毁的时候则是先调用 shutdown() 来关闭和释放资源,如果完全释放成功并执行了 cleanup() 就会关闭文件通道 FileChannel,以及删除磁盘文件。

public boolean destroy(final long intervalForcibly) {
    // 引用资源关闭和释放,MappedFile 清理
    this.shutdown(intervalForcibly);
    // 引用计数降为0
    if (this.isCleanupOver()) {
        // 关闭 MappedFile 绑定的文件通道
        this.fileChannel.close();
        // 删除文件
        this.file.delete();

        return true;
    }
    return false;
}

再来看 MappedFile 资源清理的实现,核心就是在清理内存映射缓冲区 MappedByteBuffer 的资源占用。

public boolean cleanup(final long currentRef) {
    // 资源可用,不能清理
    if (this.isAvailable()) {
        return false;
    }
    // 已经清理完毕
    if (this.isCleanupOver()) {
        return true;
    }
    // 清理 MappedByteBuffer 内存映射区
    clean(this.mappedByteBuffer);

    // MappedFile 占用的总内存扣减
    TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(this.fileSize * (-1));
    // 数量扣减
    TOTAL_MAPPED_FILES.decrementAndGet();
    return true;
}

3、释放堆外缓存

最后来看下内存缓冲映射区是如何清理回收的,因为这里内存映射缓冲区 MappedByteBuffer 是使用的堆外内存 DirectByteBuffer,而 DirectByteBuffer 是包可见范围,而 ByteBuffer 还可以一层套一层拿到视图,所以外部没法直接释放缓冲区,必须释放最原始的那个缓冲区,所以它这里是通过反射一层一层的去执行释放操作。

可以看到清理逻辑 invoke(invoke(viewed(buffer), "cleaner"), "clean") 嵌套了很多层才清理了堆外内存。

首先通过反射从 MappedByteBuffer 找到名为 attachment 或者 viewedBuffer 的方法,执行反射获取到它引用的缓冲区,会一直循环直到拿到最底层的那个原始缓冲区。

拿到原始缓冲区后,再通过反射调用缓冲区的 cleaner 方法得到 Cleaner 对象,之后再反射调用 Cleaner 的 clean 方法才清理完成。

public static void clean(final ByteBuffer buffer) {
    // 必须是堆外内存才会去清理
    if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
        return;

    // 先得到视图buffer,再反射执行 cleaner 方法,再反射执行 clean 方法.
    // DirectByteBuffer -> cleaner(Cleaner) -> clean
    invoke(invoke(viewed(buffer), "cleaner"), "clean");
}
// 对内存映射区域去获取一个视图
private static ByteBuffer viewed(ByteBuffer buffer) {
    String methodName = "viewedBuffer";
    Method[] methods = buffer.getClass().getMethods();
    for (int i = 0; i < methods.length; i++) {
        if (methods[i].getName().equals("attachment")) {
            methodName = "attachment";
            break;
        }
    }
    // 通过反射执行,attachment/viewedBuffer
    ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
    if (viewedBuffer == null)
        return buffer; // 说明获取到最里面的一层了
    else
        return viewed(viewedBuffer); // 继续往底层找
}
// 反射执行 Method
private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
    return AccessController.doPrivileged((PrivilegedAction<Object>) () -> {
        try {
            Method method = method(target, methodName, args);
            method.setAccessible(true);
            return method.invoke(target);
        } catch (Exception e) {}
    });
}
// 反射获取方法 Method
private static Method method(Object target, String methodName, Class<?>[] args) throws NoSuchMethodException {
    try {
        return target.getClass().getMethod(methodName, args);
    } catch (NoSuchMethodException e) {
        return target.getClass().getDeclaredMethod(methodName, args);
    }
}

image.png

总结

一个 commitlog 文件就是1GB,随着消息的写入,commitlog 文件会越来越多,不可能让他无限增加下去,否则会将磁盘打满,导致程序不可用。所以在必要的时候,必须清理掉已经不需要的文件,最后,我们来总结下RocketMQ对过期文件的清理机制。

RocketMQ 会启动一个后台线程服务 CleanCommitLogService 去删除过期的文件,它主要是分两个时间段去清理。一个是凌晨 4-5 点,这个时候系统一般比较空闲,因为删大文件对磁盘IO性能影响是比较大的,所以在系统空闲的时候去清理。另一个在平时,就会判断磁盘已使用空间的比例,如果超过了配置的阈值,这时候就不管系统是否繁忙了,必须要去清理文件,避免把磁盘打满了。而且磁盘使用超过90%后,会直接标记磁盘不可用,之后消息就不可以继续写入了,但可以读消息,直到清理文件释放了磁盘空间。

清理过期的文件时,MappedFileQueue 会从头遍历除最后一个 MappedFile 之外的所有文件,判断文件是否已经超过72小时没做任何更新了,或者是磁盘空间不够了,要求立即清理没有任何引用的文件。

MappedFile 销毁时,如果还有引用的地方,会等120秒,之后就会强制释放资源。清理资源主要就是针对内存映射缓冲区 MappedByteBuffer 去清理堆外内存空间,清理完之后,就会关闭文件通道 FileChannel,然后删除磁盘文件 File。

image.png

文件恢复机制

文件加载

DefaultMessageStore 启动时,会去加载磁盘文件中的数据,commitlog 文件、消费队列文件、索引文件等,并做数据恢复,主要需要恢复的就是相关的一些偏移量位置信息。

public boolean load() {
    // 判断 abort 文件是否存在
    boolean lastExitOK = !this.isTempFileExist();
    // load Commit Log
    result = result && this.commitLog.load();
    // load Consume Queue
    result = result && this.loadConsumeQueue();

    if (result) {
        // 加载存储检查点文件 ~/store/checkpoint
        this.storeCheckpoint = new StoreCheckpoint(StorePathConfigHelper.getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir()));
        // load IndexFile
        this.indexService.load(lastExitOK);
        // 恢复数据
        this.recover(lastExitOK);
        // 加载延迟偏移量文件 delayOffset.json
        result = this.scheduleMessageService.load();
    }
}

1、判断程序是否正常关闭

程序正常下线和非正常下线肯定是不一样的,正常手动关闭程序时,会去执行 commitlog、consumequeue 等文件的 flush 操作,将缓冲区中的数据刷到磁盘上。而非正常情况下线,可能缓冲区中的数据还没有刷到磁盘,那么磁盘中的数据可能就是不完整的,commitlog、consumequeue、indexfile 中的数据可能就对应不上,存在脏数据,所以需要做恢复处理。

那么如何判断程序是否正常下线呢?

在 DefaultMessageStore 初始化完成启动的时候,其它组件都启动完成后,最后会在存储根目录下创建一个 abort 的临时文件,它就是用来判断是否正常下线的机制。

public void start() throws Exception {
    //....
    this.commitLog.start();
    
    // 创建 abort 文件
    this.createTempFile();
    this.shutdown = false;
}
private void createTempFile() throws IOException {
    // ~/store/aobrt
    String fileName = getAbortFile(this.messageStoreConfig.getStorePathRootDir());
    File file = new File(fileName);
    MappedFile.ensureDirOK(file.getParent());
    // 创建abort文件
    file.createNewFile();
}

程序正常关闭下线的时候,会先把其它资源都关闭释放,然后删除 abort 文件。需要注意的是,如果commitlog中的消息没有全部重新投递出去(消费队列和索引),说明不是正常下线,这时会保留 abort 文件,以便于下次恢复数据处理。

public void shutdown() {
    this.indexService.shutdown();
    this.commitLog.shutdown();
    this.reputMessageService.shutdown();
    this.storeCheckpoint.flush();
    this.storeCheckpoint.shutdown();
    this.transientStorePool.destroy();

    // commitlog 的消息已经全部重新投递出去了,才能正常下线
    if (this.runningFlags.isWriteable() && dispatchBehindBytes() == 0) {
        this.deleteFile(getAbortFile(this.messageStoreConfig.getStorePathRootDir()));
    } else {
        log.warn("the store may be wrong, so shutdown abnormally, and keep abort file.");
    }
}

而如果程序是非正常下线的,就不会来调用 shutdown 方法关闭程序,就不会删除 abort 文件。所以下一次程序启动后,在加载数据前,就会判断下根目录下是否存在 abort 文件,如果存在,说明上一次程序是非正常下线的,可能有些数据需要做修复处理。如果没有 abort 文件,说明上一次程序是正常下线的。

private boolean isTempFileExist() {
    String fileName = getAbortFile(this.messageStoreConfig.getStorePathRootDir());
    File file = new File(fileName);
    return file.exists();
}

2、文件加载到内存

程序启动时,如果磁盘上有数据文件,那么需要将其映射为 MappedFile,读取到内存中来。

以 CommitLog 加载为例,它的加载操作实际是调用 MappedFileQueue 的加载。CommitLog 加载就是读取存储目录 ~/store/commitlog 下的所有文件,然后按文件名升序排序,开始依次去创建一个 MappedFile 映射到这个磁盘文件。

然后更新位置信息 wrotePosition、flushedPosition、committedPosition 为文件的总大小,这明显是不正确的,因为一个文件可能并没有写满,所以后面还会有一个 recover 恢复机制来更新这几个位置信息。

public boolean load() {
    // 加载存储路径下的所有 CommitLog 文件
    File dir = new File(this.storePath);
    File[] ls = dir.listFiles();
    if (ls != null) {
        return doLoad(Arrays.asList(ls));
    }
    return true;
}
public boolean doLoad(List<File> files) {
    // 按文件名升序排序
    files.sort(Comparator.comparing(File::getName));
    // 遍历每个文件
    for (File file : files) {
        // 每个文件大小必须等于1G
        if (file.length() != this.mappedFileSize) {
            return false;
        }
        try {
            // 创建 MappedFile 内存映射
            MappedFile mappedFile = new MappedFile(file.getPath(), mappedFileSize);
            // 设置写入位置、刷新位置、提交位置
            mappedFile.setWrotePosition(this.mappedFileSize);
            mappedFile.setFlushedPosition(this.mappedFileSize);
            mappedFile.setCommittedPosition(this.mappedFileSize);
            // 添加
            this.mappedFiles.add(mappedFile);
        } catch (IOException e) {}
    }
    return true;
}

存储检查点

存储检查点 StoreCheckpoint 就是为了数据恢复而存在的,它存储了 commitlog、consumequeu 等文件最后 flush 刷盘的时间等信息,并保证数据持久化到磁盘文件 checkpoint,然后就可以用存储检查点的信息来恢复数据。

StoreCheckpoint 关联到磁盘文件 ~/store/checkpoint,它只存了三个8字节的数据:

  • physicMsgTimestamp:物理消息时间戳,针对 CommitLog
  • logicsMsgTimestamp:逻辑消息时间戳,针对 ComsumeQueue
  • indexMsgTimestamp:索引消息时间戳,针对 IndexFile
public class StoreCheckpoint {
    private static final InternalLogger log = InternalLoggerFactory.getLogger(LoggerName.STORE_LOGGER_NAME);
    // NIO 文件通道
    private final FileChannel fileChannel;
    // 内存映射区域
    private final MappedByteBuffer mappedByteBuffer;
    // 物理消息时间 8字节
    private volatile long physicMsgTimestamp = 0;
    // 逻辑消息时间 8字节
    private volatile long logicsMsgTimestamp = 0;
    // 索引消息时间 8字节
    private volatile long indexMsgTimestamp = 0;
}

这几个时间的更新节点如下图所示:

  • CommitLog 同步或者异步刷盘后,会更新 physicMsgTimestamp 为当前最后一条消息的存储时间
  • CommitLog 消息重投递后,会向 ConsumeQueue 写入位置信息,这时会更新 logicsMsgTimestamp 为当前写入消息的存储时间
  • CommitLog 消息重投递后,会向 IndexFile 写入偏移量信息,这时会更新 indexMsgTimestamp 为最后一条消息的存储时间。

可以看出,physicMsgTimestamp 之前的消息是一定刷到磁盘中了的,而 logicsMsgTimestampindexMsgTimestamp 则不一定,这两个是在写入数据后就更新时间,而不是刷盘之后,所以其实最终数据还是要以 commitlog 中的数据为准。

image.png

文件恢复

1、索引文件加载

索引文件的加载机制比较简单粗暴,它首先遍历路径下的索引文件,然后创建加载对应的 IndexFile 文件。

IndexFile 的头部存了这个文件最后写入的消息的存储时间 endTimestamp,在非正常退出的情况下,如果这个时间大于存储检查点中的索引时间 indexMsgTimestamp,说明这个文件中的数据超前了,数据可能存在不正确的情况。所以它就直接销毁了这个索引文件,不过不用担心,恢复 CommitLog 数据的时候会重新投递消息出来,就会重新构建索引。

public boolean load(final boolean lastExitOK) {
    File dir = new File(this.storePath);
    File[] files = dir.listFiles();
    if (files != null) {
        Arrays.sort(files);
        for (File file : files) {
            // 创建并加载索引文件
            IndexFile f = new IndexFile(file.getPath(), this.hashSlotNum, this.indexNum, 0, 0);
            f.load(); 
            // 非正常退出时,请求头里的时间比存储检查点里的时间还大,直接销毁文件
            if (!lastExitOK) {
                if (f.getEndTimestamp() > getStoreCheckpoint().getIndexMsgTimestamp()) {
                    f.destroy(0);
                    continue;
                }
            }
            this.indexFileList.add(f);
        }
    }
    return true;
}

2、消费队列恢复

恢复数据时,会先恢复消费队列的数据,再恢复CommitLog的数据,最后恢复 topicQueueTable 表里的数据,就是每个主题队列下的下标。

private void recover(final boolean lastExitOK) {
    // 恢复完消费队列后,返回这最大的物理偏移量
    long maxPhyOffsetOfConsumeQueue = this.recoverConsumeQueue();
    if (lastExitOK) {
        // 正常退出恢复
        this.commitLog.recoverNormally(maxPhyOffsetOfConsumeQueue);
    } else {
        // 异常退出恢复
        this.commitLog.recoverAbnormally(maxPhyOffsetOfConsumeQueue);
    }
    // 恢复 topicQueueTable
    this.recoverTopicQueueTable();
}

消费队列恢复,就是在遍历 consumeQueueTable 表中的每一个 ConsumeQueue,来做恢复操作。

恢复操作只针对最后三个文件,前面的会认为是已经完全持久化到磁盘的,不需要恢复,只加载就行了。

消费队列的恢复,其实就是按一个存储单元(20字节)在读取这个文件的数据,如果能完整把这个文件读完,说明这个文件是整个刷到磁盘了的。如果不能完整读出一条数据,说明这个文件未写满,或者是这个单元的数据只刷了一部分到磁盘,这个时候就可结束读数据了。

通过不断的读数据,直到读到最后一条完整的消息,那么这个位置就代表了文件是刷盘到这个位置了的,这时就可以去更新 flushedWherecommittedWhere 两个信息。

public void recover() {
    final List<MappedFile> mappedFiles = this.mappedFileQueue.getMappedFiles();
    if (!mappedFiles.isEmpty()) {
        // 定位到倒数第三个 MappedFile
        int index = mappedFiles.size() - 3;
        if (index < 0) index = 0;

        int mappedFileSizeLogics = this.mappedFileSize;
        MappedFile mappedFile = mappedFiles.get(index);
        ByteBuffer byteBuffer = mappedFile.sliceByteBuffer();
        // 已处理的物理偏移量
        long processOffset = mappedFile.getFileFromOffset();
        // 文件相对偏移量
        long mappedFileOffset = 0;
        while (true) {
            // 遍历每一个存储单元
            for (int i = 0; i < mappedFileSizeLogics; i += CQ_STORE_UNIT_SIZE) {
                // 读取一个存储单元的数据
                long offset = byteBuffer.getLong();
                int size = byteBuffer.getInt();
                long tagsCode = byteBuffer.getLong();
                // 这条消息是完整的
                if (offset >= 0 && size > 0) {
                    // 文件偏移量
                    mappedFileOffset = i + CQ_STORE_UNIT_SIZE;
                    // 这个队列下的最大物理偏移量
                    this.maxPhysicOffset = offset + size;
                } else {
                    break; // 后面已经读不到消息了
                }
            }
            // 遍历到文件末尾,说名这个文件是正常的,
            if (mappedFileOffset == mappedFileSizeLogics) {
                index++;
                if (index >= mappedFiles.size()) {
                    break;
                } else {
                    // 继续恢复下一个文件
                    mappedFile = mappedFiles.get(index);
                    byteBuffer = mappedFile.sliceByteBuffer();
                    processOffset = mappedFile.getFileFromOffset();
                    mappedFileOffset = 0;
                }
            } else {break;}
        }
        // 更新物理偏移量
        processOffset += mappedFileOffset;
        this.mappedFileQueue.setFlushedWhere(processOffset);
        this.mappedFileQueue.setCommittedWhere(processOffset);
        // 删除脏文件
        this.mappedFileQueue.truncateDirtyFiles(processOffset);
    }
}

最后还有一个脏文件的处理,就是根据前面计算出来的物理偏移量,找到这个偏移量所在的 MappedFile,然后计算更新 wrotePosition、committedPosition、flushedPosition 三个信息,这三个值在加载的时候默认是文件的大小,这里恢复的时候就会去更新。

而其它不属于这个偏移量的 MappedFile,就直接销毁,被当做过期文件删掉。

public void truncateDirtyFiles(long offset) {
    List<MappedFile> willRemoveFiles = new ArrayList<MappedFile>();
    for (MappedFile file : this.mappedFiles) {
        // 文件尾部
        long fileTailOffset = file.getFileFromOffset() + this.mappedFileSize;
        if (fileTailOffset > offset) {
            // 偏移量在这个文件中,更新 wrotePosition
            if (offset >= file.getFileFromOffset()) {
                // 相对位置
                file.setWrotePosition((int) (offset % this.mappedFileSize));
                file.setCommittedPosition((int) (offset % this.mappedFileSize));
                file.setFlushedPosition((int) (offset % this.mappedFileSize));
            } else {
                file.destroy(1000);
                willRemoveFiles.add(file);
            }
        }
    }
    // 删除脏文件
    this.deleteExpiredFile(willRemoveFiles);
}

3、CommitLog 恢复

CommitLog 的恢复跟消费队列的恢复机制是类似的,就不再赘述了。

正常恢复时,同样也是遍历最后三个文件,从 MappedFile 不断读取一条完整的消息数据出来,然后根据读到的物理偏移量更新 flushedWhere、committedWhere 位置信息,最后删除前面过期的文件,并更新当前偏移量所在的 MappedFile 中的 wrotePosition、committedPosition、flushedPosition。

非正常恢复时,则是根据存储检查点中 physicMsgTimestamp 和 logicsMsgTimestamp 中最小的一个时间 minTimestamp,从后遍历去找到第一条消息的存储时间小于 minTimestamp 的 MappedFile,也就是说这个时间点之后的 MappedFile 可能存在脏数据。这时同样是去读取一条条完整的消息数据,然后会分发到消费队列和索引。最后根据读到的偏移量更新位置信息。