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

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 ยาวสิบเอ็ดตัว:

cli/persistence-expiration-eviction-policies-1.redis
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "0"
127.0.0.1:6379> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
127.0.0.1:6379> CONFIG GET maxmemory-samples
1) "maxmemory-samples"
2) "5"
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lru
OK
127.0.0.1:6379> CONFIG SET maxmemory 4mb
OK
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "4194304"
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lrm
OK
127.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, noeviction
127.0.0.1:6379> CONFIG SET maxmemory 0
OK
127.0.0.1:6379> CONFIG SET maxmemory-policy noeviction
OK
config/persistence-expiration-eviction-policies-conf.conf
maxmemory 512mb
maxmemory-policy allkeys-lru
maxmemory-samples 5

maxmemory-samples 5 เป็นค่าเริ่มต้น: LRU และ LFU เป็นการ ประมาณ ที่ดู key สุ่ม 5 ตัวและไล่ตัวที่ดีที่สุดในกลุ่มนั้น ไม่ใช่ลำดับทั้งหมด ยิ่ง sample มากยิ่งใกล้ LRU จริงด้วย CPU ต่อการไล่ที่มากขึ้น (เอกสาร) รายละเอียดการนับหนึ่งอย่าง: เมื่อมี replication หรือ persistence บาง buffer ไม่นับรวมในขีดจำกัด (mem_not_counted_for_evict ใน INFO memory) จึงควรเหลือที่ว่างต่ำกว่าขีดจำกัดของ container

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 ข้างบน) และอยู่ในเอกสาร

ทั้งหมดนี้มาจากสคริปต์เดียว มันตั้ง maxmemory เป็น 4 MB และเขียนค่าขนาด 100 byte ผลรายงานต่อ entry ของ pipeline (ใน ioredis การเขียนที่ถูกปฏิเสธเป็น tuple ของ error ไม่ใช่ exception ที่ถูกโยน):

src/persistence-expiration-eviction-policies-run.ts
// @run
import { 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 throw
async 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 fail
await 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 touched
async 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 writes
for (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 noeviction
await 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 candidates
await 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 first
await 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 it
await 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: 0
allkeys-lru evicted 26504 | hot left 500 / 500 | cold left 1 / 500
allkeys-lrm evicted 26537 | hot left 2 / 500 | cold left 1 / 500
allkeys-lfu evicted 26339 | hot left 500 / 500 | cold left 154 / 500
volatile-lru, no TTLs: 17111 writes ok, then: OOM command not allowed when used memory > 'maxmemory'. | evicted 0
volatile-lru mixed: error none | evicted 46653 | persistent keys left 1000 of 1000
volatile-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: กับ key EX 100 2,000 ตัว, EX 100000 2,000 ตัว และตัวเติม EX 50000 40,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 Redis ก็จัดสรรต่อไปเรื่อยๆ ภายใน container ที่มีขีดจำกัดหน่วยความจำ จบด้วย kernel ฆ่า process และข้อมูลทั้งหมดหายไปด้วย image เดียวกัน ขีดจำกัด Docker 64 MB เดียวกัน (-m 64m) loop เติมค่าขนาด 1 KB เดียวกัน ทั้งมีและไม่มี --maxmemory 40mb:

Terminal window
=== A: container limit 64m, maxmemory unset
chunk 4 -> 40000
chunk 5 -> 50000
chunk 6 -> Error: Server closed the connection
status=exited OOMKilled=true exit=137
=== B: same limit, --maxmemory 40mb (noeviction)
chunk 4 -> 40000
chunk 5 -> OOM command not allowed when used memory > 'maxmemory'. script: ...
status=running OOMKilled=false exit=0
DBSIZE: 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 เป็นผู้เลือกว่าอะไรล้มเหลว

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-lru

maxmemory คือ ขนาดของห้อง และ policy คือ สิ่งที่เกิดเมื่อห้องเต็ม: โยนเฟอร์นิเจอร์ออก (ไล่ และชิ้นไหน: ใช้ล่าสุดน้อยสุด, แก้ไขล่าสุดน้อยสุด, ใช้บ่อยน้อยสุด, ใกล้หมดอายุที่สุด) หรือไล่คนที่ประตู (noeviction) ถ้าไม่มีขนาดห้อง ขีดจำกัดของตัวอาคารจะมีผล และขีดจำกัดนั้นไม่สุภาพ

ตัวเลือกข้อดีข้อเสีย
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)
  • “Redis มีขีดจำกัดหน่วยความจำเป็นค่าเริ่มต้น” maxmemory เป็น 0 (ไม่จำกัด)
  • “volatile-lru ไล่อะไรสักอย่างเมื่อหน่วยความจำเต็ม” เฉพาะ key ที่มี TTL เท่านั้นที่มีสิทธิ์; ถ้าไม่มีเลย การเขียนล้มเหลว
  • “LRU แม่นยำ” มัน sample 5 key เป็นค่าเริ่มต้น และนาฬิกามีความละเอียดหนึ่งวินาที
  • “การอ่าน key ปกป้องมันในทุก policy” ไม่ใช่ใน allkeys-lrm/volatile-lrm
  • “noeviction แปลว่า Redis หยุดทำงานเมื่อเต็ม” อ่านและ DEL ยังทำงาน; เฉพาะการเขียนที่ทำให้หน่วยความจำโตล้มเหลว
  • 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 ของตัวเอง บทความนี้อายุสิบปีแล้ว ประเด็นคือการแยก ไม่ใช่ขนาด

eviction policy ไหนไล่ key ที่ใช้ล่าสุดน้อยที่สุดจาก key ทั้งหมด?
ค่าเริ่มต้นของ maxmemory และ maxmemory-policy คืออะไร?
คุณตั้ง volatile-lru แต่ไม่มี key ใดมี TTL และหน่วยความจำเต็ม เกิดอะไรกับการเขียน?
policy volatile-ttl ไล่ key ตามเกณฑ์อะไร?
ภายใต้ allkeys-lrm เกิดอะไรกับ key ที่ถูกอ่านตลอดแต่ไม่เคยถูกแก้ไข?
Redis รันใน container 64 MB โดยไม่ตั้ง maxmemory และรับการเขียนต่อเนื่อง อะไรจบเรื่อง?
คุณทดสอบ allkeys-lru โดยเขียนทุกอย่างในครึ่งวินาที อ่าน key เดียวตลอด ทำไมผลจึงเชื่อถือไม่ได้?