ข้ามไปยังเนื้อหา

Durability

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ที่ใช้ Docker Desktop การทดสอบ crash รันใน container ชั่วคราวของผมเอง (named volume บน /data, docker kill = SIGKILL); การทดสอบ replication ใช้สอง container บน network แบบ --internal ตัวเลขมาจากดิสก์ใน VM ของแล็ปท็อปเครื่องนี้: อ่านเป็นรูปร่าง ไม่ใช่ benchmark การตั้งค่าไฟล์อยู่ใน RDB and AOF

เมื่อเปิด AOF appendfsync กำหนดว่า Redis บอกให้ OS flush log ลงดิสก์จริงบ่อยแค่ไหน:

  • always: fsync หลังทุก batch ของการเขียน ช่วงที่เสียเล็กที่สุด ต้นทุนสูงที่สุด
  • everysec: fsync วินาทีละครั้ง ค่าเริ่มต้น อาจเสียราวหนึ่งวินาทีของการเขียนถ้าเครื่องไฟดับ
  • no: ไม่เรียก fsync เลย OS flush ตามที่ต้องการ (บน Linux โดยทั่วไปภายในราว 30 วินาที)
config/persistence-expiration-durability-fsync.conf
appendonly yes
appendfsync everysec
aof-load-truncated yes

ตรวจและสลับตอน runtime:

cli/persistence-expiration-durability-1.redis
127.0.0.1:6379> CONFIG GET appendonly
1) "appendonly"
2) "no"
127.0.0.1:6379> CONFIG GET appendfsync
1) "appendfsync"
2) "everysec"
127.0.0.1:6379> CONFIG SET appendfsync always
OK
127.0.0.1:6379> CONFIG GET appendfsync
1) "appendfsync"
2) "always"
127.0.0.1:6379> CONFIG SET appendfsync everysec
OK
127.0.0.1:6379> CONFIG GET save
1) "save"
2) "3600 1 300 100 60 10000"
127.0.0.1:6379> CONFIG GET aof-load-truncated
1) "aof-load-truncated"
2) "yes"
127.0.0.1:6379> WAIT 1 100
(integer) 0

session ที่รันซ้ำยังแสดงอีกสองข้อเท็จจริงที่สำคัญ: ถ้าไม่ตั้งค่าอะไร มีเพียงกฎ save ที่ปกป้องข้อมูล (appendonly no) และ WAIT 1 100 ตอบ 0 เพราะไม่มี replica เชื่อมอยู่ (รายละเอียดด้านล่าง)

ในแต่ละแถวผมสตาร์ต container ใหม่พร้อม volume เขียน 1,000 key, kill, สตาร์ตใหม่ แล้วถาม DBSIZE:

การตั้งค่าหยุดอย่างไรDBSIZE หลัง restart
ค่าเริ่มต้น (กฎ save ของ RDB, ไม่มี AOF)docker kill (SIGKILL)0 (/data ว่าง: ไม่เคยมี snapshot)
ค่าเริ่มต้นdocker stop (SIGTERM)1000
ค่าเริ่มต้น, BGSAVE หลัง key ที่ 1000 แล้วเขียนอีก 500docker kill1000 (500 ตัวหลัง snapshot หายไป)
--appendonly yes --appendfsync everysec SIGKILL ทันทีหลังเขียนครั้งสุดท้ายdocker kill1001 (ครบ)
--appendfsync always ทดสอบเหมือนกันdocker kill1001 (ครบ)
--appendfsync no ทดสอบเหมือนกันdocker kill1001 (ครบ)
AOF แล้วตัด 4 byte สุดท้ายของไฟล์ incr ออก (การเขียนที่ฉีก)restart999

สิ่งที่ควรได้จากมัน:

  1. RDB อย่างเดียวตามค่าเริ่มต้นเสียทุกอย่างตั้งแต่ snapshot ล่าสุด กฎเริ่มต้น snapshot เร็วสุด 60 วินาทีหลังเปลี่ยน 10,000 ครั้ง หรือหลังหนึ่งชั่วโมงสำหรับการเปลี่ยนหนึ่งครั้ง ไม่มีอะไรถูกทริกเกอร์ SIGKILL จึงเสียทั้งหมด
  2. การหยุดอย่างสุภาพไม่เสีย เมื่อได้ SIGTERM Redis บันทึกก่อน; log แสดง Saving the final RDB snapshot before exiting., BGSAVE done, 1000 keys saved และ key กลับมา นี่คือเหตุที่การ restart ตามแผนปลอดภัยและ crash ไม่ปลอดภัย
  3. สามโหมด appendfsync แยกไม่ออกที่นี่ และนั่นคือผลลัพธ์ที่ตรงไปตรงมา docker kill ฆ่า process ของ Redis; kernel ยังเป็นเจ้าของข้อมูลที่ Redis เขียนไปแล้ว มันจึงถึงดิสก์อยู่ดี appendfsync เปลี่ยนเฉพาะสิ่งที่รอดเมื่อ เครื่อง ตาย (ไฟดับ, kernel panic) เพราะตอนนั้นข้อมูลที่เขียนแล้วแต่ยังไม่ fsync จะหายไป ผมจำลองสิ่งนั้นใน container ไม่ได้ “ราวหนึ่งวินาที” ของ everysec จึงมาจาก เอกสาร ไม่ใช่จากการทดสอบนี้
  4. tail ที่ฉีกกู้ได้ aof-load-truncated yes (ค่าเริ่มต้น) โหลดส่วนที่สมบูรณ์และ log ว่า:
Terminal window
# Warning: short read while loading the AOF file appendonly.aof.1.incr.aof!!!
# Truncating the AOF appendonly.aof.1.incr.aof at offset 30884 !!!
# AOF appendonlydir/appendonly.aof.1.incr.aof loaded anyway because aof-load-truncated is enabled

redis-check-aof รายงานว่าไฟล์ไม่ถูกต้องก่อนหน้านั้น และ redis-check-aof --fix ตัดไฟล์ถึงคำสั่งดีสุดท้าย (INCR AOF ... is valid) อีกรายละเอียดที่ผมเจอโดยบังเอิญ: ความพยายามแรกของผมเติม key ด้วย Lua EVAL เดียว ซึ่งถูก log เป็นบล็อก MULTI ... EXEC เดียว การฉีก tail ของมันโหลดได้ 0 key เพราะ transaction ที่ไม่สมบูรณ์ถูกทิ้งทั้งก้อน

redis-benchmark -t set -n 30000 -r 100000 ภายในแต่ละ container (Mac เครื่องนี้, ดิสก์ของ Docker Desktop) ด้วย client หนึ่งตัวและ 50 ตัว:

การตั้งค่า1 client50 client
ไม่มี AOF21,536 req/s158,730 req/s
AOF appendfsync no23,383 req/s196,078 req/s
AOF everysec19,417 req/s159,574 req/s
AOF always2,671 req/s51,903 req/s

everysec อยู่ในช่วงสัญญาณรบกวนเทียบกับไม่มี persistence; always แพงราว 8 เท่ากับ client เดียว (ทุกการเขียนรอดิสก์) และ 3 เท่ากับ 50 client เพราะการเขียนพร้อมกันแชร์ fsync นั่นคือราคาของช่วงที่เสียเล็กที่สุด latency ที่ค่ากลางเพิ่มจาก 0.047 ms เป็น 0.343 ms (1 client) และจาก 0.135 ms เป็น 0.783 ms (50 client)

การเพิ่ม replica รู้สึกเหมือนตาข่ายนิรภัย ลองทดสอบ สอง container บน network แบบ internal, primary ที่สตาร์ตด้วย --save "" (ไม่มี RDB ไม่มี AOF) และ replica ที่ตามมัน:

Terminal window
$ redis-cli WAIT 1 500 # after writing 1000 keys on the primary
(integer) 1 # one replica acknowledged them
primary DBSIZE 1000, replica DBSIZE 1000
$ docker kill primary && docker start primary # a crash with a restart policy
primary DBSIZE 0, replica DBSIZE 0
replica log: MASTER <-> REPLICA sync: Flushing old data

primary กลับมาว่าง replica เชื่อมต่อใหม่ ทำ full resync จาก primary ที่ว่าง และ ล้างสำเนาของตัวเอง ทั้งสองสำเนาหายไป replica คือสำเนาสดของ สิ่งที่ primary ถืออยู่ตอนนี้ รวมถึงความว่างเปล่า (ตัวจัดการ cluster ที่ promote replica แทนการ restart primary ที่ว่างช่วยหลีกเลี่ยงได้ แต่นั่นคือ failover ไม่ใช่ persistence; การพึ่งจังหวะเวลาคือ bug) จึงต้องเปิด persistence บน primary และบน replica ด้วย

WAIT numreplicas timeout บล็อกจนกว่าจะมี replica ตามจำนวนที่ acknowledge การเขียนของ connection นี้ และคืนจำนวนที่ acknowledge การขอ 1 ทั้งที่ไม่มี replica เชื่อมอยู่คืน 0 หลัง timeout มันลดช่วงเสี่ยง แต่ไม่ได้ทำให้ replication เป็น synchronous และไม่รอดจาก failover ที่เลือก replica ที่ตามหลัง (เอกสาร)

service ที่ห้ามเสียออเดอร์ควรปฏิเสธการสตาร์ตกับเซิร์ฟเวอร์ที่เก็บมันไม่ได้ config('GET', ...) และ wait():

src/persistence-expiration-durability-check.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// fail fast at startup when the server does not have the durability this service needs
async function assertDurability(need: { aof: boolean }) {
const [, appendonly] = await redis.config('GET', 'appendonly') as string[];
const [, appendfsync] = await redis.config('GET', 'appendfsync') as string[];
const [, save] = await redis.config('GET', 'save') as string[];
console.log({ appendonly, appendfsync, save });
if (need.aof && appendonly !== 'yes') throw new Error('this service needs AOF (appendonly yes)');
}
try { await assertDurability({ aof: true }); } catch (e) { console.log('startup check:', (e as Error).message); }
// WAIT n ms: how many replicas acknowledged everything written so far by THIS connection. With no replicas it is 0
await redis.set('order:1', 'paid');
console.log('replicas that acknowledged:', await redis.wait(1, 100));
await redis.quit();

Output จริงบน image ค่าเริ่มต้น (นี่คือสิ่งที่ service จะเห็นกับเซิร์ฟเวอร์ที่ไม่ได้ตั้งค่า):

{
appendonly: 'no',
appendfsync: 'everysec',
save: '3600 1 300 100 60 10000'
}
startup check: this service needs AOF (appendonly yes)
replicas that acknowledged: 0

บริการแบบ managed อาจห้าม CONFIG GET; ในกรณีนั้นให้ตรวจผ่านการตั้งค่าของผู้ให้บริการหรือ INFO persistence (aof_enabled)

AI เขียน docker-compose.yml สำหรับ “Redis ที่ available สูง” persistence ถูกปิดเพราะ “replica เก็บสำเนาอยู่แล้ว”:

🐛 หา bug ในโค้ดนี้

services:
redis-primary:
image: redis:8
command: redis-server --save "" --appendonly no # the replica is our backup
restart: always
redis-replica:
image: redis:8
command: redis-server --replicaof redis-primary 6379
เฉลย

restart: always เปลี่ยนทุก crash เป็น “primary restart แบบว่าง” จากนั้น replica ทำสิ่งที่ replica ทำ: resync จาก primary และล้างข้อมูลของตัวเอง ในการทดสอบสอง container ข้างบนทั้งคู่มี 1,000 key ก่อน crash และ 0 หลังนั้น ให้ primary มีการตั้งค่า persistence จริงพร้อม volume, คงไว้ที่ replica ด้วย และทดสอบด้วยการ kill:

command: redis-server --appendonly yes --appendfsync everysec
volumes: ["redis-data:/data"]

Durability คือ ดิสก์ตามหลังหน่วยความจำอยู่เท่าไรตอนไฟดับ RDB อย่างเดียวทิ้งช่องว่างไว้ที่ “ตั้งแต่ snapshot ล่าสุด”, AOF everysec ราวหนึ่งวินาที, always แทบไม่มี replica คือสำเนาอีกชุดของหน่วยความจำ ไม่ใช่ดิสก์อีกแบบ และทุกอย่างข้างบนสมมติว่าดิสก์เป็น volume ที่อยู่รอดพ้น container

ตัวเลือกข้อดีข้อเสีย
RDB อย่างเดียวไม่มีต้นทุนต่อการเขียน; backup กะทัดรัดcrash เสียทุกอย่างตั้งแต่ snapshot ล่าสุด (0 จาก 1000 key ในการทดสอบ SIGKILL); การหยุดตามแผนปลอดภัย
AOF everysecเสี่ยงราวหนึ่งวินาทีด้วยต้นทุนแทบเป็นศูนย์ (159k เทียบ 159k req/s)ไฟล์ใหญ่ขึ้น; ยังเสียได้ถึงหนึ่งวินาทีเมื่อไฟดับ
AOF alwaysช่วงที่เสียเล็กที่สุด2,671 req/s เทียบ 21,536 กับ client เดียว (ช้าลง 8 เท่า)
AOF noAOF ที่เร็วที่สุดข้อมูลที่เสียเมื่อไฟดับขึ้นกับช่วง flush ของ OS
replica เป็นตาข่ายนิรภัยเดียวfailover เร็วprimary restart แบบว่างและล้าง replica
WAITเพิ่มความมั่นใจว่าการเขียนถึง replicaบล็อกผู้เรียก; ไม่ใช่การรับประกันข้าม failover
  • “ต้องใช้ appendfsync always เพื่อรอดจาก kill -9” ทั้งสามโหมดเก็บทุก key ในการทดสอบ SIGKILL; fsync สำคัญเมื่อเครื่องล้มเหลว
  • “มี replica แปลว่าไม่ต้อง persistence” primary ที่ restart แบบว่างทำให้ replica ล้างข้อมูล
  • “Redis เสียทุกอย่างเมื่อ AOF เสีย” tail ที่ฉีกโหลดได้ด้วย aof-load-truncated (999 จาก 1000 key) และ redis-check-aof --fix ซ่อมไฟล์
  • “หยุด container ค่าเริ่มต้นแล้วข้อมูลหาย” docker stop (SIGTERM) บันทึก snapshot สุดท้าย; SIGKILL และ OOM kill ไม่ทำ
  • “WAIT ทำให้การเขียนเป็น synchronous” มันรายงานว่ากี่ replica acknowledge; ไม่ได้ย้อนการเขียน
  • ไม่มี volume บน /data — ไฟล์หายไปพร้อม container แม้เปิด persistence
  • เชื่อ replica แทน persistence — ดูแบบฝึกหัด
  • ไม่เคย kill process เพื่อทดสอบการกู้คืน — ทำสักครั้งก่อนที่จะพึ่งพามัน
  • appendfsync always โดยไม่วัดผล — เขียนได้น้อยลง 8 เท่าต่อวินาทีกับ client เดียวบนเครื่องนี้

Real-world example

เอกสารของ Redis เอง เปรียบเทียบ RDB, AOF และ appendfsync และระบุช่วงเสียข้อมูล “ราวหนึ่งวินาที” ของ everysec (Redis persistence); ความหมายของ replication และ WAIT อยู่ใน Replication

โหมด appendfsync ไหนมีช่วงที่เสียเล็กที่สุดเมื่อเครื่องไฟดับ?
โหมด appendfsync ไหนเป็นค่าเริ่มต้นและเป็นตัวเลือกที่พบบ่อย?
คุณเขียน 1000 key ลง Redis ค่าเริ่มต้น (ไม่มี AOF) แล้ว docker kill DBSIZE หลัง restart บอกอะไร?
ทำไมทั้งสามโหมด appendfsync จึงเก็บทุก key หลัง docker kill?
primary ที่ปิด persistence ถูก restart: always สั่ง restart ข้อมูลของ replica เป็นอย่างไร?
4 byte สุดท้ายของไฟล์ incr AOF ถูกฉีกใน crash ด้วยค่าเริ่มต้น aof-load-truncated yes Redis...
คุณเรียก WAIT 1 100 แล้วได้ 0 หมายความว่าอะไร?