SET & GET
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...เป็น session เดียวเรียงตามลำดับเอกสาร และ harness ของคอร์สรันซ้ำทั้งหมด;TTLที่อ่านหลังSET ... EXไม่นานอาจต่ำกว่าค่าที่ตั้งไว้หนึ่งวินาที (ดูกฎการปัดเศษในบท Expiration)
SET และ GET — แกนหลักของ Redis strings
หัวข้อที่มีชื่อว่า “SET และ GET — แกนหลักของ Redis strings”SET key value เก็บ string GET key ดึงกลับมา ถ้า key มีอยู่แล้ว SET จะ เขียนทับ โดยไม่มีเงื่อนไข (เว้นแต่คุณเติม flag เงื่อนไข)
127.0.0.1:6379> SET city "Bangkok"OK127.0.0.1:6379> GET city"Bangkok"127.0.0.1:6379> SET city "Chiang Mai"OK127.0.0.1:6379> GET city"Chiang Mai"Redis string เป็น binary-safe — คือ byte array ไม่ใช่ข้อความ เก็บ JSON, protobuf ที่ serialize แล้ว, รูปภาพ หรือ byte 0x00 ได้ ขนาดสูงสุด 512 MB (ตั้งค่า proto-max-bulk-len; เอกสาร Redis strings) การเขียนเกินขีดจำกัดถูกปฏิเสธ พิสูจน์ได้โดยไม่ต้องจองครึ่งกิกะไบต์:
127.0.0.1:6379> SETRANGE big 536870912 x(error) ERR string exceeds maximum allowed size (proto-max-bulk-len)127.0.0.1:6379> CONFIG GET proto-max-bulk-len1) "proto-max-bulk-len"2) "536870912"STRLEN นับ byte ไม่ใช่ตัวอักษร: SET thai "สวัสดี" (อักษรไทยหกตัว) แล้ว STRLEN thai ได้ 18 (ตัวละสาม byte ใน UTF-8)
ตัวเลือกของ SET
หัวข้อที่มีชื่อว่า “ตัวเลือกของ SET”SET รองรับหลายตัวเลือกในคำสั่งเดียว:
| ตัวเลือก | ความหมาย |
|---|---|
EX seconds | ตั้งเวลาหมดอายุเป็นวินาที |
PX milliseconds | ตั้งเวลาหมดอายุเป็นมิลลิวินาที |
EXAT unix-time-seconds | หมดอายุที่ Unix timestamp (วินาที) |
PXAT unix-time-ms | หมดอายุที่ Unix timestamp (มิลลิวินาที) |
NX | set เฉพาะเมื่อ key ยังไม่มี |
XX | set เฉพาะเมื่อ key มีอยู่แล้ว |
GET | คืนค่าเก่าก่อนเขียนทับ |
KEEPTTL | คง TTL เดิมไว้แทนที่จะรีเซ็ต |
IFEQ value (8.4+) | set เฉพาะเมื่อค่าปัจจุบันเท่ากับ value (ยังมี IFNE, IFDEQ, IFDNE) |
127.0.0.1:6379> SET token "abc123" EX 60OK127.0.0.1:6379> TTL token(integer) 60127.0.0.1:6379> SET counter 0 NXOK127.0.0.1:6379> SET counter 99 NX(nil)127.0.0.1:6379> GET counter"0"127.0.0.1:6379> SET city "Phuket" GET"Chiang Mai"127.0.0.1:6379> GET city"Phuket"127.0.0.1:6379> SET city "Phuket" NX GET"Phuket"127.0.0.1:6379> SET fresh 1 XX(nil)อ่านผล: NX กับ key ที่มีอยู่คืน (nil) และไม่เปลี่ยนอะไร; SET ... GET ให้ค่า เก่า กลับมาพร้อมเขียนค่าใหม่ (ขั้นตอนเดียวแบบ atomic client อื่นแทรกไม่ได้); NX ร่วมกับ GET (ใช้ได้ตั้งแต่ 7.0) คืนค่าปัจจุบันและไม่แตะมัน; XX กับ key ที่ไม่มีคือไม่ทำอะไร TTL ทันทีหลัง EX 60 แสดง 60 จริง — ถ้าอ่านอีกวินาทีต่อมาจะเห็น 59 ค่าถูกปัดเศษ ไม่ใช่ “N ลบหนึ่ง”
SET token "abc123" EX 60
TTL token
SET counter 0 NX
SET counter 99 NX
GET counter
SET city "Phuket" GET
GET citySET ธรรมดาลบ TTL ทิ้ง — KEEPTTL เก็บไว้
หัวข้อที่มีชื่อว่า “SET ธรรมดาลบ TTL ทิ้ง — KEEPTTL เก็บไว้”127.0.0.1:6379> SET token "abc999" KEEPTTLOK127.0.0.1:6379> TTL token(integer) 59127.0.0.1:6379> SET token "abc000"OK127.0.0.1:6379> TTL token(integer) -1SET ตัวแรกคงเวลานับถอยหลังไว้ (เหลือ 59 วินาที) ตัวที่สองซึ่งเป็น SET ธรรมดาแทนที่ key และ expiry กลายเป็น -1: key ตอนนี้ ถาวร นี่คือการรั่วไหลเงียบ ๆ ที่พบบ่อยที่สุดในโค้ด cache
ตัวแปรอ่าน-แล้วแก้: GETEX, GETDEL, LCS
หัวข้อที่มีชื่อว่า “ตัวแปรอ่าน-แล้วแก้: GETEX, GETDEL, LCS”คำสั่ง string บน key เดียวอีกสามตัวที่ตัด round trip หรือ race:
127.0.0.1:6379> GETEX token"abc000"127.0.0.1:6379> SET token "again" EX 100OK127.0.0.1:6379> GETEX token PERSIST"again"127.0.0.1:6379> TTL token(integer) -1127.0.0.1:6379> GETDEL token"again"127.0.0.1:6379> GET token(nil)127.0.0.1:6379> SET key1 ohmytextOK127.0.0.1:6379> SET key2 mynewtextOK127.0.0.1:6379> LCS key1 key2"mytext"127.0.0.1:6379> LCS key1 key2 LEN(integer) 6GETEX อ่านค่าและเปลี่ยน expiry ในขั้นตอนเดียว (EX, PX, EXAT, PXAT หรือ PERSIST — primitive ของ sliding session) GETDEL อ่านและลบแบบ atomic — เหมาะกับ token ใช้ครั้งเดียว LCS คืน longest common subsequence ของ string สองค่าบนเซิร์ฟเวอร์ (6.0+)
Compare-and-set ด้วย IFEQ และ DELEX (Redis 8.4+)
หัวข้อที่มีชื่อว่า “Compare-and-set ด้วย IFEQ และ DELEX (Redis 8.4+)”ก่อน 8.4 การ “แก้ key นี้เฉพาะเมื่อมันยังเป็นค่าที่ฉันอ่านมา” ต้องใช้ WATCH หรือ Lua script บน Redis 8.4 ขึ้นไปมันเป็น flag:
127.0.0.1:6379> SET v1 helloOK127.0.0.1:6379> SET v1 world IFEQ helloOK127.0.0.1:6379> GET v1"world"127.0.0.1:6379> SET v1 nope IFEQ hello(nil)127.0.0.1:6379> DELEX v1 IFEQ nope(integer) 0127.0.0.1:6379> DELEX v1 IFEQ world(integer) 1127.0.0.1:6379> EXISTS v1(integer) 0IFEQ ที่ใช้ค่าคาดหวังที่ล้าสมัยจะคืน (nil) และไม่แตะ key; DELEX ... IFEQ ลบเฉพาะเมื่อค่าตรงกัน (เอกสาร DELEX) ไม่มีบน Redis 8.0-8.2 หรือ Valkey — บท Locks จึงเก็บทางเลือกด้วย Lua ไว้
SETEX และ SETNX
หัวข้อที่มีชื่อว่า “SETEX และ SETNX”เป็น alias สะดวกรุ่นเก่า — SET ที่มี EX หรือ NX เป็นที่นิยมใน Redis สมัยใหม่ (ตัวเลือกเหล่านี้มาใน Redis 2.6.12) แต่ยังพบในโค้ดเก่า:
127.0.0.1:6379> SETEX session:user1 3600 "token-xyz"OK127.0.0.1:6379> TTL session:user1(integer) 3600127.0.0.1:6379> SETNX lock:job1 "worker-A"(integer) 1127.0.0.1:6379> SETNX lock:job1 "worker-B"(integer) 0127.0.0.1:6379> GET lock:job1"worker-A"SETEX session:user1 3600 "token-xyz"
TTL session:user1
SETNX lock:job1 "worker-A"
SETNX lock:job1 "worker-B"
GET lock:job1MSET และ MGET — การทำงานเป็นชุด
หัวข้อที่มีชื่อว่า “MSET และ MGET — การทำงานเป็นชุด”set หรือ get หลาย key ใน round-trip เดียว เร็วกว่าคำสั่งเดี่ยว ๆ มากเมื่อต้องการหลาย key พร้อมกัน
127.0.0.1:6379> MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"OK127.0.0.1:6379> MGET user:1:name user:1:lang user:2:name user:2:lang1) "Ada"2) "Python"3) "Bob"4) "Go"127.0.0.1:6379> MGET user:1:name user:99:name1) "Ada"2) (nil)MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"
MGET user:1:name user:1:lang user:2:name user:2:lang
MGET user:1:name user:99:nameMSET เป็น atomic จริง
หัวข้อที่มีชื่อว่า “MSET เป็น atomic จริง”เอกสารทางการบอกว่า “MSET is atomic, so all given keys are set at once” (MSET) การทดสอบทำให้ชัดเจน: connection หนึ่งพลิกค่า key สองตัว 20,000 ครั้ง ขณะที่อีก connection อ่านทั้งสองด้วย MGET วนไม่หยุดและนับว่าเห็นค่าไม่ตรงกันกี่ครั้ง
// @runimport { Redis } from 'ioredis';
const writer = new Redis(process.env.REDIS_URL!);const reader = new Redis(process.env.REDIS_URL!);await writer.mset('b1:a', 'x', 'b1:b', 'x');
let stop = false;let torn = { mset: 0, twoSets: 0 };let reads = 0;const read = (async () => { while (!stop) { const [a, b] = await reader.mget('b1:a', 'b1:b'); reads++; if (a !== b) torn.mset++; }})();
// writer flips both keys 20000 times: once with MSET (one command)...for (let i = 0; i < 20000; i++) await writer.mset('b1:a', String(i), 'b1:b', String(i));stop = true; await read;console.log(`MSET writer: ${reads} MGET reads, ${torn.mset} saw a != b`);
// ...and once with two separate SETs (two commands)stop = false; reads = 0;const read2 = (async () => { while (!stop) { const [a, b] = await reader.mget('b1:a', 'b1:b'); reads++; if (a !== b) torn.twoSets++; }})();for (let i = 0; i < 20000; i++) { await Promise.all([writer.set('b1:a', `s${i}`), writer.set('b1:b', `s${i}`)]);}stop = true; await read2;console.log(`two SETs writer: ${reads} MGET reads, ${torn.twoSets} saw a != b`);
await writer.del('b1:a', 'b1:b');writer.disconnect(); reader.disconnect();ผลจริง: MSET writer: 19997 MGET reads, 0 saw a != b และ two SETs writer: 19991 MGET reads, 282 saw a != b (ตัวเลขต่างกันไปในแต่ละรอบ แต่เลขศูนย์ไม่เปลี่ยน) MSET — เช่นเดียวกับ MGET — เป็นคำสั่งเดียวบน command thread เดียว จึงไม่มี reader คนไหนเห็นมันครึ่งเดียว ส่วน SET สองตัวคือสองรอบและมองเห็นสถานะครึ่ง ๆ กลาง ๆ ได้ สิ่งที่ MSET ไม่ได้ให้ คือ transaction ข้าม คำสั่งต่างชนิด (เช่น SET กับ INCR): ต้องใช้ MULTI/EXEC หรือ script
APPEND และ STRLEN
หัวข้อที่มีชื่อว่า “APPEND และ STRLEN”APPEND ต่อท้ายค่า string ที่มีอยู่ (หรือสร้าง key ถ้ายังไม่มี) STRLEN คืนความยาวเป็น byte ของค่า
127.0.0.1:6379> SET log "2024-01-01 boot"OK127.0.0.1:6379> APPEND log " | 2024-01-01 ready"(integer) 34127.0.0.1:6379> GET log"2024-01-01 boot | 2024-01-01 ready"127.0.0.1:6379> STRLEN log(integer) 34127.0.0.1:6379> SET greet hiOK127.0.0.1:6379> OBJECT ENCODING greet"embstr"127.0.0.1:6379> APPEND greet " there"(integer) 8127.0.0.1:6379> OBJECT ENCODING greet"raw"127.0.0.1:6379> SET num 42OK127.0.0.1:6379> OBJECT ENCODING num"int"encoding เป็นรายละเอียดการ implement ที่บอกว่า Redis เก็บ string อย่างไร: ตัวเลขที่พอดี integer 64 บิตเป็น int, string สั้นเป็น embstr (จัดสรรรวมกับ object ของ key) และ string ที่ถูกแก้ไขในที่เดิม — หรือยาว — เป็น raw คุณไม่ได้เลือกเอง แค่ควรรู้ว่า APPEND บน key ที่ร้อนจะทำให้มันกลายเป็น string แบบ raw
SET log "2024-01-01 boot"
APPEND log " | 2024-01-01 ready"
GET log
STRLEN logจากแอป Node.js
หัวข้อที่มีชื่อว่า “จากแอป Node.js”คำสั่งเดียวกันผ่าน ioredis สังเกตว่า SET ... EX 30 NX คืน 'OK' เมื่อ set key สำเร็จ และ null เมื่อไม่สำเร็จ และค่า binary ไป-กลับผ่าน Buffer ได้:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.del('b1:lock', 'b1:blob');
// SET ... NX EX: 'OK' when it set the key, null when the key was already thereconsole.log(await redis.set('b1:lock', 'worker-A', 'EX', 30, 'NX')); // "OK"console.log(await redis.set('b1:lock', 'worker-B', 'EX', 30, 'NX')); // nullconsole.log(await redis.get('b1:lock')); // "worker-A"
// strings are binary-safe: store raw bytes, read them back as a Bufferconst bytes = Buffer.from([0x00, 0xff, 0x10, 0x80]);await redis.set('b1:blob', bytes);const back = await redis.getBuffer('b1:blob');console.log(back?.equals(bytes), await redis.strlen('b1:blob')); // true 4
await redis.del('b1:lock', 'b1:blob');await redis.quit();output: OK, null, worker-A, true 4 ตรวจ null เสมอ — การไม่สนใจค่าที่คืนจาก SET แบบมีเงื่อนไขคือสาเหตุที่ worker สองตัวเข้าใจว่าตัวเองถือ lock
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน “refresh ข้อมูล session” สำหรับ login flow ที่เก็บ session ไว้ 30 นาที:
🐛 หา bug ในโค้ดนี้
// login: session lives 30 minutesawait redis.set('session:42', JSON.stringify({ cart: [] }), 'EX', 1800);
// later, on every request that changes the cart:async function saveCart(cart: string[]) {await redis.set('session:42', JSON.stringify({ cart }));}เฉลย
SET ธรรมดาแทนที่ค่า และล้าง expiry หลัง saveCart ครั้งแรก TTL session:42 เป็น -1 และ session ไม่หมดอายุอีกเลย: เรารันแบบนี้จริงและได้ after plain SET: -1 ทุก session ที่ถูกแตะกลายเป็น key ถาวร และหน่วยความจำโตไปจนถึง maxmemory ให้เลือกอย่างตั้งใจ: KEEPTTL เพื่ออัปเดตข้อมูลโดยไม่แตะเวลานับถอยหลัง หรือ EX ใหม่สำหรับ sliding session
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:session:42';
await redis.set(key, JSON.stringify({ cart: [] }), 'EX', 1800);
// the buggy version: a plain SET drops the TTLawait redis.set(key, JSON.stringify({ cart: ['book'] }));console.log('after plain SET:', await redis.ttl(key)); // -1 (persistent!)
// the fix when you want to keep the existing expiryawait redis.set(key, JSON.stringify({ cart: [] }), 'EX', 1800);await redis.set(key, JSON.stringify({ cart: ['book'] }), 'KEEPTTL');console.log('after SET KEEPTTL:', await redis.ttl(key)); // 1800 (or 1799 a moment later)
await redis.del(key);await redis.quit();เวอร์ชันที่แก้แล้วพิมพ์ after SET KEEPTTL: 1800
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Redis string คือ กล่องที่ติดป้ายเก็บ byte พร้อมเวลานับถอยหลังที่จะมีหรือไม่ก็ได้ SET แทนที่ทั้งกล่อง และ เวลานับถอยหลังด้วย เว้นแต่คุณบอก KEEPTTL flag ต่าง ๆ (NX, XX, GET, IFEQ) เปลี่ยน SET ให้เป็น “ตรวจแล้วทำ” ในขั้นเดียว — การตรวจและการทำเกิดบน command thread เดียว จึงไม่มีอะไรแทรกได้ ทุกครั้งที่เผลอเขียน GET, คิด, SET ให้ถามว่า flag ตัวใดทำสิ่งนั้นแบบ atomic ได้อยู่แล้วหรือไม่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
SET key val NX (set แบบมีเงื่อนไข) | ตรวจแล้ว set แบบ atomic ในคำสั่งเดียว เพียงพอสำหรับ lock/dedup พื้นฐาน | ไม่มี ownership token ในตัว — ต้องออกแบบเองเพื่อปลดล็อกอย่างปลอดภัย |
MSET/MGET (ทำเป็นชุด) | atomic ครอบคลุมทุก key และใช้ round trip เดียว | ทั้งชุดบล็อก command thread ตลอดช่วงที่ทำ (ควรจำกัดขนาดชุด); ไม่แยกตัวจากคำสั่ง อื่น — ใช้ MULTI/EXEC หรือ script |
SET ... GET / GETEX / GETDEL / IFEQ | ตัด race แบบอ่านแล้วเขียนโดยไม่ต้องใช้ Lua | flag ใหม่ต้องใช้เซิร์ฟเวอร์รุ่นใหม่ (GETEX/GETDEL 6.2, IFEQ/DELEX 8.4) — ไม่มีบน Redis เก่าหรือ Valkey |
SET key val ธรรมดาแบบไม่มีเงื่อนไข | ง่าย เขียนทับทันทีโดยไม่ตรวจก่อน | เขียนทับข้อมูล client อื่นถ้าชื่อ key ชนกัน และล้าง TTL แบบเงียบ ๆ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
MSETไม่ atomic” มัน atomic; การทดสอบข้างบนเห็น torn read 0 ครั้งใน ~20,000 สิ่งที่มันไม่ใช่คือ transaction หลายคำสั่ง - “Redis string คือข้อความ” มันคือ byte;
STRLENนับ byte (สวัสดีเป็น 18) และขีดจำกัดคือ 512 MB - “
SETเปลี่ยนแค่ค่า” มันยังลบ TTL ด้วย เว้นแต่ใส่KEEPTTL - “
SET ... NXคืน 1 หรือ 0” นั่นคือSETNXส่วนSET ... NXคืนOKหรือ(nil)(nullใน ioredis)
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ใช้
SETธรรมดาทั้งที่หมายถึง “set เฉพาะเมื่อยังไม่มี” — ลืมNXจะเขียนทับข้อมูลเดิมแบบเงียบ ๆ ใช้SET key val NXเมื่อ logic ต้องการตรวจแล้ว set แบบ atomic - ลืมว่า
SETธรรมดารีเซ็ต TTL เดิมของ key — ถ้าต้องการคง expiry เดิมขณะอัปเดตค่า ให้เพิ่มKEEPTTLไม่เช่นนั้น key จะกลับมาเป็นถาวร - ใช้คำสั่งรุ่นเก่า
SETNX/SETEXปนกับSET ... NX/EXใน codebase เดียว — ทำให้สไตล์ไม่สม่ำเสมอ ใช้มาตรฐานSETพร้อมตัวเลือก ซึ่งมีตั้งแต่ Redis 2.6.12
Real-world example
API สาธารณะของ Stripe ระบุอัลกอริทึม idempotency-key ไว้: client ส่ง header
Idempotency-Keyเซิร์ฟเวอร์เก็บผลลัพธ์ของ request แรกไว้ภายใต้ key นั้น และตอบผลลัพธ์เดิมให้ retry ที่ใช้ key เดียวกันภายใน 24 ชั่วโมง (Idempotent requests, Stripe Docs) key ที่สำเร็จได้ครั้งเดียวคือสิ่งที่SET key val NX EX <ttl>ให้ใน Redis: request ที่ได้OKคือตัวที่ทำงานจริง