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

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 key value เก็บ string GET key ดึงกลับมา ถ้า key มีอยู่แล้ว SET จะ เขียนทับ โดยไม่มีเงื่อนไข (เว้นแต่คุณเติม flag เงื่อนไข)

cli/keys-strings-set-get-1.redis
127.0.0.1:6379> SET city "Bangkok"
OK
127.0.0.1:6379> GET city
"Bangkok"
127.0.0.1:6379> SET city "Chiang Mai"
OK
127.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) การเขียนเกินขีดจำกัดถูกปฏิเสธ พิสูจน์ได้โดยไม่ต้องจองครึ่งกิกะไบต์:

cli/keys-strings-set-get-2.redis
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-len
1) "proto-max-bulk-len"
2) "536870912"

STRLEN นับ byte ไม่ใช่ตัวอักษร: SET thai "สวัสดี" (อักษรไทยหกตัว) แล้ว STRLEN thai ได้ 18 (ตัวละสาม byte ใน UTF-8)

SET รองรับหลายตัวเลือกในคำสั่งเดียว:

ตัวเลือกความหมาย
EX secondsตั้งเวลาหมดอายุเป็นวินาที
PX millisecondsตั้งเวลาหมดอายุเป็นมิลลิวินาที
EXAT unix-time-secondsหมดอายุที่ Unix timestamp (วินาที)
PXAT unix-time-msหมดอายุที่ Unix timestamp (มิลลิวินาที)
NXset เฉพาะเมื่อ key ยังไม่มี
XXset เฉพาะเมื่อ key มีอยู่แล้ว
GETคืนค่าเก่าก่อนเขียนทับ
KEEPTTLคง TTL เดิมไว้แทนที่จะรีเซ็ต
IFEQ value (8.4+)set เฉพาะเมื่อค่าปัจจุบันเท่ากับ value (ยังมี IFNE, IFDEQ, IFDNE)
cli/keys-strings-set-get-3.redis
127.0.0.1:6379> SET token "abc123" EX 60
OK
127.0.0.1:6379> TTL token
(integer) 60
127.0.0.1:6379> SET counter 0 NX
OK
127.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 city
cli/keys-strings-set-get-4.redis
127.0.0.1:6379> SET token "abc999" KEEPTTL
OK
127.0.0.1:6379> TTL token
(integer) 59
127.0.0.1:6379> SET token "abc000"
OK
127.0.0.1:6379> TTL token
(integer) -1

SET ตัวแรกคงเวลานับถอยหลังไว้ (เหลือ 59 วินาที) ตัวที่สองซึ่งเป็น SET ธรรมดาแทนที่ key และ expiry กลายเป็น -1: key ตอนนี้ ถาวร นี่คือการรั่วไหลเงียบ ๆ ที่พบบ่อยที่สุดในโค้ด cache

คำสั่ง string บน key เดียวอีกสามตัวที่ตัด round trip หรือ race:

cli/keys-strings-set-get-5.redis
127.0.0.1:6379> GETEX token
"abc000"
127.0.0.1:6379> SET token "again" EX 100
OK
127.0.0.1:6379> GETEX token PERSIST
"again"
127.0.0.1:6379> TTL token
(integer) -1
127.0.0.1:6379> GETDEL token
"again"
127.0.0.1:6379> GET token
(nil)
127.0.0.1:6379> SET key1 ohmytext
OK
127.0.0.1:6379> SET key2 mynewtext
OK
127.0.0.1:6379> LCS key1 key2
"mytext"
127.0.0.1:6379> LCS key1 key2 LEN
(integer) 6

GETEX อ่านค่าและเปลี่ยน expiry ในขั้นตอนเดียว (EX, PX, EXAT, PXAT หรือ PERSIST — primitive ของ sliding session) GETDEL อ่านและลบแบบ atomic — เหมาะกับ token ใช้ครั้งเดียว LCS คืน longest common subsequence ของ string สองค่าบนเซิร์ฟเวอร์ (6.0+)

ก่อน 8.4 การ “แก้ key นี้เฉพาะเมื่อมันยังเป็นค่าที่ฉันอ่านมา” ต้องใช้ WATCH หรือ Lua script บน Redis 8.4 ขึ้นไปมันเป็น flag:

cli/keys-strings-set-get-6.redis
127.0.0.1:6379> SET v1 hello
OK
127.0.0.1:6379> SET v1 world IFEQ hello
OK
127.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) 0
127.0.0.1:6379> DELEX v1 IFEQ world
(integer) 1
127.0.0.1:6379> EXISTS v1
(integer) 0

IFEQ ที่ใช้ค่าคาดหวังที่ล้าสมัยจะคืน (nil) และไม่แตะ key; DELEX ... IFEQ ลบเฉพาะเมื่อค่าตรงกัน (เอกสาร DELEX) ไม่มีบน Redis 8.0-8.2 หรือ Valkey — บท Locks จึงเก็บทางเลือกด้วย Lua ไว้

เป็น alias สะดวกรุ่นเก่า — SET ที่มี EX หรือ NX เป็นที่นิยมใน Redis สมัยใหม่ (ตัวเลือกเหล่านี้มาใน Redis 2.6.12) แต่ยังพบในโค้ดเก่า:

cli/keys-strings-set-get-7.redis
127.0.0.1:6379> SETEX session:user1 3600 "token-xyz"
OK
127.0.0.1:6379> TTL session:user1
(integer) 3600
127.0.0.1:6379> SETNX lock:job1 "worker-A"
(integer) 1
127.0.0.1:6379> SETNX lock:job1 "worker-B"
(integer) 0
127.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:job1

set หรือ get หลาย key ใน round-trip เดียว เร็วกว่าคำสั่งเดี่ยว ๆ มากเมื่อต้องการหลาย key พร้อมกัน

cli/keys-strings-set-get-8.redis
127.0.0.1:6379> MSET user:1:name "Ada" user:1:lang "Python" user:2:name "Bob" user:2:lang "Go"
OK
127.0.0.1:6379> MGET user:1:name user:1:lang user:2:name user:2:lang
1) "Ada"
2) "Python"
3) "Bob"
4) "Go"
127.0.0.1:6379> MGET user:1:name user:99:name
1) "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:name

เอกสารทางการบอกว่า “MSET is atomic, so all given keys are set at once” (MSET) การทดสอบทำให้ชัดเจน: connection หนึ่งพลิกค่า key สองตัว 20,000 ครั้ง ขณะที่อีก connection อ่านทั้งสองด้วย MGET วนไม่หยุดและนับว่าเห็นค่าไม่ตรงกันกี่ครั้ง

src/keys-strings-set-get-mset.ts
// @run
import { 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 ต่อท้ายค่า string ที่มีอยู่ (หรือสร้าง key ถ้ายังไม่มี) STRLEN คืนความยาวเป็น byte ของค่า

cli/keys-strings-set-get-9.redis
127.0.0.1:6379> SET log "2024-01-01 boot"
OK
127.0.0.1:6379> APPEND log " | 2024-01-01 ready"
(integer) 34
127.0.0.1:6379> GET log
"2024-01-01 boot | 2024-01-01 ready"
127.0.0.1:6379> STRLEN log
(integer) 34
127.0.0.1:6379> SET greet hi
OK
127.0.0.1:6379> OBJECT ENCODING greet
"embstr"
127.0.0.1:6379> APPEND greet " there"
(integer) 8
127.0.0.1:6379> OBJECT ENCODING greet
"raw"
127.0.0.1:6379> SET num 42
OK
127.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

คำสั่งเดียวกันผ่าน ioredis สังเกตว่า SET ... EX 30 NX คืน 'OK' เมื่อ set key สำเร็จ และ null เมื่อไม่สำเร็จ และค่า binary ไป-กลับผ่าน Buffer ได้:

src/keys-strings-set-get-client.ts
// @run
import { 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 there
console.log(await redis.set('b1:lock', 'worker-A', 'EX', 30, 'NX')); // "OK"
console.log(await redis.set('b1:lock', 'worker-B', 'EX', 30, 'NX')); // null
console.log(await redis.get('b1:lock')); // "worker-A"
// strings are binary-safe: store raw bytes, read them back as a Buffer
const 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

AI เขียน “refresh ข้อมูล session” สำหรับ login flow ที่เก็บ session ไว้ 30 นาที:

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

// login: session lives 30 minutes
await 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

src/keys-strings-set-get-keepttl.ts
// @run
import { 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 TTL
await 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 expiry
await 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

Redis string คือ กล่องที่ติดป้ายเก็บ byte พร้อมเวลานับถอยหลังที่จะมีหรือไม่ก็ได้ SET แทนที่ทั้งกล่อง และ เวลานับถอยหลังด้วย เว้นแต่คุณบอก KEEPTTL flag ต่าง ๆ (NX, XX, GET, IFEQ) เปลี่ยน SET ให้เป็น “ตรวจแล้วทำ” ในขั้นเดียว — การตรวจและการทำเกิดบน command thread เดียว จึงไม่มีอะไรแทรกได้ ทุกครั้งที่เผลอเขียน GET, คิด, SET ให้ถามว่า flag ตัวใดทำสิ่งนั้นแบบ atomic ได้อยู่แล้วหรือไม่

ตัวเลือกข้อดีข้อเสีย
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 แบบอ่านแล้วเขียนโดยไม่ต้องใช้ Luaflag ใหม่ต้องใช้เซิร์ฟเวอร์รุ่นใหม่ (GETEX/GETDEL 6.2, IFEQ/DELEX 8.4) — ไม่มีบน Redis เก่าหรือ Valkey
SET key val ธรรมดาแบบไม่มีเงื่อนไขง่าย เขียนทับทันทีโดยไม่ตรวจก่อนเขียนทับข้อมูล client อื่นถ้าชื่อ key ชนกัน และล้าง TTL แบบเงียบ ๆ
  • “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)
  • ใช้ 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 คือตัวที่ทำงานจริง

SET key val NX คืนอะไรเมื่อ key มีอยู่แล้ว?
คำสั่งไหน set หลาย key ใน round-trip เดียว?
SET key val GET ทำอะไร?
STRLEN คืนความยาวของ string เป็นหน่วยอะไร?
SET session:42 data รันบน key ที่เคยมี EX 1800 แล้ว TTL session:42 เป็นเท่าไร?
ต้องการเปลี่ยน key แบบ atomic เฉพาะเมื่อมันยังเป็นค่าที่อ่านมา บน Redis 8.4+ วิธีที่ง่ายที่สุดคืออะไร?