Hashes
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon)HEXPIRE/HTTLต้องใช้ Redis 7.4+,HGETEX/HSETEX/HGETDELต้องใช้ 8.0+ (ไม่มีบน Valkey หรือเซิร์ฟเวอร์รุ่นเก่า) เวลาเป็นไมโครวินาทีฝั่งเซิร์ฟเวอร์จากSLOWLOGบนเครื่องนี้
Hashes — พจนานุกรมแบบแบนใต้ key เดียว
หัวข้อที่มีชื่อว่า “Hashes — พจนานุกรมแบบแบนใต้ key เดียว”Redis Hash เก็บคู่ field-value หลายคู่ภายใต้ key เดียว ลองนึกภาพเป็นพจนานุกรมแบบแบนหรือแถวในฐานข้อมูล — key เดียวถือ field ที่มีชื่อได้หลายตัว ทั้ง field และ value เป็น string (binary-safe เหมือนทุกอย่างใน Redis)
HSET และ HGET
หัวข้อที่มีชื่อว่า “HSET และ HGET”HSET key field value [field value ...] ตั้งค่า field หนึ่งตัวหรือมากกว่า (รับหลายคู่ตั้งแต่ Redis 4.0; HMSET คือชื่อเก่าที่ deprecated) HGET key field ดึง field เดียวตามชื่อ ถ้า field ไม่มี Redis คืน (nil) HSET คืนจำนวน field ที่ถูก สร้างใหม่ — การอัปเดต field ที่มีอยู่ไม่นับ:
127.0.0.1:6379> HSET product:1 name "Redis Mug" price "12.99" stock "200"(integer) 3127.0.0.1:6379> HGET product:1 name"Redis Mug"127.0.0.1:6379> HGET product:1 price"12.99"127.0.0.1:6379> HGET product:1 missing-field(nil)127.0.0.1:6379> HSET product:1 name "Redis Mug XL" color "red"(integer) 1การเรียกสุดท้ายอัปเดต name และเพิ่ม color แล้วคืน 1: นับเฉพาะ field ใหม่ อย่าใช้ค่าที่ HSET คืนเป็น “สำเร็จหรือไม่”
HSET product:1 name "Redis Mug" price "12.99" stock "200"
HGET product:1 name
HGET product:1 price
HGET product:1 missing-fieldHMGET และ HGETALL
หัวข้อที่มีชื่อว่า “HMGET และ HGETALL”HMGET key field [field ...] ดึงหลาย field ใน round-trip เดียว และคืน (nil) สำหรับ field ที่ไม่มี HGETALL key คืนทุกคู่ field-value ใน hash เป็นลิสต์สลับชื่อ field กับค่า
127.0.0.1:6379> HMGET product:1 name stock nope1) "Redis Mug XL"2) "200"3) (nil)127.0.0.1:6379> HGETALL product:11) "name"2) "Redis Mug XL"3) "price"4) "12.99"5) "stock"6) "200"7) "color"8) "red"สำหรับ hash เล็ก field ออกมาตามลำดับที่ใส่ แต่นั่นคือรายละเอียดการ implement ของ encoding แบบกะทัดรัด (ด้านล่าง) — สัญญาบอกว่าไม่มีลำดับ
HMGET product:1 name stock
HGETALL product:1HDEL และ HEXISTS
หัวข้อที่มีชื่อว่า “HDEL และ HEXISTS”HDEL key field [field ...] ลบ field หนึ่งตัวหรือมากกว่า และคืนจำนวน field ที่ถูกลบจริง HEXISTS key field คืน 1 ถ้า field มีอยู่ 0 ถ้าไม่มี — มีประโยชน์กับ logic เงื่อนไขโดยไม่ต้องดึงค่ามาเอง
127.0.0.1:6379> HEXISTS product:1 price(integer) 1127.0.0.1:6379> HEXISTS product:1 nosuchfield(integer) 0127.0.0.1:6379> HDEL product:1 price(integer) 1127.0.0.1:6379> HDEL product:1 price(integer) 0127.0.0.1:6379> HEXISTS product:1 price(integer) 0HDEL ครั้งที่สองคืน 0 ไม่ใช่ error: field หายไปแล้ว
HSET product:1 name "Redis Mug" price "12.99" stock "200"
HEXISTS product:1 price
HEXISTS product:1 color
HDEL product:1 price
HEXISTS product:1 price
HKEYS product:1
HVALS product:1
HLEN product:1HKEYS, HVALS, HLEN และคำสั่งอื่น
หัวข้อที่มีชื่อว่า “HKEYS, HVALS, HLEN และคำสั่งอื่น”HKEYS key คืนชื่อ field ทั้งหมด HVALS key คืนค่าของ field ทั้งหมด HLEN key คืนจำนวน field ทั้งหมดใน hash หลังลบ price ข้างบน hash เหลือสาม field: name, stock และ color HSTRLEN ให้ความยาวเป็น byte ของค่าใน field หนึ่ง, HSETNX ตั้ง field เฉพาะเมื่อยังไม่มี และ HINCRBY/HINCRBYFLOAT บวกค่ากับ field ตัวเลขแบบ atomic (สร้างที่ 0 ถ้าไม่มี):
127.0.0.1:6379> HKEYS product:11) "name"2) "stock"3) "color"127.0.0.1:6379> HVALS product:11) "Redis Mug XL"2) "200"3) "red"127.0.0.1:6379> HLEN product:1(integer) 3127.0.0.1:6379> HSTRLEN product:1 name(integer) 12127.0.0.1:6379> HSETNX product:1 name "ignored"(integer) 0127.0.0.1:6379> HSETNX product:1 sku "MUG-1"(integer) 1127.0.0.1:6379> HINCRBY product:1 stock -3(integer) 197127.0.0.1:6379> HINCRBY product:1 rating 5(integer) 5127.0.0.1:6379> HINCRBYFLOAT product:1 weight 0.25"0.25"127.0.0.1:6379> HINCRBY product:1 name 1(error) ERR hash value is not an integerHRANDFIELD key [count [WITHVALUES]] (6.2+) คืน field แบบสุ่ม — สะดวกสำหรับการสุ่มตัวอย่าง — และ count ติดลบอนุญาตให้ซ้ำ output ของมันสุ่มโดยนิยาม จึงแสดงเป็นการรันจริงหนึ่งครั้งแทน transcript ตายตัว:
127.0.0.1:6379> HRANDFIELD product:1 2 WITHVALUES1) "stock"2) "197"3) "sku"4) "MUG-1"Redis เก็บ hash อย่างไร: listpack แล้ว hashtable
หัวข้อที่มีชื่อว่า “Redis เก็บ hash อย่างไร: listpack แล้ว hashtable”hash เล็กคือบล็อกกะทัดรัดก้อนเดียว (listpack); hash ใหญ่คือ hash table จริง สองการตั้งค่ากำหนดเส้นแบ่ง: hash-max-listpack-entries (ค่าเริ่มต้น 512 field) และ hash-max-listpack-value (ค่าเริ่มต้น 64 byte ต่อ field หรือ value) วัดบน 8.10.2:
1 field: listpack, MEMORY USAGE 37 bytes513 fields (value 'v'): hashtable, MEMORY USAGE 22,014 bytesa 64-byte value -> listpack a 65-byte value -> hashtablefield ตัวที่ 513 และ byte ที่ 65 แต่ละอย่างทำให้สลับ การสลับแทบเป็นทางเดียวและคุณไม่ต้องสนใจมันในโค้ดแอป — แต่มันบอกว่าทำไม object เล็กจึงถูก และทำไม hash 100,000 field (หัวข้อถัดไป) เป็นสัตว์คนละชนิด
HGETALL บน hash ใหญ่
หัวข้อที่มีชื่อว่า “HGETALL บน hash ใหญ่”HGETALL เป็น O(N) ตามจำนวน field และรันบน command thread ผมสร้าง hash 100,000 field (field:<n> → value-<n>, 6 MB, hashtable) และอ่านเวลาฝั่งเซิร์ฟเวอร์:
| คำสั่ง | เวลาฝั่งเซิร์ฟเวอร์บน hash 100,000 field |
|---|---|
HGET หนึ่ง field / HMGET สอง field | 2 µs |
HGETALL | 7,818 µs (7.8 ms) |
HVALS | 5,057 µs |
HSCAN ... COUNT 100 (หนึ่งการเรียก) | ≤ 34 µs ใช้ 993 การเรียกจึงเดินครบ |
ฝั่ง client HGETALL ส่งครบ 100,000 field ใน 135 ms; การเดินด้วย HSCAN ใช้เวลารวม 450 ms แต่ไม่เคยถือเซิร์ฟเวอร์เกินไม่กี่สิบไมโครวินาที กฎเดียวกับ SCAN ของ key: ถ้า hash โตได้ไม่จำกัด ให้วนด้วย HSCAN (ioredis: hscanStream) — และถ้าต้องการแค่ไม่กี่ field ให้ระบุชื่อด้วย HMGET
ทำให้ field แต่ละตัวหมดอายุ
หัวข้อที่มีชื่อว่า “ทำให้ field แต่ละตัวหมดอายุ”EXPIRE ใช้กับทั้ง key ดังนั้นนานมาแล้ว hash เป็นแบบทั้งหมดหรือไม่มีเลย: ทุก field อยู่และตายด้วยกัน Redis 7.4 เปลี่ยนเรื่องนี้ด้วย field-level TTL และ Redis 8 เพิ่มสามคำสั่งที่รวม TTL เข้าไปในการอ่านหรือเขียนเอง
HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field ...] ตั้ง TTL สังเกต keyword FIELDS และ จำนวน field ก่อนชื่อ — ทุกคำสั่งในตระกูลนี้ต้องมี มันคืนตัวเลขหนึ่งตัวต่อ field: 1 เมื่อตั้ง TTL แล้ว, -2 เมื่อไม่มี field นั้น, 0 เมื่อเงื่อนไข NX/XX/GT/LT ไม่ตรง HTTL อ่านวินาทีที่เหลือและ HPERSIST ลบ TTL อีกครั้ง
127.0.0.1:6379> HSET session:42 user "ana" csrf "t0ken"(integer) 2127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HTTL session:42 FIELDS 2 csrf user1) (integer) 6002) (integer) -1127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 2 csrf nope1) (integer) 12) (integer) -2127.0.0.1:6379> HEXPIRE session:42 100 NX FIELDS 1 csrf1) (integer) 0127.0.0.1:6379> HEXPIRE session:42 100 XX FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HPERSIST session:42 FIELDS 1 csrf1) (integer) 1127.0.0.1:6379> HTTL session:42 FIELDS 1 csrf1) (integer) -1-1 หมายถึง field ไม่มี expiration NX ถูกปฏิเสธ (0) เพราะ csrf มี TTL อยู่แล้ว; XX ได้ผล เมื่อ TTL หมด field หายไปเอง และเมื่อ field สุดท้ายหาย key ก็หายไปด้วย — ผมตั้ง HPEXPIRE tiny 300 FIELDS 1 f บน hash ที่มี field เดียว: EXISTS tiny เป็น 1 ทันที และเป็น 0 หลังผ่านไปราว 600 ms
HGETEX, HSETEX และ HGETDEL
หัวข้อที่มีชื่อว่า “HGETEX, HSETEX และ HGETDEL”สามคำสั่งของ Redis 8 นี้แต่ละตัวแทนการทำหลายคำสั่งเล็ก ๆ:
| คำสั่ง | รูปแบบ | แทนที่ |
|---|---|---|
HGETEX key [EX seconds | PERSIST] FIELDS numfields field [field ...] | อ่านและตั้ง TTL ใหม่ | HGET + HEXPIRE |
HSETEX key [FNX | FXX] [EX seconds | KEEPTTL] FIELDS numfields field value [field value ...] | เขียนพร้อม TTL | HSET + HEXPIRE |
HGETDEL key FIELDS numfields field [field ...] | อ่านแล้วลบ | HGET + HDEL |
HGETEX คือ primitive ของ sliding session: อ่าน field และผลัก expiry ออกไปในการเรียก atomic เดียวกัน HSETEX รับ FNX (เฉพาะเมื่อไม่มี field ใดอยู่เลย) หรือ FXX (เฉพาะเมื่อมีทุกตัว) และ KEEPTTL เพื่อไม่แตะ expiry ที่มีอยู่ HGETDEL คือการอ่านครั้งเดียวที่ใช้กับค่าที่ใช้ได้ครั้งเดียว เช่น one-time token
127.0.0.1:6379> HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"(integer) 1127.0.0.1:6379> HGETEX session:42 EX 900 FIELDS 1 csrf1) "fresh-token"127.0.0.1:6379> HGETDEL session:42 FIELDS 1 csrf1) "fresh-token"127.0.0.1:6379> HGET session:42 csrf(nil)HSET session:42 user "ana" csrf "t0ken"
HEXPIRE session:42 600 FIELDS 1 csrf
HTTL session:42 FIELDS 2 csrf user
HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"
HGETEX session:42 EX 900 FIELDS 1 csrf
HGETDEL session:42 FIELDS 1 csrf
HGET session:42 csrfจาก Node.js
หัวข้อที่มีชื่อว่า “จาก Node.js”ioredis รับ object สำหรับ hset และให้ Record<string, string> จาก hgetall — ทุกค่ากลับมาเป็น string และ key ที่ไม่มีกลับมาเป็น {} คำสั่ง field-TTL มี wrapper (hexpire, httl); คำสั่งของ Redis 8 ส่งด้วย call() ได้ถ้า client รุ่นของคุณยังไม่มี wrapper:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:product:1';await redis.del(key);
// hset takes an object; every value is stored (and returned) as a stringawait redis.hset(key, { name: 'Redis Mug', price: 12.99, stock: 200 });const p = await redis.hgetall(key); // Record<string, string>console.log(p, typeof p.price); // price is the string '12.99', not a numberconsole.log(Number(p.price) * 2); // parse before doing math
console.log(await redis.hmget(key, 'name', 'nope')); // [ 'Redis Mug', null ]console.log(await redis.hincrby(key, 'stock', -3)); // 197 (a number, atomic)
// field-level TTL: HEXPIRE key seconds FIELDS n field... (Redis 7.4+)await redis.hset('b1:sess', { user: 'ana', csrf: 't0ken' });console.log(await redis.hexpire('b1:sess', 600, 'FIELDS', 1, 'csrf')); // [ 1 ]console.log(await redis.httl('b1:sess', 'FIELDS', 2, 'csrf', 'user')); // [ 600, -1 ]
// HGETEX / HSETEX / HGETDEL (Redis 8.0+) are sent through call() if your client version has no wrapperconsole.log(await redis.call('HGETEX', 'b1:sess', 'EX', 900, 'FIELDS', 1, 'csrf')); // [ 't0ken' ]
await redis.del(key, 'b1:sess');await redis.quit();output จริง: { name: 'Redis Mug', price: '12.99', stock: '200' } string, 25.98, [ 'Redis Mug', null ], 197, [ 1 ], [ 600, -1 ], [ 't0ken' ]
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน cache โปรไฟล์ผู้ใช้บน hash:
🐛 หา bug ในโค้ดนี้
async function getUser(id: number) {const user = await redis.hgetall('user:' + id);if (!user) { return loadFromDatabaseAndCache(id); // cache miss}return { name: user.name, isAdmin: !!user.isAdmin, age: user.age };}
await redis.hset('user:1', { name: 'Ana', isAdmin: false, age: 17 });เฉลย
สอง bug แยกกัน มาจากความคิดว่า “hash คือ object ที่มี type” (1) HGETALL บน key ที่ไม่มีคืน object ว่าง {} ซึ่งเป็น truthy ดังนั้น !user ไม่เคยเป็นจริง: branch cache miss ไม่เคยรัน และผู้เรียกได้ field เป็น undefined สำหรับผู้ใช้ที่ไม่รู้จัก (2) ทุกค่าใน hash เป็น string: isAdmin: false ถูกเก็บและอ่านกลับเป็น "false" และ !!"false" เป็น true — Ana อายุ 17 ถูกรายงานว่าเป็น admin; age เป็น "17" การเทียบแบบ user.age > 18 และ + จึงทำงานผิด รัน:
isAdmin (buggy): ADMIN | stored value: "false"cache miss detected (buggy)? false | what HGETALL returned: {}fixed: { name: 'Ana', isAdmin: false, age: 17 } nullวิธีแก้คือ parse อย่างชัดเจนและตรวจ miss จากผลที่ว่าง (หรือ EXISTS):
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.del('b1:user:1', 'b1:user:2');await redis.hset('b1:user:1', { name: 'Ana', isAdmin: false, age: 17 });
// the AI-written checksconst buggy = await redis.hgetall('b1:user:1');console.log('isAdmin (buggy):', buggy.isAdmin ? 'ADMIN' : 'user', '| stored value:', JSON.stringify(buggy.isAdmin)); // "false" is truthyconst missing = await redis.hgetall('b1:user:2');console.log('cache miss detected (buggy)?', !missing, '| what HGETALL returned:', JSON.stringify(missing)); // {} is truthy
// the fix: parse explicitly, detect a miss by emptiness (or EXISTS)const parse = (h: Record<string, string>) => Object.keys(h).length === 0 ? null : { name: h.name, isAdmin: h.isAdmin === 'true', age: Number(h.age) };console.log('fixed:', parse(buggy), parse(missing));
await redis.del('b1:user:1');await redis.quit();Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”hash คือ แถวสเปรดชีตเล็ก ๆ ที่เก็บใต้ key เดียว โดยหัวคอลัมน์ซ้ำอยู่ในทุกแถว คุณอ่านหรือแก้เซลล์เดียว (HGET/HSET/HINCRBY) ได้โดยไม่แตะที่เหลือ และ — ตั้งแต่ 7.4 — ตั้งนาฬิกาปลุกบนเซลล์เดียวได้ ทุกอย่างในนั้นเป็นข้อความ ตัวแถวไม่มี schema และไม่มีแถวว่าง: ไม่มี field คือไม่มี key ปฏิบัติกับ HGETALL เหมือน SELECT *: ไม่เป็นไรกับแถวแคบ แต่อันตรายกับทั้งตาราง
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| Hash (field เป็นคู่ field-value) | อ่าน/เขียน field เดียวโดยไม่ต้อง parse ทั้งก้อน (HGET/HSET); HINCRBY แบบ atomic; TTL ต่อ field (7.4+) | ไม่มีโครงสร้างซ้อนหรือ type ในตัว — ต้อง flatten ข้อมูลซับซ้อนเองและ parse string กลับ |
| String ที่เก็บ JSON blob | รองรับข้อมูลซับซ้อน/ซ้อนกันและ type จริงได้เป็นธรรมชาติ | ต้อง GET ทั้งก้อนและ parse ทุกครั้งแม้ต้องการ field เดียว; เขียนทับทั้ง blob เสมอ |
HGETALL | เรียกครั้งเดียวได้ทั้ง object | O(N) บน command thread: 7.8 ms และส่ง 100,000 field สำหรับ hash 100k field; ใช้ HMGET หรือ HSCAN |
HDEL ทีละ field | ลบ field ที่ไม่ต้องการพอดีโดยไม่แตะตัวอื่น | ต้องรู้ชื่อ field ล่วงหน้า — ไม่มีการลบด้วย wildcard ในตัว |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “hash เก็บค่าที่มี type” ทุกอย่างเป็น string;
falseกลายเป็น"false"และ17กลายเป็น"17" - “
HGETALLบน key ที่ไม่มีคือnull” เป็น array ว่าง ({}ใน ioredis) ตรวจความว่าง - “
HSETคืนว่าสำเร็จหรือไม่” มันคืนจำนวน field ใหม่; การอัปเดตนับ 0 - “ทำให้ field หมดอายุไม่ได้” ตั้งแต่ Redis 7.4 ทำได้ (
HEXPIRE); คำแนะนำเก่าที่ให้แยก field เป็น key ต่างหากเพื่อ TTL ไม่ใช้แล้ว
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- เก็บ object เป็น JSON string ทั้งที่ field ส่วนใหญ่เป็นคู่ key-value แบบแบน — ต้องจ่ายค่า parse/serialize ทุกครั้งที่อ่านหรือเขียนแม้แค่ field เดียว
- เรียก
HGETALLซ้ำ ๆ บน hash ที่มี field เยอะ — โหลดทุก field เข้าหน่วยความจำ client ทั้งที่ต้องการแค่หนึ่งสองตัว ใช้HMGETหรือHGETแทน หรือHSCANสำหรับ hash ใหญ่ - ลืมว่า
HDELคืนจำนวน field ที่ถูกลบจริง ไม่ใช่ boolean — ค่า0ดูเหมือน error ได้ง่าย แต่แปลว่าไม่มี field นั้นอยู่แต่แรก - อ่านตัวเลขกลับโดยไม่แปลง —
hgetallให้ string; แปลงด้วยNumber()และเก็บ boolean อย่างตั้งใจ ('1'/'0'หรือ'true'/'false')
Real-world example
เอกสารของ Redis เอง นำเสนอ hash เป็น type สำหรับแทน object เช่น ผู้ใช้หรือสินค้า พร้อมใช้
HINCRBYสำหรับ counter ข้างใน (Redis hashes); คำสั่ง expiration ต่อ field ระบุไว้ในหน้าHEXPIREของ 7.4 (HEXPIRE)