Eviction Policies
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...ถูก harness ของคอร์สรันซ้ำ; การรัน eviction คือสคริปต์@runที่ตั้งmaxmemoryเป็น 4 MB (เซิร์ฟเวอร์ 8.10.2 ที่ว่างใช้อยู่แล้วราว 2.4 MB ดังนั้น 4 MB เหลือที่พอสำหรับ key เล็กราว 17,000 ตัว); การทดสอบ container ถูก OOM-kill รันใน container ชั่วคราวของผมที่จำกัดด้วย Docker 64 MB เอกสารอ้างอิง: Key eviction
ตั้งขีดจำกัดหน่วยความจำ
หัวข้อที่มีชื่อว่า “ตั้งขีดจำกัดหน่วยความจำ”maxmemory จำกัดหน่วยความจำที่ Redis ใช้กับข้อมูล ค่าเริ่มต้นคือ 0 หมายถึงไม่จำกัด เมื่อการเขียนจะดันหน่วยความจำเกินเพดาน maxmemory-policy ตัดสินว่าจะเกิดอะไร: ไล่ของบางอย่างออก หรือปฏิเสธการเขียน ทั้งสองเปลี่ยนตอน runtime ได้ด้วย CONFIG SET และ policy ที่ไม่รู้จักจะบอกรายการที่ถูกต้อง ซึ่งบน 8.10.2 ยาวสิบเอ็ดตัว:
127.0.0.1:6379> CONFIG GET maxmemory1) "maxmemory"2) "0"127.0.0.1:6379> CONFIG GET maxmemory-policy1) "maxmemory-policy"2) "noeviction"127.0.0.1:6379> CONFIG GET maxmemory-samples1) "maxmemory-samples"2) "5"127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lruOK127.0.0.1:6379> CONFIG SET maxmemory 4mbOK127.0.0.1:6379> CONFIG GET maxmemory1) "maxmemory"2) "4194304"127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lrmOK127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-nonsense(error) ERR CONFIG SET failed (possibly related to argument 'maxmemory-policy') - argument(s) must be one of the following: volatile-lru, volatile-lfu, volatile-random, volatile-ttl, volatile-lrm, allkeys-lru, allkeys-lfu, allkeys-random, allkeys-lrm, noeviction127.0.0.1:6379> CONFIG SET maxmemory 0OK127.0.0.1:6379> CONFIG SET maxmemory-policy noevictionOKmaxmemory 512mbmaxmemory-policy allkeys-lrumaxmemory-samples 5maxmemory-samples 5 เป็นค่าเริ่มต้น: LRU และ LFU เป็นการ ประมาณ ที่ดู key สุ่ม 5 ตัวและไล่ตัวที่ดีที่สุดในกลุ่มนั้น ไม่ใช่ลำดับทั้งหมด ยิ่ง sample มากยิ่งใกล้ LRU จริงด้วย CPU ต่อการไล่ที่มากขึ้น (เอกสาร) รายละเอียดการนับหนึ่งอย่าง: เมื่อมี replication หรือ persistence บาง buffer ไม่นับรวมในขีดจำกัด (mem_not_counted_for_evict ใน INFO memory) จึงควรเหลือที่ว่างต่ำกว่าขีดจำกัดของ container
สิบเอ็ด policy
หัวข้อที่มีชื่อว่า “สิบเอ็ด policy”| Policy | พฤติกรรม |
|---|---|
noeviction | ปฏิเสธการเขียนที่ต้องใช้หน่วยความจำ (ค่าเริ่มต้น); การอ่านและ DEL ยังทำงาน |
allkeys-lru | ไล่ key ที่ ถูกใช้ (อ่านหรือเขียน) นานที่สุด |
allkeys-lrm | ไล่ key ที่ ถูกแก้ไข นานที่สุด (8.x): การอ่านไม่ปกป้อง key |
allkeys-lfu | ไล่ key ที่ถูกใช้ บ่อย น้อยที่สุด |
allkeys-random | ไล่ key แบบสุ่ม |
volatile-lru / volatile-lrm / volatile-lfu / volatile-random | สี่ตัวเดียวกัน แต่เฉพาะใน key ที่ มี TTL |
volatile-ttl | ไล่ key ที่ TTL เหลือน้อยที่สุด (เฉพาะ key ที่มี TTL) |
ตารางเวอร์ชันเก่าของบทนี้ระบุ 8 policy; allkeys-lrm และ volatile-lrm ถูก 8.10.2 ยอมรับ (ดู transcript ข้างบน) และอยู่ในเอกสาร
แต่ละ policy ทำอะไรเมื่อหน่วยความจำเต็ม
หัวข้อที่มีชื่อว่า “แต่ละ policy ทำอะไรเมื่อหน่วยความจำเต็ม”ทั้งหมดนี้มาจากสคริปต์เดียว มันตั้ง maxmemory เป็น 4 MB และเขียนค่าขนาด 100 byte ผลรายงานต่อ entry ของ pipeline (ใน ioredis การเขียนที่ถูกปฏิเสธเป็น tuple ของ error ไม่ใช่ exception ที่ถูกโยน):
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const val = 'x'.repeat(100);
async function reset(policy: string) { await redis.flushall(); await redis.config('SET', 'maxmemory', '0'); await redis.config('RESETSTAT'); await redis.config('SET', 'maxmemory-policy', policy); await redis.config('SET', 'maxmemory', String(4 * 1024 * 1024)); // 4 MB: the empty server already uses ~2.4 MB}const evicted = async () => Number((await redis.info('stats')).match(/evicted_keys:(\d+)/)![1]);
// pipeline in batches; results are [error, value] tuples, so a refused write shows up as an error entry, not a throwasync function fill(prefix: string, count: number, ttl?: number, touch?: string) { let written = 0; let firstError: string | undefined; for (let start = 0; start < count; start += 500) { const p = redis.pipeline(); for (let i = start; i < Math.min(count, start + 500); i++) ttl ? p.set(`${prefix}${i}`, val, 'EX', ttl) : p.set(`${prefix}${i}`, val); if (touch) p.get(touch); // a READ of one key per batch for (const [err] of (await p.exec())!) err ? (firstError ??= err.message) : written++; if (firstError) break; } return { written, firstError };}
// 1) noeviction: reads and DEL still work, new writes failawait reset('noeviction');const full = await fill('k:', 100000);console.log('noeviction:', full.written, 'writes ok, then:', full.firstError);console.log(' GET still works:', (await redis.get('k:0')) === val, '| evicted_keys:', await evicted());
// 2) allkeys-*: 500 "hot" keys are READ constantly and never rewritten; 500 "cold" keys are equally old and never touchedasync function survivors(prefix: string) { let n = 0; for (let i = 0; i < 500; i++) n += await redis.exists(`${prefix}${i}`); return n;}// the LRU clock has a 1-second resolution, so the fill is spread over ~6 seconds: reads must happen at different seconds than the writesfor (const policy of ['allkeys-lru', 'allkeys-lrm', 'allkeys-lfu']) { await reset(policy); for (let i = 0; i < 500; i++) await redis.set(`hot:${i}`, val).then(() => redis.set(`cold:${i}`, val)); for (let stage = 0; stage < 6; stage++) { await new Promise((r) => setTimeout(r, 1050)); const p = redis.pipeline(); for (let i = 0; i < 500; i++) p.get(`hot:${i}`); // reads only, every stage for (let i = 0; i < 7000; i++) p.set(`k:${stage}:${i}`, val); await p.exec(); } console.log(policy.padEnd(12), 'evicted', String(await evicted()).padStart(5), '| hot left', await survivors('hot:'), '/ 500 | cold left', await survivors('cold:'), '/ 500');}
// 3) volatile-lru with NO ttl anywhere behaves like noevictionawait reset('volatile-lru');const noTtl = await fill('k:', 100000);console.log('volatile-lru, no TTLs:', noTtl.written, 'writes ok, then:', noTtl.firstError, '| evicted', await evicted());
// 4) volatile-lru with 1000 persistent keys and many expiring ones: only expiring keys are candidatesawait reset('volatile-lru');await fill('keep:', 1000);const tmp = await fill('tmp:', 60000, 3600);let kept = 0;for (let i = 0; i < 1000; i++) kept += await redis.exists(`keep:${i}`);console.log('volatile-lru mixed: error', tmp.firstError ?? 'none', '| evicted', await evicted(), '| persistent keys left', kept, 'of 1000');
// 5) volatile-ttl: the shortest remaining TTLs go firstawait reset('volatile-ttl');await fill('short:', 2000, 100);await fill('long:', 2000, 100000);await fill('fill:', 40000, 50000);let short = 0, long = 0;for (let i = 0; i < 2000; i++) { short += await redis.exists(`short:${i}`); long += await redis.exists(`long:${i}`); }console.log('volatile-ttl: EX 100 keys left', short, '/ 2000 | EX 100000 keys left', long, '/ 2000');
// leave the server as we found itawait redis.config('SET', 'maxmemory', '0');await redis.config('SET', 'maxmemory-policy', 'noeviction');await redis.flushall();await redis.quit();Output จริง (Mac เครื่องนี้):
noeviction: 17032 writes ok, then: OOM command not allowed when used memory > 'maxmemory'. GET still works: true | evicted_keys: 0allkeys-lru evicted 26504 | hot left 500 / 500 | cold left 1 / 500allkeys-lrm evicted 26537 | hot left 2 / 500 | cold left 1 / 500allkeys-lfu evicted 26339 | hot left 500 / 500 | cold left 154 / 500volatile-lru, no TTLs: 17111 writes ok, then: OOM command not allowed when used memory > 'maxmemory'. | evicted 0volatile-lru mixed: error none | evicted 46653 | persistent keys left 1000 of 1000volatile-ttl: EX 100 keys left 0 / 2000 | EX 100000 keys left 2000 / 2000ตัวเลขที่แน่นอนขยับเล็กน้อยทุกครั้งที่รัน เพราะการไล่ sample key แบบสุ่ม (ในสี่การรันของผม: LFU เหลือ key เย็นระหว่าง 11 ถึง 154, LRM เหลือ key ร้อนระหว่าง 0 ถึง 7); รูปร่างไม่เปลี่ยน
อ่านผล:
noevictionเขียนได้ราว 17,000 key แล้วทุกการเขียนได้OOM command not allowed when used memory > 'maxmemory'.การอ่านทำงานevicted_keysคงที่ 0 ฐานข้อมูลไม่เสียหาย แค่ตอบว่าไม่ (ตัวเลขต่างกันไม่กี่ร้อย key ในแต่ละรอบ)allkeys-lru: key “ร้อน” 500 ตัวที่ถูกอ่านทุกช่วงรอด (500 จาก 500) ขณะที่ key “เย็น” 500 ตัวที่เก่าเท่ากันถูกกวาดหมด (เหลือ 0 หรือ 1) นี่คือพฤติกรรม cache แบบคลาสสิกallkeys-lrm: key ร้อนถูก อ่าน อย่างเดียว ไม่เคยถูกแก้ไข จึงถูกไล่เหมือนทุกอย่าง (เหลือ 0 ถึง 7 จาก 500) ใช้ LRM เมื่อ “เพิ่งเขียน” สำคัญกว่า “เพิ่งอ่าน”allkeys-lfu: key ที่อ่านบ่อยรอด (500 จาก 500) และในหมู่ key ที่ไม่ถูกแตะบางตัวรอด (154 จาก 500 ที่นี่, 11 ในอีกรอบ): เมื่อความถี่เท่ากัน การเลือกในหมู่ sample เป็นไปตามอำเภอใจvolatile-*ที่ไม่มี TTL ทำตัวเหมือนnoevictionเขียนได้ราว 17,100 ครั้งแล้วOOMโดยevicted_keysเป็น 0: ไม่มีอะไรที่มีสิทธิ์ถูกไล่ นี่คือกับดักใน production จริง: policy ของ cache ที่ตั้งเป็นvolatile-lruกับ key ที่ลืม TTL จะทำให้การเขียนล้มเหลวแทนที่จะไล่volatile-lruที่มี key ถาวร 1,000 ตัวและ key ที่หมดอายุได้จำนวนมาก ไล่ key ที่หมดอายุได้ราว 46,600 ตัวและไม่แตะ key ถาวรเลย (เหลือ 1000 จาก 1000)volatile-ttl: กับ keyEX 1002,000 ตัว,EX 1000002,000 ตัว และตัวเติมEX 5000040,000 ตัว ทุก key ที่ TTL สั้นถูกไล่ (เหลือ 0 จาก 2,000) และทุก key ที่ TTL ยาวรอด (2,000 จาก 2,000)
allkeys-lru และ -lrm ต่างกันก็ต่อเมื่อนาฬิกา LRU แยกช่วงเวลาได้: มัน มีความละเอียดหนึ่งวินาที ดังนั้นการทดสอบเวอร์ชันแรกที่เร็วกว่าของผม (ทุกอย่างในหนึ่งวินาที) แยก key ร้อนกับเย็นไม่ได้เลย (รอด 170 เทียบ 154) สคริปต์จึงกระจายการเติมไปราวหกวินาที อย่าสรุปอะไรเกี่ยวกับ LRU จากการทดสอบที่ไม่ถึงหนึ่งวินาที
CONFIG GET maxmemory-policy
CONFIG SET maxmemory-policy allkeys-lru
CONFIG GET maxmemory
CONFIG SET maxmemory 256mb
CONFIG SET maxmemory 0
CONFIG SET maxmemory-policy noevictionเมื่อไม่ตั้ง maxmemory: OOM killer เป็นผู้ตัดสิน
หัวข้อที่มีชื่อว่า “เมื่อไม่ตั้ง maxmemory: OOM killer เป็นผู้ตัดสิน”เมื่อไม่มี maxmemory Redis ก็จัดสรรต่อไปเรื่อยๆ ภายใน container ที่มีขีดจำกัดหน่วยความจำ จบด้วย kernel ฆ่า process และข้อมูลทั้งหมดหายไปด้วย image เดียวกัน ขีดจำกัด Docker 64 MB เดียวกัน (-m 64m) loop เติมค่าขนาด 1 KB เดียวกัน ทั้งมีและไม่มี --maxmemory 40mb:
=== A: container limit 64m, maxmemory unsetchunk 4 -> 40000chunk 5 -> 50000chunk 6 -> Error: Server closed the connectionstatus=exited OOMKilled=true exit=137=== B: same limit, --maxmemory 40mb (noeviction)chunk 4 -> 40000chunk 5 -> OOM command not allowed when used memory > 'maxmemory'. script: ...status=running OOMKilled=false exit=0DBSIZE: 40000, GET still worksใน A container ถูกฆ่า (OOMKilled=true, exit code 137) ที่ราว 50,000 key และทุกอย่างในหน่วยความจำหาย; ใน B เซิร์ฟเวอร์ปฏิเสธการเขียน ยังทำงานอยู่ และให้บริการ 40,000 key ที่มี ตั้ง maxmemory ต่ำกว่าขีดจำกัดของ container หรือเครื่อง (เหลือที่สำหรับ fork ของ BGSAVE, replication buffer และ fragmentation) เพื่อให้ Redis ไม่ใช่ kernel เป็นผู้เลือกว่าอะไรล้มเหลว
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI จัดทำ container ของ cache เพื่อ “เก็บข้อมูลให้ปลอดภัย” มันปล่อย eviction เป็นค่าเริ่มต้นและให้ orchestrator จำกัดหน่วยความจำ:
🐛 หา bug ในโค้ดนี้
services:cache: image: redis:8 deploy: resources: limits: memory: 64M # no maxmemory configured, default policyเฉลย
ไม่มีอะไรบอก Redis เรื่องขีดจำกัด 64 MB maxmemory ของมันคือ 0 มันจึงโตจนกว่า kernel จะ OOM-kill ในการรันของเรา container ตายด้วย OOMKilled=true, exit 137 และเสียทุก key และ restart loop จะทำซ้ำตลอดไป เมื่อใช้ --maxmemory 40mb โหลดเดียวกันให้ error OOM command not allowed ที่สะอาดและเซิร์ฟเวอร์ยังอยู่ สำหรับ cache ใช้ policy allkeys-* เพื่อให้การเขียนสำเร็จด้วยการไล่:
command: redis-server --maxmemory 48mb --maxmemory-policy allkeys-lruMental Model
หัวข้อที่มีชื่อว่า “Mental Model”maxmemory คือ ขนาดของห้อง และ policy คือ สิ่งที่เกิดเมื่อห้องเต็ม: โยนเฟอร์นิเจอร์ออก (ไล่ และชิ้นไหน: ใช้ล่าสุดน้อยสุด, แก้ไขล่าสุดน้อยสุด, ใช้บ่อยน้อยสุด, ใกล้หมดอายุที่สุด) หรือไล่คนที่ประตู (noeviction) ถ้าไม่มีขนาดห้อง ขีดจำกัดของตัวอาคารจะมีผล และขีดจำกัดนั้นไม่สุภาพ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
noeviction | ไม่ทิ้งข้อมูล; อ่านและ DEL ทำงานต่อ | การเขียนล้มเหลวด้วย OOM เมื่อเต็ม; ต้องเฝ้าดู |
allkeys-lru | ค่าเริ่มต้น cache ที่ดีที่สุดเมื่อบางส่วนร้อน: เก็บ key ร้อนได้ 500/500 | เป็นการประมาณ (samples 5) อาจไล่สิ่งที่คุณต้องการ |
allkeys-lrm | ไล่สิ่งที่ไม่ได้ถูก เขียน เร็วๆ นี้ | key ที่ร้อนด้านการอ่านถูกไล่ (เก็บ key ร้อนได้ 0/500) |
allkeys-lfu | ปกป้อง key ที่อ่านบ่อย | key ที่ไม่ถูกแตะเสมอกัน; ต้องมี traffic พอสร้างตัวนับ |
volatile-* | เฉพาะ key ที่หมดอายุได้เป็นผู้มีสิทธิ์ ข้อมูลถาวรปลอดภัย | ถ้าไม่มี TTL มันคือ noeviction ปลอมตัว |
ไม่มี maxmemory | ไม่ต้องตั้งค่าอะไร | kernel OOM-kill process (exit 137) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Redis มีขีดจำกัดหน่วยความจำเป็นค่าเริ่มต้น”
maxmemoryเป็น0(ไม่จำกัด) - “
volatile-lruไล่อะไรสักอย่างเมื่อหน่วยความจำเต็ม” เฉพาะ key ที่มี TTL เท่านั้นที่มีสิทธิ์; ถ้าไม่มีเลย การเขียนล้มเหลว - “LRU แม่นยำ” มัน sample 5 key เป็นค่าเริ่มต้น และนาฬิกามีความละเอียดหนึ่งวินาที
- “การอ่าน key ปกป้องมันในทุก policy” ไม่ใช่ใน
allkeys-lrm/volatile-lrm - “
noevictionแปลว่า Redis หยุดทำงานเมื่อเต็ม” อ่านและDELยังทำงาน; เฉพาะการเขียนที่ทำให้หน่วยความจำโตล้มเหลว
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- cache ใน production ที่ใช้
noeviction— ทุกการเขียน error เมื่อเต็ม - ไม่เคยตั้ง
maxmemory— container หรือโฮสต์หมดก่อน volatile-*กับ key ที่สูญเสีย TTL — การเขียนล้มเหลวแทนที่จะไล่ (SETที่ไม่มีKEEPTTLลบ TTL)- ตั้ง
maxmemoryเท่ากับขีดจำกัดของ container — copy-on-write ของ fork, output buffer และ fragmentation ต้องการที่เผื่อ - ตัดสิน LRU ด้วยการทดสอบที่ไม่ถึงหนึ่งวินาที — ความละเอียดของนาฬิกาคือหนึ่งวินาที
Real-world example
บทความสถาปัตยกรรม Stack Overflow ปี 2016 (Nick Craver, 2016) รายงานว่ามีการทำงาน Redis ราว 160 พันล้านครั้งต่อเดือน และ instance Redis สำหรับ machine learning แยกต่างหาก (“Providence”) บนเซิร์ฟเวอร์เฉพาะ “due to memory” instance ที่แยกกันทำให้แต่ละงานมี
maxmemoryและ policy ของตัวเอง บทความนี้อายุสิบปีแล้ว ประเด็นคือการแยก ไม่ใช่ขนาด