[ISSUE #9735] Add TimerWheel snapshot mechanism for reliable recovery (#9736)

* feat(store): add timer wheel snapshot mechanism for recovery

- Add snapshot backup and recovery functionality to TimerWheel
- Add configuration options for snapshot control with backward compatibility
- Implement atomic flush operations with synchronization locks
- Add snapshot file management (creation, cleanup, selection)
- Support recovery from snapshot files to avoid rebuilding TimerWheel
- Add TimerFlushService.flush() method for manual flush operations

This addresses the issue where discrete TimerWheel state cannot be
recovered from TimerLog alone, ensuring data consistency and
improving recovery performance.

Configuration changes:
- timerWheelSnapshotFlush: enable/disable snapshot functionality (default: false)
- timerWheelDefaultFlush: enable/disable default flush behavior (default: true)
- timerWheelSnapshotIntervalMs: snapshot creation interval (default: 10s)

Change-Id: I801ce7ca6330d02aefcae905e59e1e2b7642ebf5

* fix

Change-Id: I97968a6b87d55c73ce871b3522c67b8b543d6eb2

* fix

Change-Id: Id751e63ddd20ca6223d83d8057623f19e54fa3ca

* fix

Change-Id: I8099c89ebded4f5b66eb98537396afc75b147583

* fix

Change-Id: I1610f1ebba7b05de8517111155f2116025c65e1e

* fix

Change-Id: Iad1171bf7805102f0a87b9623de048fc8bbe9fce

* fix

Change-Id: Ic1ef006f709006a0f30c734cb4426d3aea57eccd

* Acquire the lock only when the timerWheelSnapshotFlush is true

Change-Id: I1c7c2895efebfef0185411c0630bbd2da23ce3c7

* fix

Change-Id: I73e9ecd37f25bda6d6d2f5191bd11ebb8d02c39e

---------

Co-authored-by: guyinyou <guyinyou.gyy@alibaba-inc.com>
Co-authored-by: RongtongJin <jinrongtong16@mails.ucas.ac.cn>
This commit is contained in:
guyinyou
2025-11-10 16:54:30 +08:00
committed by GitHub
parent 607263ba38
commit 29a62c46c9
4 changed files with 268 additions and 45 deletions
@@ -80,6 +80,7 @@ public class MessageStoreConfig {
private boolean timerEnableCheckMetrics = true;
private boolean timerInterceptDelayLevel = false;
private int timerMaxDelaySec = 3600 * 24 * 3;
private boolean timerWheelSnapshotFlush = false;
private boolean timerWheelEnable = true;
/**
@@ -103,6 +104,7 @@ public class MessageStoreConfig {
private int timerMetricSmallThreshold = 1000000;
private int timerProgressLogIntervalMs = 10 * 1000;
private int timerWheelSnapshotIntervalMs = 10 * 1000;
// default, defaultRocksDB
@ImportantField
@@ -1750,6 +1752,14 @@ public class MessageStoreConfig {
return timerWarmEnable;
}
public boolean isTimerWheelSnapshotFlush() {
return timerWheelSnapshotFlush;
}
public void setTimerWheelSnapshotFlush(boolean timerWheelSnapshotFlush) {
this.timerWheelSnapshotFlush = timerWheelSnapshotFlush;
}
public boolean isTimerWheelEnable() {
return timerWheelEnable;
}
@@ -1795,6 +1805,14 @@ public class MessageStoreConfig {
return timerProgressLogIntervalMs;
}
public int getTimerWheelSnapshotIntervalMs() {
return timerWheelSnapshotIntervalMs;
}
public void setTimerWheelSnapshotIntervalMs(int timerWheelSnapshotIntervalMs) {
this.timerWheelSnapshotIntervalMs = timerWheelSnapshotIntervalMs;
}
public void setTimerProgressLogIntervalMs(final int timerProgressLogIntervalMs) {
this.timerProgressLogIntervalMs = timerProgressLogIntervalMs;
}
@@ -19,6 +19,7 @@ package org.apache.rocketmq.store.timer;
import org.apache.rocketmq.common.constant.LoggerName;
import org.apache.rocketmq.logging.org.slf4j.Logger;
import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
import org.apache.rocketmq.store.RunningFlags;
import org.apache.rocketmq.store.logfile.MappedFile;
import org.apache.rocketmq.store.MappedFileQueue;
import org.apache.rocketmq.store.SelectMappedBufferResult;
@@ -45,8 +46,12 @@ public class TimerLog {
private final int fileSize;
public TimerLog(final String storePath, final int fileSize) {
this(storePath, fileSize, null, false);
}
public TimerLog(final String storePath, final int fileSize, RunningFlags runningFlags, boolean writeWithoutMmap) {
this.fileSize = fileSize;
this.mappedFileQueue = new MappedFileQueue(storePath, fileSize, null);
this.mappedFileQueue = new MappedFileQueue(storePath, fileSize, null, runningFlags, writeWithoutMmap);
}
public boolean load() {
@@ -17,6 +17,7 @@
package org.apache.rocketmq.store.timer;
import com.conversantmedia.util.concurrent.DisruptorBlockingQueue;
import io.opentelemetry.api.common.Attributes;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -40,7 +41,6 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import io.opentelemetry.api.common.Attributes;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.rocketmq.common.ServiceThread;
@@ -61,6 +61,7 @@ import org.apache.rocketmq.logging.org.slf4j.LoggerFactory;
import org.apache.rocketmq.store.DefaultMessageStore;
import org.apache.rocketmq.store.MessageStore;
import org.apache.rocketmq.store.PutMessageResult;
import org.apache.rocketmq.store.RunningFlags;
import org.apache.rocketmq.store.SelectMappedBufferResult;
import org.apache.rocketmq.store.config.BrokerRole;
import org.apache.rocketmq.store.config.MessageStoreConfig;
@@ -160,6 +161,8 @@ public class TimerMessageStore {
private final BrokerStatsManager brokerStatsManager;
private Function<MessageExtBrokerInner, PutMessageResult> escapeBridgeHook;
private final Object lockWhenFlush = new Object();
public TimerMessageStore(final MessageStore messageStore, final MessageStoreConfig storeConfig,
TimerCheckpoint timerCheckpoint, TimerMetrics timerMetrics,
final BrokerStatsManager brokerStatsManager) throws IOException {
@@ -172,9 +175,29 @@ public class TimerMessageStore {
// TimerWheel contains the fixed number of slots regardless of precision.
this.slotsTotal = TIMER_WHEEL_TTL_DAY * DAY_SECS;
String timerWheelPath = getTimerWheelPath(storeConfig.getStorePathRootDir());
long snapOffset = -1;
if (storeConfig.isTimerWheelSnapshotFlush()) {
snapOffset = TimerWheel.getMaxSnapshotFlag(timerWheelPath);
if (snapOffset > 0) {
// correct recover offset
timerCheckpoint.setLastTimerLogFlushPos(snapOffset);
LOGGER.info("found timerWheel snapshot offset {}", snapOffset);
} else {
LOGGER.info("not found timerWheel snapshot", snapOffset);
}
}
RunningFlags runningFlags = null;
if (storeConfig.isEnableRunningFlagsInFlush() && messageStore != null) {
runningFlags = messageStore.getRunningFlags();
}
this.timerWheel = new TimerWheel(
getTimerWheelPath(storeConfig.getStorePathRootDir()), this.slotsTotal, precisionMs);
this.timerLog = new TimerLog(getTimerLogPath(storeConfig.getStorePathRootDir()), timerLogFileSize);
timerWheelPath, this.slotsTotal, precisionMs, snapOffset);
this.timerLog = new TimerLog(getTimerLogPath(storeConfig.getStorePathRootDir()), timerLogFileSize,
runningFlags, storeConfig.isWriteWithoutMmap());
this.timerMetrics = timerMetrics;
this.timerCheckpoint = timerCheckpoint;
this.lastBrokerRole = storeConfig.getBrokerRole();
@@ -617,7 +640,6 @@ public class TimerMessageStore {
}
}
protected void maybeMoveWriteTime() {
if (currWriteTimeMs < formatTimeMs(System.currentTimeMillis())) {
currWriteTimeMs = formatTimeMs(System.currentTimeMillis());
@@ -959,7 +981,7 @@ public class TimerMessageStore {
LOGGER.info("Not Running dequeue, skip checkDequeueLatch for delayedTime:{}", delayedTime);
break;
}
if (dequeuePutQueue.size() > 0
|| !checkStateForGetMessages(AbstractStateService.WAITING)
|| !checkStateForPutMessages(AbstractStateService.WAITING)) {
@@ -1478,7 +1500,13 @@ public class TimerMessageStore {
CountDownLatch latch = new CountDownLatch(trs.size());
for (TimerRequest req : trs) {
req.setLatch(latch);
this.putMessageToTimerWheel(req);
if (storeConfig.isTimerWheelSnapshotFlush()) {
synchronized (lockWhenFlush) {
this.putMessageToTimerWheel(req);
}
} else {
this.putMessageToTimerWheel(req);
}
}
checkDequeueLatch(latch, -1);
boolean allSuccess = trs.stream().allMatch(TimerRequest::isSucc);
@@ -1790,7 +1818,8 @@ public class TimerMessageStore {
public class TimerFlushService extends ServiceThread {
private final SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm:ss");
@Override public String getServiceName() {
@Override
public String getServiceName() {
String brokerIdentifier = "";
if (TimerMessageStore.this.messageStore instanceof DefaultMessageStore && ((DefaultMessageStore) TimerMessageStore.this.messageStore).getBrokerConfig().isInBrokerContainer()) {
brokerIdentifier = ((DefaultMessageStore) TimerMessageStore.this.messageStore).getBrokerConfig().getIdentifier();
@@ -1805,33 +1834,55 @@ public class TimerMessageStore {
@Override
public void run() {
TimerMessageStore.LOGGER.info(this.getServiceName() + " service start");
long start = System.currentTimeMillis();
while (!this.isStopped()) {
try {
prepareTimerCheckPoint();
timerLog.getMappedFileQueue().flush(0);
timerWheel.flush();
timerCheckpoint.flush();
if (System.currentTimeMillis() - start > storeConfig.getTimerProgressLogIntervalMs()) {
start = System.currentTimeMillis();
long tmpQueueOffset = currQueueOffset;
ConsumeQueueInterface cq = messageStore.getConsumeQueue(TIMER_TOPIC, 0);
long maxOffsetInQueue = cq == null ? 0 : cq.getMaxOffsetInQueue();
TimerMessageStore.LOGGER.info("[{}]Timer progress-check commitRead:[{}] currRead:[{}] currWrite:[{}] readBehind:{} currReadOffset:{} offsetBehind:{} behindMaster:{} " +
"enqPutQueue:{} deqGetQueue:{} deqPutQueue:{} allCongestNum:{} enqExpiredStoreTime:{}",
storeConfig.getBrokerRole(),
format(commitReadTimeMs), format(currReadTimeMs), format(currWriteTimeMs), getDequeueBehind(),
tmpQueueOffset, maxOffsetInQueue - tmpQueueOffset, timerCheckpoint.getMasterTimerQueueOffset() - tmpQueueOffset,
enqueuePutQueue.size(), dequeueGetQueue.size(), dequeuePutQueue.size(), getAllCongestNum(), format(lastEnqueueButExpiredStoreTime));
}
timerMetrics.persist();
waitForRunning(storeConfig.getTimerFlushIntervalMs());
this.flush();
} catch (Throwable e) {
TimerMessageStore.LOGGER.error("Error occurred in " + getServiceName(), e);
}
try {
waitForRunning(storeConfig.getTimerFlushIntervalMs());
} catch (Throwable e) {
// ignore interrupt
}
}
TimerMessageStore.LOGGER.info(this.getServiceName() + " service end");
}
long start = System.currentTimeMillis();
long lastSnapshotTime = System.currentTimeMillis();
public void flush() throws IOException {
if (storeConfig.isTimerWheelSnapshotFlush()) {
synchronized (lockWhenFlush) {
prepareTimerCheckPoint();
timerLog.getMappedFileQueue().flush(0);
if (System.currentTimeMillis() - lastSnapshotTime > storeConfig.getTimerWheelSnapshotIntervalMs()) {
lastSnapshotTime = System.currentTimeMillis();
timerWheel.backup(timerLog.getMappedFileQueue().getFlushedWhere());
}
timerCheckpoint.flush();
}
} else {
prepareTimerCheckPoint();
timerLog.getMappedFileQueue().flush(0);
timerWheel.flush();
timerCheckpoint.flush();
}
if (System.currentTimeMillis() - start > storeConfig.getTimerProgressLogIntervalMs()) {
start = System.currentTimeMillis();
long tmpQueueOffset = currQueueOffset;
ConsumeQueueInterface cq = messageStore.getConsumeQueue(TIMER_TOPIC, 0);
long maxOffsetInQueue = cq == null ? 0 : cq.getMaxOffsetInQueue();
TimerMessageStore.LOGGER.info("[{}]Timer progress-check commitRead:[{}] currRead:[{}] currWrite:[{}] readBehind:{} currReadOffset:{} offsetBehind:{} behindMaster:{} " +
"enqPutQueue:{} deqGetQueue:{} deqPutQueue:{} allCongestNum:{} enqExpiredStoreTime:{}",
storeConfig.getBrokerRole(),
format(commitReadTimeMs), format(currReadTimeMs), format(currWriteTimeMs), getDequeueBehind(),
tmpQueueOffset, maxOffsetInQueue - tmpQueueOffset, timerCheckpoint.getMasterTimerQueueOffset() - tmpQueueOffset,
enqueuePutQueue.size(), dequeueGetQueue.size(), dequeuePutQueue.size(), getAllCongestNum(), format(lastEnqueueButExpiredStoreTime));
}
timerMetrics.persist();
}
}
public long getAllCongestNum() {
@@ -2023,4 +2074,8 @@ public class TimerMessageStore {
public static String buildDeleteKey(String realTopic, String uniqueKey) {
return realTopic + "+" + uniqueKey;
}
public TimerFlushService getTimerFlushService() {
return timerFlushService;
}
}
@@ -16,6 +16,12 @@
*/
package org.apache.rocketmq.store.timer;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import org.apache.rocketmq.common.MixAll;
import org.apache.rocketmq.common.UtilAll;
import org.apache.rocketmq.common.constant.LoggerName;
import org.apache.rocketmq.logging.org.slf4j.Logger;
@@ -32,13 +38,12 @@ import java.nio.channels.FileChannel;
public class TimerWheel {
private static final Logger log = LoggerFactory.getLogger(LoggerName.STORE_LOGGER_NAME);
public static final String TIMER_WHEEL_FILE_NAME = "timerwheel";
public static final int BLANK = -1, IGNORE = -2;
public final int slotsTotal;
public final int precisionMs;
private String fileName;
private final RandomAccessFile randomAccessFile;
private final FileChannel fileChannel;
private final MappedByteBuffer mappedByteBuffer;
private final String fileName;
private MappedByteBuffer mappedByteBuffer;
private final ByteBuffer byteBuffer;
private final ThreadLocal<ByteBuffer> localBuffer = new ThreadLocal<ByteBuffer>() {
@Override
@@ -48,34 +53,47 @@ public class TimerWheel {
};
private final int wheelLength;
private long snapOffset;
public TimerWheel(String fileName, int slotsTotal, int precisionMs) throws IOException {
this(fileName, slotsTotal, precisionMs, -1);
}
public TimerWheel(String fileName, int slotsTotal, int precisionMs, long snapOffset) throws IOException {
this.slotsTotal = slotsTotal;
this.precisionMs = precisionMs;
this.fileName = fileName;
this.wheelLength = this.slotsTotal * 2 * Slot.SIZE;
this.snapOffset = snapOffset;
File file = new File(fileName);
String finalFileName = selectSnapshotByFlag(snapOffset);
File file = new File(finalFileName);
UtilAll.ensureDirOK(file.getParent());
RandomAccessFile randomAccessFile = null;
try {
randomAccessFile = new RandomAccessFile(this.fileName, "rw");
randomAccessFile = new RandomAccessFile(finalFileName, "rw");
if (file.exists() && randomAccessFile.length() != 0 &&
randomAccessFile.length() != wheelLength) {
throw new RuntimeException(String.format("Timer wheel length:%d != expected:%s",
randomAccessFile.length(), wheelLength));
}
randomAccessFile.setLength(wheelLength);
fileChannel = randomAccessFile.getChannel();
mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, wheelLength);
assert wheelLength == mappedByteBuffer.remaining();
if (snapOffset < 0) {
mappedByteBuffer = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, wheelLength);
assert wheelLength == mappedByteBuffer.remaining();
}
this.byteBuffer = ByteBuffer.allocateDirect(wheelLength);
this.byteBuffer.put(mappedByteBuffer);
this.byteBuffer.put(Files.readAllBytes(file.toPath()));
} catch (FileNotFoundException e) {
log.error("create file channel " + this.fileName + " Failed. ", e);
log.error("create file channel " + finalFileName + " Failed. ", e);
throw e;
} catch (IOException e) {
log.error("map file " + this.fileName + " Failed. ", e);
log.error("map file " + finalFileName + " Failed. ", e);
throw e;
} finally {
if (randomAccessFile != null) {
randomAccessFile.close();
}
}
}
@@ -96,15 +114,12 @@ public class TimerWheel {
UtilAll.cleanBuffer(this.mappedByteBuffer);
UtilAll.cleanBuffer(this.byteBuffer);
localBuffer.remove();
try {
this.fileChannel.close();
} catch (Throwable t) {
log.error("Shutdown error in timer wheel", t);
}
}
public void flush() {
if (mappedByteBuffer == null) {
return;
}
ByteBuffer bf = localBuffer.get();
bf.position(0);
bf.limit(wheelLength);
@@ -118,6 +133,131 @@ public class TimerWheel {
this.mappedByteBuffer.force();
}
/**
* Perform backup operation.
* <p>
* Select snapshot file based on the provided flag, write current buffer content to a temporary file,
* then rename the temporary file to the formal snapshot file. If rename fails, delete the temporary file.
* Finally clean up expired snapshot files.
*
* @param flushWhere Flag used to select snapshot file.
* @throws IOException If I/O error occurs during backup process.
*/
public void backup(long flushWhere) throws IOException {
// Get current local buffer and position it to the beginning
ByteBuffer bf = localBuffer.get();
bf.position(0);
bf.limit(wheelLength);
// Select snapshot file name based on flag
String fileName = selectSnapshotByFlag(flushWhere);
File bakFile = new File(fileName);
// Create or open temporary file for snapshot, ready for writing
File tmpFile = new File(fileName + ".tmp");
// Delete if exists first
Files.deleteIfExists(tmpFile.toPath());
try (RandomAccessFile randomAccessFile = new RandomAccessFile(tmpFile, "rw")) {
try (FileChannel fileChannel = randomAccessFile.getChannel()) {
fileChannel.write(bf);
fileChannel.force(true);
}
}
if (tmpFile.exists()) {
// atomic move
Files.move(tmpFile.toPath(), bakFile.toPath(), StandardCopyOption.ATOMIC_MOVE);
// sync the directory, ensure that the bak file is visible
MixAll.fsyncDirectory(Paths.get(bakFile.getParent()));
}
cleanExpiredSnapshot(); // Clean up expired snapshot files
}
/**
* Select snapshot file name based on flag.
*
* @param flag Flag used to select or identify snapshot file.
* @return Name of the snapshot file.
*/
private String selectSnapshotByFlag(long flag) {
if (flag < 0) {
return this.fileName; // If flag is less than 0, return default file name
}
return this.fileName + "." + flag; // Otherwise, return file name with flag suffix
}
/**
* Clean up expired snapshot files.
* <p>
* This method will find and delete all snapshot files with flags smaller than the specified value
* under the current file name, keeping the two snapshot files with the largest flags.
*/
public void cleanExpiredSnapshot() {
File dir = new File(this.fileName).getParentFile();
File[] files = dir.listFiles();
if (files == null) {
return;
}
// Collect all snapshot files and their flags
List<FileWithFlag> snapshotFiles = new ArrayList<>();
for (File file : files) {
String fileName = file.getName();
if (fileName.startsWith(TIMER_WHEEL_FILE_NAME + ".")) {
long flag = UtilAll.asLong(fileName.substring(TIMER_WHEEL_FILE_NAME.length() + 1), -1);
if (flag >= 0) {
snapshotFiles.add(new FileWithFlag(file, flag));
}
}
}
// Sort by flag in descending order
snapshotFiles.sort((a, b) -> Long.compare(b.flag, a.flag));
// Delete all files except the first two
for (int i = 2; i < snapshotFiles.size(); i++) {
UtilAll.deleteFile(snapshotFiles.get(i).file);
}
}
/**
* Get the maximum flag from existing snapshot files.
*
* @return The maximum flag value, or -1 if no snapshot files exist
*/
public static long getMaxSnapshotFlag(String timerWheelPath) {
File dir = new File(timerWheelPath).getParentFile();
File[] files = dir.listFiles();
if (files == null) {
return -1;
}
long maxFlag = -1;
for (File file : files) {
String fileName = file.getName();
if (fileName.startsWith(TIMER_WHEEL_FILE_NAME + ".")) {
long flag = UtilAll.asLong(fileName.substring(TIMER_WHEEL_FILE_NAME.length() + 1), -1);
if (flag > maxFlag) {
maxFlag = flag;
}
}
}
return maxFlag;
}
/**
* Wrapper class for file and flag
*/
private static class FileWithFlag {
final File file;
final long flag;
FileWithFlag(File file, long flag) {
this.file = file;
this.flag = flag;
}
}
public Slot getSlot(long timeMs) {
Slot slot = getRawSlot(timeMs);
if (slot.timeMs != timeMs / precisionMs * precisionMs) {
@@ -145,6 +285,7 @@ public class TimerWheel {
localBuffer.get().putLong(firstPos);
localBuffer.get().putLong(lastPos);
}
public void putSlot(long timeMs, long firstPos, long lastPos, int num, int magic) {
localBuffer.get().position(getSlotIndex(timeMs) * Slot.SIZE);
localBuffer.get().putLong(timeMs / precisionMs);
@@ -212,4 +353,8 @@ public class TimerWheel {
}
return allNum;
}
public String getFileName() {
return fileName;
}
}