Redis 寫(xiě)時(shí)復(fù)制的防坑指南
做了這么多高并發(fā)系統(tǒng),我發(fā)現(xiàn) Redis 的寫(xiě)時(shí)復(fù)制(COW)是最容易被誤解的特性之一。很多人以為配了 BGSAVE 就萬(wàn)事大吉,結(jié)果生產(chǎn)環(huán)境內(nèi)存翻倍、服務(wù)抖動(dòng)、甚至 OOM 宕機(jī)。這篇文章不講理論,只說(shuō)我這些踩過(guò)的坑和總結(jié)出來(lái)的實(shí)戰(zhàn)經(jīng)驗(yàn)。
COW 的本質(zhì):一場(chǎng)內(nèi)存與時(shí)間的博弈
寫(xiě)時(shí)復(fù)制聽(tīng)起來(lái)很高大上,其實(shí)原理特別樸素。想象你要復(fù)印一本書(shū),有兩種方式:
- 笨辦法:一頁(yè)一頁(yè)全部復(fù)印(傳統(tǒng) SAVE)
- 聰明辦法:先記下頁(yè)碼,誰(shuí)要改哪頁(yè)才復(fù)印哪頁(yè)(COW)
Redis 的 BGSAVE 和 BGREWRITEAOF 都是用第二種方式。fork 出子進(jìn)程時(shí),父子進(jìn)程共享同一份物理內(nèi)存,只是各自有獨(dú)立的頁(yè)表。當(dāng)父進(jìn)程要修改數(shù)據(jù)時(shí),操作系統(tǒng)才會(huì)真正復(fù)制那一頁(yè)內(nèi)存。
這個(gè)機(jī)制很巧妙,但魔鬼藏在細(xì)節(jié)里。
第一個(gè)大坑:fork 不是免費(fèi)午餐
很多人以為 fork 很快,畢竟"只是復(fù)制頁(yè)表"。但在大內(nèi)存場(chǎng)景下,這個(gè)認(rèn)知會(huì)害死你。
真實(shí)案例:我們有個(gè) 64GB 的 Redis 實(shí)例,存儲(chǔ)商品數(shù)據(jù)和用戶畫(huà)像。每次 fork 要花 400ms,這 400ms 里:
- 所有客戶端請(qǐng)求被阻塞
- 監(jiān)控顯示一條直線(像死了一樣)
- 業(yè)務(wù)方瘋狂報(bào)超時(shí)
解決方案一:拆分大實(shí)例
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class RedisShardingStrategy {
// 原來(lái):一個(gè)大 Redis 存所有數(shù)據(jù)
// JedisPool redisAll = new JedisPool("10.0.0.1", 6379); // 64GB
// 現(xiàn)在:按數(shù)據(jù)特性拆分
private JedisPool redisHot; // 16GB 熱數(shù)據(jù)
private JedisPool redisWarm; // 32GB 溫?cái)?shù)據(jù)
private JedisPool redisCold; // 16GB 冷數(shù)據(jù)
public void init() {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
config.setMaxIdle(20);
redisHot = new JedisPool(config, "10.0.0.2", 6379);
redisWarm = new JedisPool(config, "10.0.0.3", 6379);
redisCold = new JedisPool(config, "10.0.0.4", 6379);
// 熱數(shù)據(jù):頻繁訪問(wèn),很少持久化
try (Jedis jedis = redisHot.getResource()) {
jedis.configSet("save", ""); // 關(guān)閉自動(dòng)保存
}
// 溫?cái)?shù)據(jù):定期持久化
try (Jedis jedis = redisWarm.getResource()) {
jedis.configSet("save", "1800 1"); // 30分鐘
}
// 冷數(shù)據(jù):積極持久化
try (Jedis jedis = redisCold.getResource()) {
jedis.configSet("save", "300 10"); // 5分鐘
}
}
}
解決方案二:優(yōu)化頁(yè)表大小
Linux 默認(rèn)用 4KB 的內(nèi)存頁(yè),64GB 就是 1600 萬(wàn)個(gè)頁(yè)表項(xiàng)??梢蚤_(kāi)啟大頁(yè)(Huge Pages):
# 查看當(dāng)前透明大頁(yè)設(shè)置 cat /sys/kernel/mm/transparent_hugepage/enabled # 建議設(shè)為 madvise(讓 Redis 自己決定) echo madvise > /sys/kernel/mm/transparent_hugepage/enabled # 預(yù)分配大頁(yè) echo 20480 > /proc/sys/vm/nr_hugepages # 20480 * 2MB = 40GB
開(kāi)啟大頁(yè)后,fork 時(shí)間從 400ms 降到 50ms,效果立竿見(jiàn)影。
第二個(gè)大坑:COW 的內(nèi)存膨脹
理論上 COW 很省內(nèi)存,實(shí)際上經(jīng)常事與愿違。最慘的一次,32GB 的 Redis 在 BGSAVE 期間內(nèi)存飆到 58GB,直接觸發(fā) OOM。
為什么會(huì)膨脹?
COW 是以內(nèi)存頁(yè)為單位的(通常 4KB)。假設(shè)一個(gè)頁(yè)里存了 100 個(gè) key,你改了其中 1 個(gè),整頁(yè)都要復(fù)制。如果寫(xiě)入很分散,大部分頁(yè)都會(huì)被復(fù)制。
實(shí)測(cè)數(shù)據(jù)(32GB Redis):
- 寫(xiě)入集中(熱點(diǎn) key):COW 額外內(nèi)存 2-3GB
- 寫(xiě)入分散(隨機(jī) key):COW 額外內(nèi)存 15-20GB
- 全量更新(批量寫(xiě)入):COW 額外內(nèi)存 25-30GB
應(yīng)對(duì)策略:
import redis.clients.jedis.*;
import java.util.*;
import java.util.concurrent.TimeUnit;
public class COWFriendlyRedis {
private JedisPool jedisPool;
private boolean inCow = false;
public COWFriendlyRedis(String host, int port) {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(100);
this.jedisPool = new JedisPool(config, host, port);
}
public void batchWrite(Map<String, String> dataMap, boolean cowFriendly) throws InterruptedException {
if (!cowFriendly) {
// 傳統(tǒng)方式:直接寫(xiě)
try (Jedis jedis = jedisPool.getResource()) {
Pipeline pipe = jedis.pipelined();
for (Map.Entry<String, String> entry : dataMap.entrySet()) {
pipe.set(entry.getKey(), entry.getValue());
}
pipe.sync();
return;
}
}
// COW 友好方式:檢測(cè)并避讓
try (Jedis jedis = jedisPool.getResource()) {
// 1. 檢查是否正在做持久化
String persistence = jedis.info("persistence");
boolean rdbInProgress = persistence.contains("rdb_bgsave_in_progress:1");
boolean aofInProgress = persistence.contains("aof_rewrite_in_progress:1");
if (rdbInProgress || aofInProgress) {
System.out.println("檢測(cè)到持久化進(jìn)行中,降速寫(xiě)入...");
// 降速寫(xiě)入,每寫(xiě)入一些就休眠
int count = 0;
for (Map.Entry<String, String> entry : dataMap.entrySet()) {
jedis.set(entry.getKey(), entry.getValue());
if (++count % 100 == 0) { // 每100個(gè)key休眠一下
TimeUnit.MILLISECONDS.sleep(1);
}
}
} else {
// 2. 非持久化期間,集中寫(xiě)入
// 先關(guān)閉自動(dòng)持久化
String oldSave = jedis.configGet("save").get(1);
jedis.configSet("save", "");
// 3. 批量寫(xiě)入
Pipeline pipe = jedis.pipelined();
for (Map.Entry<String, String> entry : dataMap.entrySet()) {
pipe.set(entry.getKey(), entry.getValue());
}
pipe.sync();
// 4. 手動(dòng)觸發(fā)一次持久化
jedis.bgsave();
// 5. 恢復(fù)自動(dòng)持久化配置
TimeUnit.SECONDS.sleep(1); // 等 BGSAVE 開(kāi)始
jedis.configSet("save", oldSave);
}
}
}
}
第三個(gè)大坑:AOF 重寫(xiě)的死亡螺旋
AOF 重寫(xiě)也用 COW,但它比 RDB 更容易出問(wèn)題。因?yàn)?AOF 重寫(xiě)期間,新的寫(xiě)入命令會(huì)同時(shí)寫(xiě)到:
- AOF 緩沖區(qū)(給主進(jìn)程用)
- AOF 重寫(xiě)緩沖區(qū)(給子進(jìn)程用)
如果寫(xiě)入速度太快,重寫(xiě)緩沖區(qū)會(huì)爆炸式增長(zhǎng)。
真實(shí)故事:
某個(gè)黑色星期五,營(yíng)銷系統(tǒng)瘋狂更新 Redis 里的庫(kù)存數(shù)據(jù)。AOF 重寫(xiě)觸發(fā)后:
- 重寫(xiě)緩沖區(qū)快速增長(zhǎng)到 8GB
- 內(nèi)存使用率 95%
- 重寫(xiě)快完成時(shí),要把 8GB 緩沖區(qū)寫(xiě)回,主線程阻塞 30 秒
- 所有服務(wù)超時(shí),雪崩
解決方法:
# 1. 限制 AOF 重寫(xiě)頻率 auto-aof-rewrite-percentage 200 # 文件大小翻倍才重寫(xiě)(默認(rèn)100) auto-aof-rewrite-min-size 1gb # 最小 1GB 才考慮重寫(xiě) # 2. 重寫(xiě)期間不做 fsync(危險(xiǎn)但有效) no-appendfsync-on-rewrite yes # 3. 使用 RDB+AOF 混合持久化(Redis 4.0+) aof-use-rdb-preamble yes
更徹底的方案是監(jiān)控加熔斷:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.concurrent.*;
import java.util.regex.*;
public class AOFRewriteGuard {
private final JedisPool jedisPool;
private final int maxBufferMB;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public AOFRewriteGuard(JedisPool jedisPool, int maxBufferMB) {
this.jedisPool = jedisPool;
this.maxBufferMB = maxBufferMB;
}
public void startMonitoring() {
scheduler.scheduleAtFixedRate(this::checkAOFRewrite, 0, 5, TimeUnit.SECONDS);
}
private void checkAOFRewrite() {
try (Jedis jedis = jedisPool.getResource()) {
String info = jedis.info("persistence");
// 解析持久化信息
Pattern inProgressPattern = Pattern.compile("aof_rewrite_in_progress:(\\d)");
Pattern bufferLengthPattern = Pattern.compile("aof_rewrite_buffer_length:(\\d+)");
Matcher inProgressMatcher = inProgressPattern.matcher(info);
if (inProgressMatcher.find() && "1".equals(inProgressMatcher.group(1))) {
// 正在重寫(xiě),檢查緩沖區(qū)大小
Matcher bufferMatcher = bufferLengthPattern.matcher(info);
if (bufferMatcher.find()) {
long bufferSize = Long.parseLong(bufferMatcher.group(1));
double bufferMB = bufferSize / 1024.0 / 1024.0;
if (bufferMB > maxBufferMB) {
// 緩沖區(qū)過(guò)大,緊急措施
System.err.printf("AOF重寫(xiě)緩沖區(qū)過(guò)大: %.2fMB%n", bufferMB);
// 1. 通知應(yīng)用層限流
publishAlert("aof_buffer_overflow", bufferMB);
// 2. 如果超過(guò) 2 倍閾值,考慮中止重寫(xiě)
if (bufferMB > maxBufferMB * 2) {
// 這是個(gè)危險(xiǎn)操作,記錄日志
System.err.println("警告:AOF緩沖區(qū)超過(guò)2倍閾值,考慮中止重寫(xiě)");
// 實(shí)際生產(chǎn)環(huán)境中,這里應(yīng)該發(fā)送告警而不是自動(dòng)中止
// jedis.configSet("appendonly", "no");
// Thread.sleep(1000);
// jedis.configSet("appendonly", "yes");
}
}
}
}
} catch (Exception e) {
System.err.println("AOF監(jiān)控異常: " + e.getMessage());
}
}
private void publishAlert(String type, double bufferMB) {
// 實(shí)際環(huán)境中這里應(yīng)該接入告警系統(tǒng)
System.err.printf("告警: %s, 緩沖區(qū)大小: %.2fMB%n", type, bufferMB);
}
}
隱藏技巧:利用 COW 做零成本備份
這是個(gè)很少人知道的技巧。既然 COW 讓父子進(jìn)程共享內(nèi)存,那能不能利用這個(gè)特性做備份?
傳統(tǒng)備份:
# 占用雙倍內(nèi)存 redis-cli --rdb /backup/dump.rdb
COW 備份:
import redis.clients.jedis.Jedis;
import java.io.*;
import java.util.regex.*;
import java.util.concurrent.TimeUnit;
public class COWBackupUtil {
public static void cowBackup(Jedis jedis, String backupPath) throws Exception {
// 1. 觸發(fā) BGSAVE
String result = jedis.bgsave();
System.out.println("BGSAVE 觸發(fā): " + result);
// 2. 等待 BGSAVE 開(kāi)始(不是結(jié)束)
long childPid = -1;
Pattern pidPattern = Pattern.compile("rdb_last_bgsave_pid:(\\d+)");
Pattern progressPattern = Pattern.compile("rdb_bgsave_in_progress:(\\d)");
while (childPid == -1) {
String info = jedis.info("persistence");
Matcher progressMatcher = progressPattern.matcher(info);
if (progressMatcher.find() && "1".equals(progressMatcher.group(1))) {
Matcher pidMatcher = pidPattern.matcher(info);
if (pidMatcher.find()) {
childPid = Long.parseLong(pidMatcher.group(1));
}
}
TimeUnit.MILLISECONDS.sleep(100);
}
System.out.println("BGSAVE 子進(jìn)程 PID: " + childPid);
// 3. 這時(shí)子進(jìn)程已經(jīng) fork 完成,內(nèi)存是 COW 共享的
// 可以慢慢導(dǎo)出數(shù)據(jù),不影響主進(jìn)程
// 4. 通過(guò) /proc 讀取子進(jìn)程的內(nèi)存映射(需要 Linux 環(huán)境)
File mapsFile = new File("/proc/" + childPid + "/maps");
if (mapsFile.exists()) {
try (BufferedReader reader = new BufferedReader(new FileReader(mapsFile))) {
String line;
System.out.println("內(nèi)存映射信息:");
while ((line = reader.readLine()) != null) {
// 找到堆區(qū)域(通常包含 Redis 數(shù)據(jù))
if (line.contains("[heap]")) {
System.out.println("堆區(qū)域: " + line);
}
}
}
}
// 5. 等待 BGSAVE 完成,然后復(fù)制 dump.rdb 文件
Pattern lastSavePattern = Pattern.compile("rdb_last_bgsave_status:ok");
while (true) {
String info = jedis.info("persistence");
if (lastSavePattern.matcher(info).find()) {
// BGSAVE 完成,復(fù)制文件
File sourceFile = new File(jedis.configGet("dir").get(1) + "/" +
jedis.configGet("dbfilename").get(1));
File destFile = new File(backupPath);
try (FileInputStream fis = new FileInputStream(sourceFile);
FileOutputStream fos = new FileOutputStream(destFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
}
System.out.println("COW 備份完成: " + backupPath);
break;
}
TimeUnit.SECONDS.sleep(1);
}
}
}
生產(chǎn)環(huán)境的最佳配置
經(jīng)過(guò)無(wú)數(shù)次調(diào)優(yōu),這是我認(rèn)為比較均衡的配置:
# ========== 內(nèi)存管理 ========== maxmemory 32gb maxmemory-policy volatile-lru # 預(yù)留 25% 內(nèi)存給 COW maxmemory-reserved 8gb # 僅 Redis 7.0+ # ========== RDB 配置 ========== # 分層保存策略 save 3600 1 # 1小時(shí)內(nèi)至少1個(gè)鍵改變 save 1800 100 # 30分鐘內(nèi)至少100個(gè)鍵改變 save 300 10000 # 5分鐘內(nèi)至少10000個(gè)鍵改變 # 壓縮和校驗(yàn) rdbcompression yes rdbchecksum yes # BGSAVE 失敗不停止寫(xiě)入(危險(xiǎn)但實(shí)用) stop-writes-on-bgsave-error no # ========== AOF 配置 ========== appendonly yes appendfilename "redis.aof" # 同步策略(everysec 是最佳平衡) appendfsync everysec # AOF 重寫(xiě) auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 2gb no-appendfsync-on-rewrite yes # 混合持久化(強(qiáng)烈推薦) aof-use-rdb-preamble yes # ========== 系統(tǒng)層面 ========== # /etc/sysctl.conf vm.overcommit_memory=1 # 允許 overcommit vm.overcommit_ratio=100 # 可以用到 100% 物理內(nèi)存 vm.swappiness=1 # 盡量不用 swap vm.dirty_background_ratio=5 # 后臺(tái)開(kāi)始刷盤(pán)的臟頁(yè)比例 vm.dirty_ratio=10 # 前臺(tái)強(qiáng)制刷盤(pán)的臟頁(yè)比例 # 透明大頁(yè)(THP) echo madvise > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag
監(jiān)控:這些指標(biāo)必須盯著
import redis.clients.jedis.Jedis;
import java.io.*;
import java.util.*;
import java.util.regex.*;
public class RedisCoWMonitor {
private final Jedis redis;
private final Map<String, Double> metrics = new HashMap<>();
public RedisCoWMonitor(Jedis redis) {
this.redis = redis;
}
public Map<String, Double> collectMetrics() {
String info = redis.info();
Map<String, String> infoMap = parseInfo(info);
// 核心指標(biāo)
metrics.put("cow_overhead", calculateCowOverhead(infoMap));
metrics.put("fork_time_ms",
Double.parseDouble(infoMap.getOrDefault("latest_fork_usec", "0")) / 1000);
metrics.put("fragmentation",
Double.parseDouble(infoMap.getOrDefault("mem_fragmentation_ratio", "1.0")));
// 持久化相關(guān)
String persistInfo = redis.info("persistence");
Map<String, String> persistMap = parseInfo(persistInfo);
metrics.put("rdb_in_progress",
Double.parseDouble(persistMap.getOrDefault("rdb_bgsave_in_progress", "0")));
metrics.put("aof_rewrite_in_progress",
Double.parseDouble(persistMap.getOrDefault("aof_rewrite_in_progress", "0")));
metrics.put("aof_buffer_length",
Double.parseDouble(persistMap.getOrDefault("aof_rewrite_buffer_length", "0")));
// 內(nèi)存使用
double usedMemory = Double.parseDouble(infoMap.getOrDefault("used_memory", "0"));
double usedMemoryRss = Double.parseDouble(infoMap.getOrDefault("used_memory_rss", "0"));
metrics.put("used_memory_gb", usedMemory / Math.pow(1024, 3));
metrics.put("used_memory_rss_gb", usedMemoryRss / Math.pow(1024, 3));
metrics.put("memory_available", (double) getSystemAvailableMemory());
return metrics;
}
private Map<String, String> parseInfo(String info) {
Map<String, String> result = new HashMap<>();
String[] lines = info.split("\n");
for (String line : lines) {
if (line.contains(":")) {
String[] parts = line.split(":", 2);
result.put(parts[0].trim(), parts[1].trim());
}
}
return result;
}
private double calculateCowOverhead(Map<String, String> infoMap) {
double used = Double.parseDouble(infoMap.getOrDefault("used_memory", "0"));
double rss = Double.parseDouble(infoMap.getOrDefault("used_memory_rss", "0"));
double fragmentation = Double.parseDouble(
infoMap.getOrDefault("mem_fragmentation_ratio", "1.0"));
// RSS 包含:實(shí)際使用 + COW復(fù)制 + 內(nèi)存碎片
// 減去碎片部分,剩下的主要是 COW
double cowOverhead = rss - used * fragmentation;
return Math.max(0, cowOverhead); // 可能是負(fù)數(shù),取 0
}
public List<String> alertIfNeeded() {
List<String> alerts = new ArrayList<>();
// COW 開(kāi)銷超過(guò) 50%
double usedMemoryBytes = metrics.get("used_memory_gb") * Math.pow(1024, 3);
if (metrics.get("cow_overhead") > usedMemoryBytes * 0.5) {
alerts.add("COW 內(nèi)存開(kāi)銷過(guò)大");
}
// Fork 時(shí)間超過(guò) 200ms
if (metrics.get("fork_time_ms") > 200) {
alerts.add(String.format("Fork 耗時(shí)過(guò)長(zhǎng): %.2fms", metrics.get("fork_time_ms")));
}
// 內(nèi)存碎片嚴(yán)重
if (metrics.get("fragmentation") > 1.5) {
alerts.add(String.format("內(nèi)存碎片率過(guò)高: %.2f", metrics.get("fragmentation")));
}
// AOF 重寫(xiě)緩沖區(qū)過(guò)大
if (metrics.get("aof_buffer_length") > Math.pow(1024, 3)) { // 1GB
double bufferGb = metrics.get("aof_buffer_length") / Math.pow(1024, 3);
alerts.add(String.format("AOF 重寫(xiě)緩沖區(qū)過(guò)大: %.2fGB", bufferGb));
}
// 可用內(nèi)存不足
if (metrics.get("memory_available") < metrics.get("used_memory_gb") * 0.3 * Math.pow(1024, 3)) {
alerts.add("系統(tǒng)可用內(nèi)存不足,COW 可能失敗");
}
return alerts;
}
private long getSystemAvailableMemory() {
// Linux 系統(tǒng)獲取可用內(nèi)存
try (BufferedReader reader = new BufferedReader(new FileReader("/proc/meminfo"))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("MemAvailable:")) {
String[] parts = line.split("\\s+");
return Long.parseLong(parts[1]) * 1024; // KB to Bytes
}
}
} catch (IOException e) {
System.err.println("無(wú)法讀取系統(tǒng)內(nèi)存信息: " + e.getMessage());
}
return 0;
}
}
不同場(chǎng)景的 COW 策略
場(chǎng)景一:緩存系統(tǒng)(可以接受數(shù)據(jù)丟失)
# 關(guān)閉持久化,完全避免 COW save "" appendonly no # 或者只做最低限度的持久化 save 7200 1 # 2小時(shí) appendonly no
場(chǎng)景二:Session 存儲(chǔ)(需要一定持久性)
# RDB 足夠,AOF 太重 save 900 1 300 10 60 10000 appendonly no # 用主從復(fù)制代替 AOF # 從節(jié)點(diǎn)可以更激進(jìn)地做持久化
場(chǎng)景三:消息隊(duì)列(不能丟數(shù)據(jù))
# AOF 為主,RDB 為輔 appendonly yes appendfsync everysec save 1800 1 # 考慮使用 Redis Streams + Consumer Groups # 自帶持久化和 ack 機(jī)制
場(chǎng)景四:實(shí)時(shí)分析(寫(xiě)入密集)
import redis.clients.jedis.*;
import java.util.Set;
import java.util.concurrent.*;
// 時(shí)間窗口策略
public class TimeWindowRedis {
private final JedisPool currentPool; // 當(dāng)前窗口(不持久化)
private final JedisPool historyPool; // 歷史窗口(定期持久化)
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public TimeWindowRedis() {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(50);
// 雙緩沖:當(dāng)前窗口 + 歷史窗口
this.currentPool = new JedisPool(config, "localhost", 6379);
this.historyPool = new JedisPool(config, "localhost", 6380);
// 配置不同的持久化策略
try (Jedis current = currentPool.getResource()) {
current.configSet("save", ""); // 不持久化
}
try (Jedis history = historyPool.getResource()) {
history.configSet("save", "1800 1"); // 30分鐘持久化
}
// 每小時(shí)輪轉(zhuǎn)一次窗口
scheduler.scheduleAtFixedRate(this::rotateWindow, 1, 1, TimeUnit.HOURS);
}
public void write(String key, String value) {
// 寫(xiě)入當(dāng)前窗口
try (Jedis jedis = currentPool.getResource()) {
jedis.set(key, value);
}
}
public void rotateWindow() {
System.out.println("開(kāi)始窗口輪轉(zhuǎn)...");
try (Jedis current = currentPool.getResource();
Jedis history = historyPool.getResource()) {
// 1. 當(dāng)前窗口數(shù)據(jù)導(dǎo)入歷史
ScanParams scanParams = new ScanParams().count(100);
String cursor = "0";
do {
ScanResult<String> scanResult = current.scan(cursor, scanParams);
for (String key : scanResult.getResult()) {
String value = current.get(key);
if (value != null) {
history.set(key, value);
}
}
cursor = scanResult.getCursor();
} while (!"0".equals(cursor));
// 2. 清空當(dāng)前窗口
current.flushDB();
// 3. 歷史窗口做持久化
history.bgsave();
System.out.println("窗口輪轉(zhuǎn)完成");
} catch (Exception e) {
System.err.println("窗口輪轉(zhuǎn)失敗: " + e.getMessage());
}
}
public void shutdown() {
scheduler.shutdown();
currentPool.close();
historyPool.close();
}
}
踩坑總結(jié):血淚教訓(xùn)
永遠(yuǎn)不要在內(nèi)存使用超過(guò) 70% 時(shí)做持久化
- COW 需要額外內(nèi)存,留 30% 是底線
- 實(shí)在不行,先淘汰一些 key 再持久化
fork 失敗不是世界末日
- 準(zhǔn)備好降級(jí)方案(比如寫(xiě)日志、發(fā)消息隊(duì)列)
- 監(jiān)控
latest_fork_usec = -1表示 fork 失敗
不要迷信 Redis 的默認(rèn)配置
- 默認(rèn)配置是為了兼容性,不是為了性能
- 根據(jù)實(shí)際場(chǎng)景調(diào)整,沒(méi)有銀彈
COW 不是萬(wàn)能的
- 寫(xiě)入越分散,COW 效果越差
- 考慮業(yè)務(wù)層面的優(yōu)化(比如批量寫(xiě)、按范圍寫(xiě))
主從復(fù)制也會(huì)觸發(fā) COW
- 全量同步時(shí),主節(jié)點(diǎn)會(huì) BGSAVE
- 部分重同步失敗會(huì)退化為全量同步
- 建議:
repl-backlog-size設(shè)大一點(diǎn)(1GB起步)
實(shí)戰(zhàn)案例:Spring Boot 集成 Redis COW 監(jiān)控
在實(shí)際項(xiàng)目中,我們通常需要把 Redis COW 監(jiān)控集成到 Spring Boot 應(yīng)用里:
import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.beans.factory.annotation.Autowired;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.Jedis;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class RedisCowService {
private static final Logger logger = LoggerFactory.getLogger(RedisCowService.class);
@Autowired
private JedisPool jedisPool;
// 每分鐘檢查一次 COW 狀態(tài)
@Scheduled(fixedDelay = 60000)
public void monitorCowStatus() {
try (Jedis jedis = jedisPool.getResource()) {
String info = jedis.info();
// 解析關(guān)鍵指標(biāo)
long usedMemory = parseInfoLong(info, "used_memory:");
long usedMemoryRss = parseInfoLong(info, "used_memory_rss:");
double fragRatio = parseInfoDouble(info, "mem_fragmentation_ratio:");
// 計(jì)算 COW 壓力指數(shù)
double cowPressure = (usedMemoryRss - usedMemory) / (double) usedMemory;
if (cowPressure > 0.3) {
logger.warn("COW內(nèi)存壓力過(guò)高: {}%, 考慮優(yōu)化寫(xiě)入模式", cowPressure * 100);
// 動(dòng)態(tài)調(diào)整策略
adjustStrategy(jedis, cowPressure);
}
// 記錄指標(biāo)到監(jiān)控系統(tǒng)(Prometheus/Grafana)
recordMetrics(usedMemory, usedMemoryRss, fragRatio, cowPressure);
}
}
private void adjustStrategy(Jedis jedis, double cowPressure) {
if (cowPressure > 0.5) {
// 壓力極高,臨時(shí)關(guān)閉自動(dòng)持久化
jedis.configSet("save", "");
logger.warn("COW壓力極高,已臨時(shí)關(guān)閉自動(dòng)持久化");
// 通知運(yùn)維人員
sendAlert("Redis COW壓力超過(guò)50%,請(qǐng)立即檢查");
} else if (cowPressure > 0.3) {
// 壓力較高,延長(zhǎng)持久化間隔
jedis.configSet("save", "3600 1"); // 改為1小時(shí)
logger.info("調(diào)整持久化間隔為1小時(shí)");
}
}
private long parseInfoLong(String info, String key) {
int start = info.indexOf(key);
if (start == -1) return 0;
start += key.length();
int end = info.indexOf('\r', start);
if (end == -1) end = info.indexOf('\n', start);
return Long.parseLong(info.substring(start, end));
}
private double parseInfoDouble(String info, String key) {
int start = info.indexOf(key);
if (start == -1) return 0;
start += key.length();
int end = info.indexOf('\r', start);
if (end == -1) end = info.indexOf('\n', start);
return Double.parseDouble(info.substring(start, end));
}
private void recordMetrics(long usedMemory, long usedMemoryRss,
double fragRatio, double cowPressure) {
// 這里接入你的監(jiān)控系統(tǒng)
// 比如 Micrometer、Prometheus 等
}
private void sendAlert(String message) {
// 接入告警系統(tǒng):釘釘、企業(yè)微信、PagerDuty 等
logger.error("告警: " + message);
}
}
高級(jí)技巧:利用 Lua 腳本減少 COW 開(kāi)銷
很多人不知道,Lua 腳本可以顯著減少 COW 期間的內(nèi)存復(fù)制:
public class LuaOptimizedRedis {
private final JedisPool jedisPool;
// 批量更新的 Lua 腳本
private static final String BATCH_UPDATE_SCRIPT =
"local count = 0\n" +
"for i = 1, #KEYS do\n" +
" redis.call('SET', KEYS[i], ARGV[i])\n" +
" count = count + 1\n" +
" -- 每100個(gè)key做一次內(nèi)存整理\n" +
" if count % 100 == 0 then\n" +
" redis.call('MEMORY', 'PURGE')\n" +
" end\n" +
"end\n" +
"return count";
// 條件性持久化的 Lua 腳本
private static final String CONDITIONAL_SAVE_SCRIPT =
"local info = redis.call('INFO', 'memory')\n" +
"local used = tonumber(string.match(info, 'used_memory:(%d+)'))\n" +
"local total = tonumber(string.match(info, 'total_system_memory:(%d+)'))\n" +
"if used / total < 0.7 then\n" +
" redis.call('BGSAVE')\n" +
" return 'BGSAVE triggered'\n" +
"else\n" +
" return 'Memory usage too high, skip BGSAVE'\n" +
"end";
public LuaOptimizedRedis(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
public long batchUpdate(Map<String, String> data) {
try (Jedis jedis = jedisPool.getResource()) {
List<String> keys = new ArrayList<>(data.keySet());
List<String> values = new ArrayList<>();
for (String key : keys) {
values.add(data.get(key));
}
// 執(zhí)行 Lua 腳本,原子操作減少內(nèi)存碎片
Object result = jedis.eval(BATCH_UPDATE_SCRIPT, keys, values);
return (Long) result;
}
}
public String conditionalSave() {
try (Jedis jedis = jedisPool.getResource()) {
// 只在內(nèi)存使用率低于70%時(shí)觸發(fā)持久化
return (String) jedis.eval(CONDITIONAL_SAVE_SCRIPT, 0);
}
}
}
生產(chǎn)環(huán)境實(shí)戰(zhàn):處理千萬(wàn)級(jí)數(shù)據(jù)的 COW 優(yōu)化
這是我在一個(gè)電商項(xiàng)目中看到的處理千萬(wàn)級(jí)商品緩存的方案:
import java.util.concurrent.*;
import java.util.*;
import redis.clients.jedis.*;
public class MassiveDataCowOptimizer {
private final List<JedisPool> shardPools = new ArrayList<>();
private final int SHARD_COUNT = 16; // 16個(gè)分片
private final ExecutorService executor = Executors.newFixedThreadPool(32);
public MassiveDataCowOptimizer() {
// 初始化16個(gè)Redis分片,每個(gè)2GB
for (int i = 0; i < SHARD_COUNT; i++) {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(10);
config.setMaxIdle(5);
JedisPool pool = new JedisPool(config, "redis-shard-" + i, 6379);
shardPools.add(pool);
// 每個(gè)分片不同的持久化策略
try (Jedis jedis = pool.getResource()) {
if (i < 4) {
// 前4個(gè)分片存熱數(shù)據(jù),不持久化
jedis.configSet("save", "");
} else if (i < 12) {
// 中間8個(gè)分片,適度持久化
jedis.configSet("save", "1800 100");
} else {
// 最后4個(gè)分片存冷數(shù)據(jù),頻繁持久化
jedis.configSet("save", "300 10");
}
}
}
}
// 一致性哈希分片
private int getShard(String key) {
return Math.abs(key.hashCode()) % SHARD_COUNT;
}
// 并行批量寫(xiě)入,最小化COW影響
public void parallelBatchWrite(Map<String, String> bigData) throws InterruptedException {
// 按分片分組數(shù)據(jù)
Map<Integer, Map<String, String>> shardedData = new HashMap<>();
for (Map.Entry<String, String> entry : bigData.entrySet()) {
int shard = getShard(entry.getKey());
shardedData.computeIfAbsent(shard, k -> new HashMap<>())
.put(entry.getKey(), entry.getValue());
}
// 并行寫(xiě)入各分片
CountDownLatch latch = new CountDownLatch(shardedData.size());
for (Map.Entry<Integer, Map<String, String>> entry : shardedData.entrySet()) {
final int shardId = entry.getKey();
final Map<String, String> shardData = entry.getValue();
executor.submit(() -> {
try {
writeToShard(shardId, shardData);
} finally {
latch.countDown();
}
});
}
latch.await(30, TimeUnit.SECONDS);
}
private void writeToShard(int shardId, Map<String, String> data) {
JedisPool pool = shardPools.get(shardId);
try (Jedis jedis = pool.getResource()) {
// 檢查當(dāng)前分片的內(nèi)存狀態(tài)
String info = jedis.info("memory");
long usedMemory = parseMemory(info, "used_memory:");
if (usedMemory > 1_500_000_000) { // 超過(guò)1.5GB
// 內(nèi)存過(guò)高,分批寫(xiě)入
Pipeline pipe = jedis.pipelined();
int count = 0;
for (Map.Entry<String, String> entry : data.entrySet()) {
pipe.set(entry.getKey(), entry.getValue());
if (++count % 100 == 0) {
pipe.sync();
// 讓COW有機(jī)會(huì)處理
Thread.sleep(1);
pipe = jedis.pipelined();
}
}
if (count % 100 != 0) {
pipe.sync();
}
} else {
// 內(nèi)存充足,一次性寫(xiě)入
Pipeline pipe = jedis.pipelined();
for (Map.Entry<String, String> entry : data.entrySet()) {
pipe.set(entry.getKey(), entry.getValue());
}
pipe.sync();
}
} catch (Exception e) {
System.err.println("分片" + shardId + "寫(xiě)入失敗: " + e.getMessage());
}
}
// 智能持久化調(diào)度
public void smartPersistence() {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
for (int i = 0; i < SHARD_COUNT; i++) {
final int shardId = i;
executor.submit(() -> checkAndPersist(shardId));
}
}, 0, 5, TimeUnit.MINUTES);
}
private void checkAndPersist(int shardId) {
JedisPool pool = shardPools.get(shardId);
try (Jedis jedis = pool.getResource()) {
String info = jedis.info();
// 解析關(guān)鍵指標(biāo)
boolean rdbInProgress = info.contains("rdb_bgsave_in_progress:1");
long changedKeys = parseMemory(info, "rdb_changes_since_last_save:");
// 根據(jù)變化量決定是否持久化
if (!rdbInProgress && changedKeys > 10000) {
// 檢查系統(tǒng)負(fù)載
double loadAvg = getSystemLoadAverage();
if (loadAvg < 2.0) { // 負(fù)載低時(shí)才持久化
jedis.bgsave();
System.out.println("分片" + shardId + "觸發(fā)持久化,變更鍵數(shù): " + changedKeys);
}
}
}
}
private long parseMemory(String info, String key) {
int idx = info.indexOf(key);
if (idx == -1) return 0;
idx += key.length();
int end = info.indexOf('\n', idx);
if (end == -1) end = info.length();
return Long.parseLong(info.substring(idx, end).trim());
}
private double getSystemLoadAverage() {
return ManagementFactory.getOperatingSystemMXBean().getSystemLoadAverage();
}
}
寫(xiě)在最后
Redis 的 COW 機(jī)制就像一把雙刃劍。用得好,你可以在有限的硬件上榨取最大性能;用不好,就等著半夜被電話叫醒吧。
我的建議是:
- 從保守配置開(kāi)始,逐步優(yōu)化 - 寧可慢一點(diǎn),也別宕機(jī)
- 監(jiān)控一定要全,寧可過(guò)度監(jiān)控 - 指標(biāo)就是你的眼睛
- 準(zhǔn)備好 Plan B(降級(jí)方案) - 永遠(yuǎn)有后手
- 定期做壓測(cè) - 別等出事了才發(fā)現(xiàn)瓶頸
- 理解原理比記配置更重要 - 知其然更要知其所以然
最后,如果你覺(jué)得 Redis 的 COW 太麻煩,可以考慮:
- KeyDB(多線程版 Redis,COW 影響更?。?/li>
- Dragonfly(新一代內(nèi)存數(shù)據(jù)庫(kù),不依賴 fork)
- RocksDB(LSM-tree 結(jié)構(gòu),沒(méi)有 COW 問(wèn)題)
- Apache Ignite(分布式內(nèi)存網(wǎng)格,持久化機(jī)制不同)
但說(shuō)實(shí)話,把 Redis COW 玩明白了,你對(duì)操作系統(tǒng)和內(nèi)存管理的理解會(huì)上一個(gè)臺(tái)階。這些知識(shí)在優(yōu)化 JVM、數(shù)據(jù)庫(kù)、甚至容器運(yùn)行時(shí)都用得上。
記?。?strong>內(nèi)存永遠(yuǎn)是最貴的資源,而 COW 是我們對(duì)抗這個(gè)現(xiàn)實(shí)的武器之一。掌握它,駕馭它,讓它為你所用。
到此這篇關(guān)于Redis 寫(xiě)時(shí)復(fù)制的防坑指南的文章就介紹到這了,更多相關(guān)Redis 寫(xiě)時(shí)復(fù)制內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
基于Redis Streams的實(shí)時(shí)消息處理實(shí)戰(zhàn)指南
這篇文章主要為大家詳細(xì)介紹了在生產(chǎn)環(huán)境中基于 Redis Streams 構(gòu)建實(shí)時(shí)消息處理的完整經(jīng)驗(yàn),包括技術(shù)選型、核心代碼示例、踩坑解決和優(yōu)化方案,希望對(duì)大家有所幫助2025-07-07
使用redis實(shí)現(xiàn)高效分頁(yè)的項(xiàng)目實(shí)踐
在很多場(chǎng)景下,我們需要對(duì)大量的數(shù)據(jù)進(jìn)行分頁(yè)展示,本文主要介紹了使用redis實(shí)現(xiàn)高效分頁(yè)的項(xiàng)目實(shí)踐,具有一定的參考價(jià)值,感興趣的可以了解一下2024-02-02
redis.conf中使用requirepass不生效的原因及解決方法
本文主要介紹了如何啟用requirepass,以及啟用requirepass為什么不會(huì)生效,從代碼層面分析了不生效的原因,以及解決方法,需要的朋友可以參考下2023-07-07
Redis+aop實(shí)現(xiàn)接口防刷(冪等)的解決方案
在高并發(fā)場(chǎng)景下,可能會(huì)因?yàn)榫W(wǎng)絡(luò)或者服務(wù)器原因,造成延遲,同時(shí)就是有可能會(huì)有人用腳本大量訪問(wèn)你的接口,造成資源崩潰,所以本文給大家介紹了Redis+aop實(shí)現(xiàn)接口防刷(冪等)的解決方案,需要的朋友可以參考下2024-03-03
Redis官方可視化工具RedisInsight的安裝使用詳細(xì)教程(功能強(qiáng)大)
RedisInsight是Redis官方出品的可視化管理工具,可用于設(shè)計(jì)、開(kāi)發(fā)、優(yōu)化你的Redis應(yīng)用。支持深色和淺色兩種主題,界面非常炫酷,接下來(lái)通過(guò)本文給大家介紹Redis官方可視化工具RedisInsight的安裝使用過(guò)程,需要的朋友可以參考下2022-04-04

