最低水位線(Low-Water Mark)
最低水位線是指在 WAL(Write Ahead Log)預寫日誌這種設計模式中,標記在這個位置之前的日誌可以被丟棄。
問題背景
WAL(Write Ahead Log)預寫日誌維護了對於儲存的每次更新,隨著時間不斷增長,這個日誌檔案會變得無限大。Segmented Log 分割日誌這種設計模式可以讓我們每次只處理一個更小的檔案,但是日誌如果不清理,會無休止增長以至於硬碟被佔滿。
解決方案
最低水位線這種設計模式會告訴系統哪一部分的日誌可以被刪除了,即在最低水位線之前的所有日誌可以被清理掉。一般的方式是,程式內有一個執行緒執行一個定時任務,不斷地檢查哪一部分的日誌可以被清理並且刪除這些日誌檔案。
this.logCleaner = newLogCleaner(config);
this.logCleaner.startup();
這裡的 LogCleaner 可以用定時任務實現:
public void startup() {
scheduleLogCleaning();
}
private void scheduleLogCleaning() {
singleThreadedExecutor.schedule(() -> {
cleanLogs();
}, config.getCleanTaskIntervalMs(), TimeUnit.MILLISECONDS);
}
基於快照的最低水位線實現以及示例
大部分的分散式一致性系統(例如 Zookeeper(ZAB 簡化 paxos協議),etcd(raft協議)),都實現了快照機制。在這種機制下,他們的儲存引擎會定時的進行全量快照,並且記錄下快照對應的日誌位置,將這個位置作為最低水位線。
//進行快照
public SnapShot takeSnapshot() {
//獲取最近的日誌id
Long snapShotTakenAtLogIndex = wal.getLastLogEntryId();
//利用這個日誌 id 作為標識,生成快照
return new SnapShot(serializeState(kv), snapShotTakenAtLogIndex);
}
當生成了快照併成功儲存到了磁碟上,對應的最低水位線將用來清理老的日誌:
//根據位置獲取這個位置之前的所有日誌檔案
List<WALSegment> getSegmentsBefore(Long snapshotIndex) {
List<WALSegment> markedForDeletion = new ArrayList<>();
List<WALSegment> sortedSavedSegments = wal.sortedSavedSegments;
for (WALSegment sortedSavedSegment : sortedSavedSegments) {
//如果這個日誌檔案的最新log id 小於快照位置,證明可以被清理掉
if (sortedSavedSegment.getLastLogEntryId() < snapshotIndex) {
markedForDeletion.add(sortedSavedSegment);
}
}
return markedForDeletion;
}
zookeeper 中的最低水位線實現
定時任務位於DatadirCleanupManager
的start
方法:
public void start() {
//只啟動一次
if (PurgeTaskStatus.STARTED == purgeTaskStatus) {
LOG.warn("Purge task is already running.");
return;
}
//檢查定時間隔有效性
if (purgeInterval <= 0) {
LOG.info("Purge task is not scheduled.");
return;
}
//啟動定時任務
timer = new Timer("PurgeTask", true);
TimerTask task = new PurgeTask(dataLogDir, snapDir,snapRetainCount);
timer.scheduleAtFixedRate(task, 0, TimeUnit.HOURS.toMillis(purgeInterval));
purgeTaskStatus = PurgeTaskStatus.STARTED;
}
核心方法為PurgeTxnLog
的purge
方法:
public static void purge(File dataDir, File snapDir, int num) throws IOException {
//保留的snapshot數量不能超過3
if (num < 3) {
throw new IllegalArgumentException(COUNT_ERR_MSG);
}
FileTxnSnapLog txnLog = new FileTxnSnapLog(dataDir, snapDir);
//統計檔案數量
List<File> snaps = txnLog.findNValidSnapshots(num);
int numSnaps = snaps.size();
if (numSnaps > 0) {
//利用上一個檔案的日誌偏移,清理log檔案和snapshot檔案
purgeOlderSnapshots(txnLog, snaps.get(numSnaps - 1));
}
}
static void purgeOlderSnapshots(FileTxnSnapLog txnLog, File snapShot) {
//名字包括開頭的zxid,就是代表了日誌位置
final long leastZxidToBeRetain = Util.getZxidFromName(snapShot.getName(), PREFIX_SNAPSHOT);
final Set<File> retainedTxnLogs = new HashSet<File>();
retainedTxnLogs.addAll(Arrays.asList(txnLog.getSnapshotLogs(leastZxidToBeRetain)));
class MyFileFilter implements FileFilter {
private final String prefix;
MyFileFilter(String prefix) {
this.prefix = prefix;
}
public boolean accept(File f) {
if (!f.getName().startsWith(prefix + ".")) {
return false;
}
if (retainedTxnLogs.contains(f)) {
return false;
}
long fZxid = Util.getZxidFromName(f.getName(), prefix);
//根據檔名稱代表的zxid,過濾出要刪除的檔案
return fZxid < leastZxidToBeRetain;
}
}
//篩選出符合條件的 log 檔案和 snapshot 檔案
File[] logs = txnLog.getDataDir().listFiles(new MyFileFilter(PREFIX_LOG));
List<File> files = new ArrayList<>();
if (logs != null) {
files.addAll(Arrays.asList(logs));
}
File[] snapshots = txnLog.getSnapDir().listFiles(new MyFileFilter(PREFIX_SNAPSHOT));
if (snapshots != null) {
files.addAll(Arrays.asList(snapshots));
}
//進行刪除
for (File f : files) {
final String msg = String.format(
"Removing file: %s\t%s",
DateFormat.getDateTimeInstance().format(f.lastModified()),
f.getPath());
LOG.info(msg);
System.out.println(msg);
if (!f.delete()) {
System.err.println("Failed to remove " + f.getPath());
}
}
}
那麼是什麼時候 snapshot 呢?檢視SyncRequestProcessor
的run
方法,這個方法時處理請求,處理請求的時候記錄操作日誌到 log 檔案,同時在有需要進行 snapshot 的時候進行 snapshot:
public void run() {
try {
//避免所有的server都同時進行snapshot
resetSnapshotStats();
lastFlushTime = Time.currentElapsedTime();
while (true) {
//獲取請求程式碼省略
// 請求操作紀錄成功
if (!si.isThrottled() && zks.getZKDatabase().append(si)) {
//是否需要snapshot
if (shouldSnapshot()) {
//重置是否需要snapshot判斷相關的統計
resetSnapshotStats();
//另起新檔案
zks.getZKDatabase().rollLog();
//進行snapshot,先獲取鎖,保證只有一個進行中的snapshot
if (!snapThreadMutex.tryAcquire()) {
LOG.warn("Too busy to snap, skipping");
} else {
//非同步snapshot
new ZooKeeperThread("Snapshot Thread") {
public void run() {
try {
zks.takeSnapshot();
} catch (Exception e) {
LOG.warn("Unexpected exception", e);
} finally {
//釋放鎖
snapThreadMutex.release();
}
}
}.start();
}
}
}
//省略其他
}
} catch (Throwable t) {
handleException(this.getName(), t);
}
}
resetSnapshotStats()
設定隨機起始位,避免叢集內所有例項同時進行 snapshot:
private void resetSnapshotStats() {
//生成隨機roll,snapCount(預設100000)
randRoll = ThreadLocalRandom.current().nextInt(snapCount / 2);
//生成隨機size,snapSizeInBytes(預設4GB)
randSize = Math.abs(ThreadLocalRandom.current().nextLong() % (snapSizeInBytes / 2));
}
shouldSnapshot()
根據啟動時設定的隨機起始位以及配置,判斷是否需要 snapshot
private boolean shouldSnapshot() {
//獲取日誌計數
int logCount = zks.getZKDatabase().getTxnCount();
//獲取大小
long logSize = zks.getZKDatabase().getTxnSize();
//當日志個數大於snapCount(預設100000)/2 + 隨機roll,或者日誌大小大於snapSizeInBytes(預設4GB)/2+隨機size
return (logCount > (snapCount / 2 + randRoll))
|| (snapSizeInBytes > 0 && logSize > (snapSizeInBytes / 2 + randSize));
}
``
基於時間的最低水位線實現與示例
在某些系統中,日誌不是用來更新系統的狀態,可以在一段時間之後刪除,並且不用考慮任何子系統這個最低水位線之前的是否可以刪除。例如,kafka 預設保留 7 天的 log,RocketMQ 預設保留 3 天的 commit log。
RocketMQ中最低水位線實現
在 DefaultMeesageStore
的addScheduleTask()
方法中,定義了清理的定時任務:
private void addScheduleTask() {
this.scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
DefaultMessageStore.this.cleanFilesPeriodically();
}
}, 1000 * 60, this.messageStoreConfig.getCleanResourceInterval(), TimeUnit.MILLISECONDS);
//忽略其他定時任務
}
private void cleanFilesPeriodically() {
//清理訊息儲存檔案
this.cleanCommitLogService.run();
//清理消費佇列檔案
this.cleanConsumeQueueService.run();
}
我們這裡只關心清理訊息儲存檔案,即DefaultMessageStore
的deleteExpiredFiles
方法:
private void deleteExpiredFiles() {
int deleteCount = 0;
//檔案保留時間,就是檔案最後一次更新時間到現在的時間間隔,如果超過了這個時間間隔,就認為可以被清理掉了
long fileReservedTime = DefaultMessageStore.this.getMessageStoreConfig().getFileReservedTime();
//刪除檔案的間隔,每次清理可能不止刪除一個檔案,這個配置指定兩個檔案刪除之間的最小間隔
int deletePhysicFilesInterval = DefaultMessageStore.this.getMessageStoreConfig().getDeleteCommitLogFilesInterval();
//清理檔案時,可能檔案被其他執行緒佔用,例如讀取訊息,這時不能輕易刪除
//在第一次觸發時,記錄一個當前時間戳,當與當前時間間隔超過這個配置之後,強制刪除
int destroyMapedFileIntervalForcibly = DefaultMessageStore.this.getMessageStoreConfig().getDestroyMapedFileIntervalForcibly();
//判斷是否要刪除的時間到了
boolean timeup = this.isTimeToDelete();
//判斷磁碟空間是否還充足
boolean spacefull = this.isSpaceToDelete();
//是否是手工觸發
boolean manualDelete = this.manualDeleteFileSeveralTimes > 0;
//滿足其一,就執行清理
if (timeup || spacefull || manualDelete) {
if (manualDelete)
this.manualDeleteFileSeveralTimes--;
boolean cleanAtOnce = DefaultMessageStore.this.getMessageStoreConfig().isCleanFileForciblyEnable() && this.cleanImmediately;
fileReservedTime *= 60 * 60 * 1000;
//清理檔案
deleteCount = DefaultMessageStore.this.commitLog.deleteExpiredFile(fileReservedTime, deletePhysicFilesInterval,
destroyMapedFileIntervalForcibly, cleanAtOnce);
if (deleteCount > 0) {
} else if (spacefull) {
log.warn("disk space will be full soon, but delete file failed.");
}
}
}
清理檔案的程式碼MappedFile
的deleteExpiredFileByTime
方法:
public int deleteExpiredFileByTime(final long expiredTime,
final int deleteFilesInterval,
final long intervalForcibly,
final boolean cleanImmediately) {
Object[] mfs = this.copyMappedFiles(0);
if (null == mfs)
return 0;
//刨除最新的那個檔案
int mfsLength = mfs.length - 1;
int deleteCount = 0;
List<MappedFile> files = new ArrayList<MappedFile>();
if (null != mfs) {
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++;
if (files.size() >= DELETE_FILES_BATCH_MAX) {
break;
}
//如果配置了刪除檔案時間間隔,則需要等待
if (deleteFilesInterval > 0 && (i + 1) < mfsLength) {
try {
Thread.sleep(deleteFilesInterval);
} catch (InterruptedException e) {
}
}
} else {
break;
}
} else {
//avoid deleting files in the middle
break;
}
}
}
//從檔案列表裡面裡將本次刪除的檔案剔除
deleteExpiredFile(files);
return deleteCount;
}
每日一刷,輕鬆提升技術,斬獲各種offer: