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

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 บนเครื่องนี้

Redis Hash เก็บคู่ field-value หลายคู่ภายใต้ key เดียว ลองนึกภาพเป็นพจนานุกรมแบบแบนหรือแถวในฐานข้อมูล — key เดียวถือ field ที่มีชื่อได้หลายตัว ทั้ง field และ value เป็น string (binary-safe เหมือนทุกอย่างใน Redis)

HSET key field value [field value ...] ตั้งค่า field หนึ่งตัวหรือมากกว่า (รับหลายคู่ตั้งแต่ Redis 4.0; HMSET คือชื่อเก่าที่ deprecated) HGET key field ดึง field เดียวตามชื่อ ถ้า field ไม่มี Redis คืน (nil) HSET คืนจำนวน field ที่ถูก สร้างใหม่ — การอัปเดต field ที่มีอยู่ไม่นับ:

cli/lists-hashes-hashes-1.redis
127.0.0.1:6379> HSET product:1 name "Redis Mug" price "12.99" stock "200"
(integer) 3
127.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-field

HMGET key field [field ...] ดึงหลาย field ใน round-trip เดียว และคืน (nil) สำหรับ field ที่ไม่มี HGETALL key คืนทุกคู่ field-value ใน hash เป็นลิสต์สลับชื่อ field กับค่า

cli/lists-hashes-hashes-2.redis
127.0.0.1:6379> HMGET product:1 name stock nope
1) "Redis Mug XL"
2) "200"
3) (nil)
127.0.0.1:6379> HGETALL product:1
1) "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:1

HDEL key field [field ...] ลบ field หนึ่งตัวหรือมากกว่า และคืนจำนวน field ที่ถูกลบจริง HEXISTS key field คืน 1 ถ้า field มีอยู่ 0 ถ้าไม่มี — มีประโยชน์กับ logic เงื่อนไขโดยไม่ต้องดึงค่ามาเอง

cli/lists-hashes-hashes-3.redis
127.0.0.1:6379> HEXISTS product:1 price
(integer) 1
127.0.0.1:6379> HEXISTS product:1 nosuchfield
(integer) 0
127.0.0.1:6379> HDEL product:1 price
(integer) 1
127.0.0.1:6379> HDEL product:1 price
(integer) 0
127.0.0.1:6379> HEXISTS product:1 price
(integer) 0

HDEL ครั้งที่สองคืน 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:1

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 ถ้าไม่มี):

cli/lists-hashes-hashes-4.redis
127.0.0.1:6379> HKEYS product:1
1) "name"
2) "stock"
3) "color"
127.0.0.1:6379> HVALS product:1
1) "Redis Mug XL"
2) "200"
3) "red"
127.0.0.1:6379> HLEN product:1
(integer) 3
127.0.0.1:6379> HSTRLEN product:1 name
(integer) 12
127.0.0.1:6379> HSETNX product:1 name "ignored"
(integer) 0
127.0.0.1:6379> HSETNX product:1 sku "MUG-1"
(integer) 1
127.0.0.1:6379> HINCRBY product:1 stock -3
(integer) 197
127.0.0.1:6379> HINCRBY product:1 rating 5
(integer) 5
127.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 integer

HRANDFIELD key [count [WITHVALUES]] (6.2+) คืน field แบบสุ่ม — สะดวกสำหรับการสุ่มตัวอย่าง — และ count ติดลบอนุญาตให้ซ้ำ output ของมันสุ่มโดยนิยาม จึงแสดงเป็นการรันจริงหนึ่งครั้งแทน transcript ตายตัว:

127.0.0.1:6379> HRANDFIELD product:1 2 WITHVALUES
1) "stock"
2) "197"
3) "sku"
4) "MUG-1"

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 bytes
513 fields (value 'v'): hashtable, MEMORY USAGE 22,014 bytes
a 64-byte value -> listpack a 65-byte value -> hashtable

field ตัวที่ 513 และ byte ที่ 65 แต่ละอย่างทำให้สลับ การสลับแทบเป็นทางเดียวและคุณไม่ต้องสนใจมันในโค้ดแอป — แต่มันบอกว่าทำไม object เล็กจึงถูก และทำไม hash 100,000 field (หัวข้อถัดไป) เป็นสัตว์คนละชนิด

HGETALL เป็น O(N) ตามจำนวน field และรันบน command thread ผมสร้าง hash 100,000 field (field:<n> → value-<n>, 6 MB, hashtable) และอ่านเวลาฝั่งเซิร์ฟเวอร์:

คำสั่งเวลาฝั่งเซิร์ฟเวอร์บน hash 100,000 field
HGET หนึ่ง field / HMGET สอง field2 µs
HGETALL7,818 µs (7.8 ms)
HVALS5,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

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 อีกครั้ง

cli/lists-hashes-hashes-5.redis
127.0.0.1:6379> HSET session:42 user "ana" csrf "t0ken"
(integer) 2
127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HTTL session:42 FIELDS 2 csrf user
1) (integer) 600
2) (integer) -1
127.0.0.1:6379> HEXPIRE session:42 600 FIELDS 2 csrf nope
1) (integer) 1
2) (integer) -2
127.0.0.1:6379> HEXPIRE session:42 100 NX FIELDS 1 csrf
1) (integer) 0
127.0.0.1:6379> HEXPIRE session:42 100 XX FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HPERSIST session:42 FIELDS 1 csrf
1) (integer) 1
127.0.0.1:6379> HTTL session:42 FIELDS 1 csrf
1) (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

สามคำสั่งของ 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 ...]เขียนพร้อม TTLHSET + 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

cli/lists-hashes-hashes-6.redis
127.0.0.1:6379> HSETEX session:42 EX 900 FIELDS 1 csrf "fresh-token"
(integer) 1
127.0.0.1:6379> HGETEX session:42 EX 900 FIELDS 1 csrf
1) "fresh-token"
127.0.0.1:6379> HGETDEL session:42 FIELDS 1 csrf
1) "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

ioredis รับ object สำหรับ hset และให้ Record<string, string> จาก hgetall — ทุกค่ากลับมาเป็น string และ key ที่ไม่มีกลับมาเป็น {} คำสั่ง field-TTL มี wrapper (hexpire, httl); คำสั่งของ Redis 8 ส่งด้วย call() ได้ถ้า client รุ่นของคุณยังไม่มี wrapper:

src/lists-hashes-hashes-client.ts
// @run
import { 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 string
await 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 number
console.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 wrapper
console.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' ]

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):

src/lists-hashes-hashes-cache.ts
// @run
import { 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 checks
const buggy = await redis.hgetall('b1:user:1');
console.log('isAdmin (buggy):', buggy.isAdmin ? 'ADMIN' : 'user', '| stored value:', JSON.stringify(buggy.isAdmin)); // "false" is truthy
const 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();

hash คือ แถวสเปรดชีตเล็ก ๆ ที่เก็บใต้ key เดียว โดยหัวคอลัมน์ซ้ำอยู่ในทุกแถว คุณอ่านหรือแก้เซลล์เดียว (HGET/HSET/HINCRBY) ได้โดยไม่แตะที่เหลือ และ — ตั้งแต่ 7.4 — ตั้งนาฬิกาปลุกบนเซลล์เดียวได้ ทุกอย่างในนั้นเป็นข้อความ ตัวแถวไม่มี schema และไม่มีแถวว่าง: ไม่มี field คือไม่มี key ปฏิบัติกับ HGETALL เหมือน SELECT *: ไม่เป็นไรกับแถวแคบ แต่อันตรายกับทั้งตาราง

ตัวเลือกข้อดีข้อเสีย
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เรียกครั้งเดียวได้ทั้ง objectO(N) บน command thread: 7.8 ms และส่ง 100,000 field สำหรับ hash 100k field; ใช้ HMGET หรือ HSCAN
HDEL ทีละ fieldลบ field ที่ไม่ต้องการพอดีโดยไม่แตะตัวอื่นต้องรู้ชื่อ field ล่วงหน้า — ไม่มีการลบด้วย wildcard ในตัว
  • “hash เก็บค่าที่มี type” ทุกอย่างเป็น string; false กลายเป็น "false" และ 17 กลายเป็น "17"
  • “HGETALL บน key ที่ไม่มีคือ null” เป็น array ว่าง ({} ใน ioredis) ตรวจความว่าง
  • “HSET คืนว่าสำเร็จหรือไม่” มันคืนจำนวน field ใหม่; การอัปเดตนับ 0
  • “ทำให้ field หมดอายุไม่ได้” ตั้งแต่ Redis 7.4 ทำได้ (HEXPIRE); คำแนะนำเก่าที่ให้แยก field เป็น key ต่างหากเพื่อ TTL ไม่ใช้แล้ว
  • เก็บ 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)

คำสั่งไหนคืนคู่ field-value ทั้งหมดของ hash?
HGET key missing-field คืนอะไร?
HSET user:1 name 'Ana' age '30' สร้างกี่ field?
คำสั่งไหนนับจำนวน field ใน hash?
ต้องการอ่าน field ของ session และผลัก expiry ออกไปในการเรียก atomic เดียว ใช้คำสั่งไหน?
ใน ioredis await redis.hgetall('user:404') สำหรับ key ที่ไม่มี คืนอะไร?
hash คง encoding แบบ listpack กะทัดรัดจนกว่าจะเกินขีดจำกัดค่าเริ่มต้นใด?