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

Expiration & TTL

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ค่า TTL ที่แสดงคือสิ่งที่เซิร์ฟเวอร์คืนตอนรันคำสั่ง; ถ้าอ่านจาก transcript ในบทหลังจากนั้นหนึ่งวินาทีจะเห็นน้อยลงหนึ่ง

Redis มักถูกใช้เป็น cache หรือ session store ทั้งสองกรณีคุณอยากให้ key หายไปเองหลังเวลาหนึ่ง — ไม่ควรต้องเขียน cleanup job Redis จัดการเรื่องนี้ในตัวผ่าน key expiration

คุณผูก TTL (time-to-live) ได้ตอนสร้าง key หรือเพิ่มให้ key ที่มีอยู่แล้ว:

คำสั่งคำอธิบาย
SET key val EX secsสร้าง + หมดอายุเป็นวินาที
SET key val PX msสร้าง + หมดอายุเป็นมิลลิวินาที
EXPIRE key secsตั้ง/อัปเดต expiry เป็นวินาทีบน key ที่มีอยู่
PEXPIRE key msตั้ง/อัปเดต expiry เป็นมิลลิวินาที
EXPIREAT key unix-secsหมดอายุที่ Unix timestamp สัมบูรณ์ (วินาที)
PEXPIREAT key unix-msหมดอายุที่ Unix timestamp สัมบูรณ์ (มิลลิวินาที)
cli/keys-strings-expiration-ttl-1.redis
127.0.0.1:6379> SET session:abc "user:42" EX 300
OK
127.0.0.1:6379> TTL session:abc
(integer) 300
127.0.0.1:6379> SET cache:home "<html>...</html>"
OK
127.0.0.1:6379> EXPIRE cache:home 60
(integer) 1
127.0.0.1:6379> TTL cache:home
(integer) 60
127.0.0.1:6379> EXPIRE nokey 60
(integer) 0

อ่านค่าจริง: TTL ทันทีหลัง EX 300 คือ 300 ไม่ใช่ 299 — อีกประมาณหนึ่งวินาทีต่อมาจึงจะเห็น 299 EXPIRE คืน 1 ถ้าตั้ง timeout ได้ และ 0 ถ้า key ไม่มีอยู่

SET session:abc "user:42" EX 300
TTL session:abc
SET cache:home "<html>...</html>"
EXPIRE cache:home 60
TTL cache:home

TTL คืนวินาที; PTTL คืนมิลลิวินาที ทั้งสองมีค่าพิเศษสองค่า:

  • -1 — key มีอยู่แต่ ไม่มี expiry (คงอยู่ถาวร)
  • -2 — key ไม่มีอยู่ (หรือหมดอายุไปแล้ว)
cli/keys-strings-expiration-ttl-2.redis
127.0.0.1:6379> SET persistent "I live forever"
OK
127.0.0.1:6379> TTL persistent
(integer) -1
127.0.0.1:6379> TTL nonexistent:key
(integer) -2
127.0.0.1:6379> SET brief "gone soon" PX 400
OK
127.0.0.1:6379> TTL brief
(integer) 0
127.0.0.1:6379> EXISTS brief
(integer) 1
127.0.0.1:6379> SET rounddown x PX 1400
OK
127.0.0.1:6379> TTL rounddown
(integer) 1
127.0.0.1:6379> SET roundup x PX 1600
OK
127.0.0.1:6379> TTL roundup
(integer) 2

TTL ถูกปัดเป็นวินาทีที่ใกล้ที่สุด ไม่ใช่ปัดลง และไม่ใช่นับถอยหลังลบหนึ่ง key ที่เหลือ 400 ms รายงาน TTL 0 ขณะที่ EXISTS ยังบอก 1 PX 1400 อ่านได้ 1 และ PX 1600 อ่านได้ 2 ใช้ PTTL เมื่อต้องการความแม่นยำระดับต่ำกว่าวินาที (PTTL brief อ่านได้ 278 ไม่นานหลังคำสั่งข้างบน)

SET persistent "I live forever"
TTL persistent
TTL nonexistent:key
SET brief "gone soon" PX 5000
PTTL brief

ถ้าต้องการยกเลิก expiry ของ key และทำให้มันคงอยู่ถาวรอีกครั้ง ใช้ PERSIST:

cli/keys-strings-expiration-ttl-3.redis
127.0.0.1:6379> SET promo "SALE10" EX 3600
OK
127.0.0.1:6379> TTL promo
(integer) 3600
127.0.0.1:6379> PERSIST promo
(integer) 1
127.0.0.1:6379> TTL promo
(integer) -1
127.0.0.1:6379> PERSIST promo
(integer) 0

PERSIST คืน 1 เฉพาะเมื่อมี timeout ให้ลบจริง

SET promo "SALE10" EX 3600
TTL promo
PERSIST promo
TTL promo

EXPIRE ธรรมดาเขียนทับ timeout เดิมไม่ว่าจะเป็นอะไร ตั้งแต่ 7.0 มี flag ทำให้การอัปเดตมีเงื่อนไข ซึ่งแก้ bug คลาสสิกหลายแบบ (“sliding window” ที่บังเอิญ ทำให้ TTL สั้นลง หรือ counter ของ rate limit ที่ window เริ่มใหม่ไม่หยุด):

Flagใช้ TTL ใหม่เฉพาะเมื่อ…
NXkey ยังไม่มี TTL
XXkey มี TTL อยู่แล้ว
GTTTL ใหม่ มากกว่า ปัจจุบัน (key ที่ไม่มี TTL นับเป็นอนันต์ ดังนั้น GT ไม่มีผลกับมัน)
LTTTL ใหม่ น้อยกว่า ปัจจุบัน (ไม่มี TTL นับเป็นอนันต์ ดังนั้น LT มีผล)
cli/keys-strings-expiration-ttl-4.redis
127.0.0.1:6379> EXPIRE promo 100 NX
(integer) 1
127.0.0.1:6379> EXPIRE promo 200 NX
(integer) 0
127.0.0.1:6379> EXPIRE promo 50 GT
(integer) 0
127.0.0.1:6379> EXPIRE promo 300 GT
(integer) 1
127.0.0.1:6379> EXPIRE promo 400 LT
(integer) 0
127.0.0.1:6379> EXPIRE promo 10 LT
(integer) 1
127.0.0.1:6379> EXPIRE promo 10 XX
(integer) 1
127.0.0.1:6379> EXPIRE promo -1
(integer) 1
127.0.0.1:6379> EXISTS promo
(integer) 0

ไล่ทีละขั้น จาก key ถาวร: NX 100 ได้ผล (เพราะยังไม่มี TTL), NX 200 ถูกปฏิเสธ (ตอนนี้มีแล้ว), GT 50 ถูกปฏิเสธ (50 < 100), GT 300 ต่ออายุ, LT 400 ถูกปฏิเสธ, LT 10 ทำให้สั้นลง, XX 10 ได้ผล สองบรรทัดสุดท้ายแสดง timeout แบบ ติดลบ: EXPIRE promo -1 คืน 1 และ key ถูกลบทันที

หากต้องการอ่านกำหนดเวลาเป็นเวลาสัมบูรณ์แทนการนับถอยหลัง ใช้ EXPIRETIME / PEXPIRETIME (7.0+): EXPIRETIME promo พิมพ์ 1790747663 (Unix วินาที — ของคุณจะต่าง), -1 สำหรับ key ถาวร และ -2 สำหรับ key ที่ไม่มี

Redis ใช้สองกลยุทธ์ที่เสริมกัน — คุณไม่ต้องทำอะไร แต่การเข้าใจช่วยให้คิดเรื่องหน่วยความจำได้ถูก:

Lazy expiry — เมื่อคุณเข้าถึง key (GET, EXISTS ฯลฯ) Redis ตรวจ expiry ก่อน ถ้าหมดอายุ Redis ลบทันทีและคืน (nil) ราวกับไม่เคยมี

Active expiry — Redis รอบการทำงานเบื้องหลัง (hz ค่าเริ่มต้น 10 ครั้งต่อวินาที — CONFIG GET hz คืน 10) สุ่มตัวอย่าง key ที่มี TTL และลบตัวที่หมดอายุ ป้องกันไม่ให้ keyspace เต็มไปด้วย key ที่ไม่เคยถูกเข้าถึง

ดูทั้งสองแบบทำงานได้จริง คำสั่ง debug DEBUG SET-ACTIVE-EXPIRE 0 (ต้องสตาร์ทด้วย --enable-debug-command yes จึงใช้เฉพาะใน container ทิ้ง) ปิดรอบ active สาม key ด้วย PX 100 แล้วครึ่งวินาทีที่ไม่มีใครแตะมัน:

Terminal window
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 0
OK
127.0.0.1:6379> SET a 1 PX 100 # and b, c the same
127.0.0.1:6379> DBSIZE # 500 ms later
(integer) 3 # expired, but still in memory
127.0.0.1:6379> INFO keyspace
db0:keys=3,expires=3,avg_ttl=0,subexpiry=0
127.0.0.1:6379> GET a # lazy expiry: deleted on access
(nil)
127.0.0.1:6379> EXISTS b
(integer) 0
127.0.0.1:6379> DBSIZE
(integer) 1
127.0.0.1:6379> INFO stats # expired_keys:2 expired_keys_active:0
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 1 # 500 ms later, key c is gone by itself
127.0.0.1:6379> DBSIZE
(integer) 0
127.0.0.1:6379> INFO stats # expired_keys:3 expired_keys_active:1

expired_keys นับทุกการหมดอายุ; expired_keys_active (มีใน 8.10.2) นับตัวที่รอบเบื้องหลังลบ สองตัวถูกลบโดยการอ่าน (lazy) หนึ่งตัวโดยรอบเบื้องหลัง สองกลยุทธ์นี้รวมกันทำให้การใช้หน่วยความจำมีขอบเขตโดยไม่บล็อกแอป รอบ active จะหยุดก่อนเมื่อใช้งบ CPU หมด (INFO stats แสดง expired_time_cap_reached_count) การหมดอายุพร้อมกันเป็นจำนวนมากจึงอาจทำให้ key ที่ตายแล้วค้างในหน่วยความจำนานขึ้นเล็กน้อย

src/keys-strings-expiration-ttl-check.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const key = 'b1:tmp';
// the AI-written check: "TTL 0 means expired"
const looksExpired = async (k: string) => (await redis.ttl(k)) === 0;
await redis.set(key, 'x', 'PX', 400);
console.log('fresh key ttl:', await redis.ttl(key), 'looksExpired:', await looksExpired(key)); // 0 true (but it exists!)
console.log('missing key ttl:', await redis.ttl('b1:missing'), 'looksExpired:', await looksExpired('b1:missing')); // -2 false
// the fix: ask about existence, and use the millisecond clock for short TTLs
const isGone = async (k: string) => (await redis.exists(k)) === 0;
console.log('isGone now:', await isGone(key)); // false
await new Promise((r) => setTimeout(r, 600));
console.log('isGone 600 ms later:', await isGone(key)); // true
// EXPIRE flags: extend only (GT), set only if no TTL yet (NX)
await redis.set(key, 'x', 'EX', 100);
console.log(await redis.expire(key, 50, 'GT'), await redis.expire(key, 200, 'GT')); // 0 1
console.log(await redis.expire(key, 10, 'NX')); // 0
await redis.del(key);
await redis.quit();

output จริง: fresh key ttl: 0 looksExpired: true, missing key ttl: -2 looksExpired: false, isGone now: false, isGone 600 ms later: true แล้ว 0 1 และ 0 predicate ผิด ทั้งสองทิศทาง: รายงานว่า key ที่ยังสดหมดอายุแล้ว และ key ที่ไม่มีว่าไม่เป็นไร

AI เขียน helper ที่ตัดสินว่ารายการใน cache ต้องสร้างใหม่หรือไม่:

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

// returns true when the cached report has expired and must be regenerated
async function needsRebuild(key: string): Promise<boolean> {
const ttl = await redis.ttl(key);
return ttl === 0;
}
เฉลย

TTL ไม่เคยคืน 0 สำหรับ key ที่หายไปแล้ว — มันคืน -2 และมัน คืน 0 ขณะที่ key ยังมีชีวิตอยู่และเหลือไม่ถึงครึ่งวินาที ดังนั้น needsRebuild เป็น false สำหรับ key ที่ไม่มีทุกตัว (รายงานไม่ถูกสร้างใหม่หลังหมดอายุ) และเป็น true สำหรับ key ที่กำลังจะถูกเสิร์ฟอีกไม่กี่ร้อยมิลลิวินาที เรารันกับ key PX 400 และ key ที่ไม่มี: looksExpired ได้ true และ false ตามลำดับ ตรงข้ามกับเจตนาเป๊ะ วิธีแก้คือ EXISTS (หรือ TTL === -2 หรือ PTTL) ดังใน helper isGone ข้างบน

TTL คือ เส้นตายที่ประทับบน key ไม่ใช่นาฬิกาจับเวลาที่ key เดินเอง Redis เก็บเวลาหมดอายุแบบสัมบูรณ์; TTL แค่ลบ “ตอนนี้” ออกแล้วปัดเศษ เส้นตายถูกบังคับสองทาง: ใครก็ตามที่แตะ key หลังเส้นตายจะพบว่ามันหายไป (lazy) และตัวกวาดเบื้องหลังเดินไปหา key ที่มีเส้นตาย (active) อะไรก็ตามที่เขียน key ใหม่ (SET) ทิ้งเส้นตายไปเลย เว้นแต่จะบอก KEEPTTL

ตัวเลือกข้อดีข้อเสีย
ตั้ง TTL ให้ทุก key ของ cache/sessionหน่วยความจำถูกคืนอัตโนมัติ ไม่ต้องเขียน cleanup jobTTL ที่คำนวณผิดอาจทำให้ข้อมูลหมดอายุก่อนที่ระบบยังต้องใช้
พึ่ง lazy expiry (ตรวจตอนเข้าถึง)ไม่มี overhead เพิ่มถ้าไม่มีใครอ่าน key นั้นkey ที่หมดอายุแล้วแต่ไม่มีใครเข้าถึงยังกินหน่วยความจำจนกว่ารอบ active จะเจอ
ใช้ PERSIST ยกเลิก TTL ที่มีอยู่ต่ออายุข้อมูลสำคัญโดยไม่ต้องสร้าง key ใหม่ลืมง่ายว่า key นี้ตั้งใจให้ชั่วคราว ทำให้หน่วยความจำโตโดยไม่มีใครควบคุม
EXPIRE ... NX/GT/LT (7.0+)อัปเดตแบบมีเงื่อนไขในคำสั่ง atomic เดียว ไม่ต้อง read-modify-writeไม่มีบน Redis < 7.0 หรือบริการ managed รุ่นเก่า; โค้ดเก่าต้องใช้ WATCH หรือ Lua
  • “TTL นับถอยหลังทีละหนึ่งจากที่ตั้ง ดังนั้น EX 300 อ่านได้ 299” อ่านได้ 300 ก่อน และปัดเป็นวินาทีที่ใกล้ที่สุด
  • “TTL 0 แปลว่า key หายแล้ว” แปลว่าเหลือไม่ถึงครึ่งวินาที key ยังอยู่ ที่หายแล้วคือ -2
  • “key หมดอายุที่เส้นตายเป๊ะ ๆ แล้วหายไป” การอ่านไม่เห็นมันหลังเส้นตาย แต่หน่วยความจำถูกคืนแบบ lazy หรือโดยตัวกวาด
  • “EXPIRE ต่ออายุ TTL” มัน แทนที่ TTL อาจเป็นค่าที่สั้นลง; ใช้ GT เพื่อต่ออายุอย่างเดียว
  • เช็ค TTL key == 0 เพื่อตรวจ key ที่ไม่มี — key ที่ไม่มีคือ -2 และ 0 แปลว่า key ยังอยู่โดยเหลือไม่ถึงครึ่งวินาที แยกกรณีด้วย -2 (ไม่มี) และ -1 (ถาวร) หรือใช้ EXISTS
  • ลืมตั้ง TTL ให้ key ที่ควรเป็นข้อมูลชั่วคราว — ถ้า SET ค่า cache หรือ session โดยไม่มี EX/PX มันจะกลายเป็นถาวรและสะสมจน maxmemory เต็ม
  • สับสนระหว่าง EXPIRE (สัมพัทธ์) กับ EXPIREAT (timestamp สัมบูรณ์) — ใช้ผิดตัวทำให้ TTL เพี้ยนจากที่ตั้งใจ โดยเฉพาะเมื่อนาฬิกา client กับเซิร์ฟเวอร์ไม่ตรงกัน

Real-world example

Stack Overflow ใช้ cache L1/L2 (หน่วยความจำในเครื่องบวก Redis) หน้า SQL Server ที่ขนาดรายงานไว้ราว 160 พันล้านการดำเนินการ Redis ต่อเดือน (Stack Overflow: The Architecture – 2016 Edition, Nick Craver, 2016 — ตัวเลขอายุสิบปี) การทำให้ค่าใน cache หมดอายุด้วย TTL ตามที่แสดงในบทนี้คือกลไกที่ป้องกันไม่ให้ cache แบบนั้นเสิร์ฟข้อมูลเก่าตลอดไป

TTL คืนอะไรสำหรับ key ที่มีอยู่แต่ไม่ได้ตั้ง expiration?
คำสั่งไหนยกเลิก expiration และทำให้ key คงอยู่ถาวร?
PEXPIRE ตั้งค่าต่างจาก EXPIRE อย่างไร?
lazy expiry ใน Redis หมายถึงอะไร?
key เหลือ PX 400 TTL key คืนอะไร?
key มี TTL 100 วินาที EXPIRE key 50 GT ทำอะไร?