การ model Object ด้วย Hashes
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ตัวเลขหน่วยความจำมาจากMEMORY USAGEและผลต่างของused_memoryในINFO memoryบนเครื่องนี้ จำนวน byte เฉพาะเจาะจงกับชื่อ field และค่าเหล่านี้ (jemalloc, 64-bit)
หนึ่ง hash ต่อหนึ่ง object — pattern มาตรฐาน
หัวข้อที่มีชื่อว่า “หนึ่ง hash ต่อหนึ่ง object — pattern มาตรฐาน”วิธีมาตรฐานในการโมเดล domain object ใน Redis คือเก็บทุก field ไว้ใต้ hash key เดียว ตัวอย่างเช่น record ผู้ใช้อยู่ครบภายใต้ user:1:
127.0.0.1:6379> HSET user:1 name "Ada" email "[email protected]" age "28" points "0"(integer) 4127.0.0.1:6379> HGETALL user:11) "name"2) "Ada"3) "email"4) "[email protected]"5) "age"6) "28"7) "points"8) "0"เปรียบเทียบกับทางเลือกที่ใช้หลาย key:
| แนวทาง | key ที่ต้องใช้ |
|---|---|
| Hash | user:1 — key เดียว HSET ครั้งเดียว |
| String แยก | user:1:name, user:1:email, user:1:age, user:1:points — สี่ key |
แนวทาง hash ใช้หน่วยความจำน้อยกว่า และให้ดึงหรืออัปเดต field ที่ต้องการ — ด้วย HGET / HSET — โดยไม่ต้องโหลดหรือแตะ field อื่นใน object น้อยกว่าเท่าไร? วัดเอา อย่าเชื่อกฎทั่วไป
HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
HGETALL user:1hash ประหยัดหน่วยความจำจริงเท่าไร?
หัวข้อที่มีชื่อว่า “hash ประหยัดหน่วยความจำจริงเท่าไร?”สี่ field เดียวกัน (name, email, age, points) เก็บสองแบบ สำหรับ object หนึ่งตัว MEMORY USAGE บอกว่า:
| รูปแบบ | Byte | Encoding |
|---|---|---|
1 hash user:1 | 87 | listpack |
| 4 string key | 157 (40 + 56 + 29 + 32) | embstr ทุกตัว |
สำหรับผู้ใช้ 100,000 คน (used_memory ก่อนและหลังโหลดแต่ละรูปแบบลงเซิร์ฟเวอร์ว่าง):
| รูปแบบ | รวม | ต่อผู้ใช้ |
|---|---|---|
| hash 100,000 ตัว | 12.7 MB | 127 B |
| string key 400,000 ตัว | 28.2 MB | 282 B |
ดังนั้นกับข้อมูลนี้ hash ใช้หน่วยความจำ 45% — ต่ำกว่าราว 155 byte ต่อ object หรือต่างกัน 2.2 เท่า กฎทั่วไปที่เคยอยู่ในบทนี้ (“ประมาณ 50-80 byte ต่อ object”) ไม่รอดการวัด: ส่วนต่างขึ้นกับจำนวน field และความยาวชื่อ key แต่ string key แยกทุกตัวต้องจ่ายค่า key object ของตัวเอง ช่องใน dictionary ของตัวเอง และการปัดเศษของ allocator คำแนะนำของ Redis เองก็บอกเรื่องเดียวกันเป็นคำพูด — “use hashes when possible” เพราะ hash เล็กถูกเข้ารหัสแบบกะทัดรัด (Memory optimization) ส่วนต่างนี้อยู่ได้เฉพาะเมื่อ hash ยังเป็น listpack (ไม่เกิน 512 field, value ไม่เกิน 64 byte — ดูบท Hashes); hash ที่กลายเป็น hash table แล้วไม่กะทัดรัด
HINCRBY และ HINCRBYFLOAT
หัวข้อที่มีชื่อว่า “HINCRBY และ HINCRBYFLOAT”เมื่อ field ของ hash เก็บตัวเลข Redis เพิ่มค่าให้แบบ atomic ได้ — ไม่มี race แบบ read-modify-write:
HINCRBY key field increment— เพิ่ม field จำนวนเต็มด้วยจำนวนเต็มHINCRBYFLOAT key field increment— เพิ่ม field ทศนิยมด้วยค่าทศนิยม
127.0.0.1:6379> HINCRBY user:1 points 10(integer) 10127.0.0.1:6379> HINCRBY user:1 age 1(integer) 29127.0.0.1:6379> HINCRBYFLOAT user:1 score 1.5"1.5"127.0.0.1:6379> HINCRBYFLOAT user:1 score 0.5"2"127.0.0.1:6379> HGET user:1 points"10"127.0.0.1:6379> HINCRBY user:1 visits 5(integer) 5ถ้ายังไม่มี field ทั้งสองคำสั่งถือว่าค่าเริ่มต้นเป็น 0 ก่อนบวก ดังนั้น HINCRBY user:1 visits 5 บน field visits ที่ไม่มีจะสร้างมันด้วยค่า 5 สังเกตว่า HINCRBYFLOAT ... 0.5 พิมพ์ "2" ไม่ใช่ "2.0": reply ตัดศูนย์ท้าย
HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
HINCRBY user:1 points 10
HINCRBY user:1 age 1
HINCRBYFLOAT user:1 score 1.5
HINCRBYFLOAT user:1 score 0.5
HGET user:1 pointsพิสูจน์: HINCRBY ภายใต้การใช้งานพร้อมกัน
หัวข้อที่มีชื่อว่า “พิสูจน์: HINCRBY ภายใต้การใช้งานพร้อมกัน”ยี่สิบ connection แต่ละตัวบวก 1 ให้ field points 50 ครั้ง ครั้งหนึ่งด้วยรูปแบบอ่านแล้วเขียน และอีกครั้งด้วย HINCRBY จากนั้นยี่สิบ connection แย่งกัน HSETNX field created_at:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const clients = Array.from({ length: 20 }, () => new Redis(url));const admin = clients[0];await admin.del('b1:user:1', 'b1:user:2');await admin.hset('b1:user:1', { name: 'Ada', points: 0 });await admin.hset('b1:user:2', { name: 'Bob', points: 0 });
// the AI-written way: read, add, write back (20 connections x 50 rounds)await Promise.all(clients.map(async (c) => { for (let i = 0; i < 50; i++) { const p = Number(await c.hget('b1:user:1', 'points')); await c.hset('b1:user:1', 'points', p + 1); }}));console.log('HGET+HSET points:', await admin.hget('b1:user:1', 'points'), '(expected 1000)');
// the fix: HINCRBY is one atomic commandawait Promise.all(clients.map(async (c) => { for (let i = 0; i < 50; i++) await c.hincrby('b1:user:2', 'points', 1); }));console.log('HINCRBY points:', await admin.hget('b1:user:2', 'points'), '(expected 1000)');
// HSETNX: 20 writers race to stamp created_at, exactly one winsconst wins = await Promise.all(clients.map((c, i) => c.hsetnx('b1:user:2', 'created_at', String(i))));console.log('HSETNX winners:', wins.reduce((a, b) => a + b, 0));
await admin.del('b1:user:1', 'b1:user:2');clients.forEach((c) => c.disconnect());output จริง: HGET+HSET points: 91 (expected 1000), HINCRBY points: 1000 (expected 1000), HSETNX winners: 1 เวอร์ชัน read-modify-write เสียไป 909 จาก 1,000 การเพิ่ม (ตัวเลขแม่นยำต่างกันทุกรอบ); HINCRBY ไม่เสียเลย
HSETNX — ตั้ง field แบบมีเงื่อนไข
หัวข้อที่มีชื่อว่า “HSETNX — ตั้ง field แบบมีเงื่อนไข”HSETNX key field value ตั้ง field เฉพาะเมื่อยังไม่มี คืน 1 ถ้าสร้าง field แล้ว 0 ถ้ามีอยู่แล้วและไม่เปลี่ยนแปลง
127.0.0.1:6379> HSETNX user:1 email "[email protected]"(integer) 0127.0.0.1:6379> HGET user:1 email127.0.0.1:6379> HSETNX user:1 referrer "bob"(integer) 1127.0.0.1:6379> HGET user:1 referrer"bob"กรณีใช้งานจริง: ประทับ field created_at เพียงครั้งเดียวตอนเขียน object ครั้งแรก เพราะ HSETNX เป็น atomic ผู้เขียนพร้อมกันสองคนจึงชนะทั้งคู่ไม่ได้ — ในการแข่งข้างบน ผู้เขียน 20 คนได้ 1 เพียงหนึ่งครั้ง
HSET user:1 name "Ada" email "[email protected]"
HSETNX user:1 email "[email protected]"
HGET user:1 email
HSETNX user:1 referrer "bob"
HGET user:1 referrerHash เทียบกับ string key หลายตัว — ใช้อะไรเมื่อไร
หัวข้อที่มีชื่อว่า “Hash เทียบกับ string key หลายตัว — ใช้อะไรเมื่อไร”| Hash (key เดียว) | String key หลายตัว | |
|---|---|---|
| หน่วยความจำ | กะทัดรัด: 87 เทียบ 157 byte สำหรับสี่ field สั้น; 2.2 เท่าที่ 100k object | overhead ต่อ key สูงกว่า |
| ดึง field เดียว | HGET user:1 name | GET user:1:name |
| ดึงทุก field | HGETALL user:1 | MGET user:1:name user:1:email ... |
| เพิ่มค่าแบบ atomic | HINCRBY | INCR user:1:points |
| TTL ต่อ field | ได้ตั้งแต่ Redis 7.4: HEXPIRE / HSETEX (ดูด้านล่าง) | ได้ (แต่ละ key มี TTL ของตัวเอง) |
| TTL ทั้ง object | EXPIRE user:1 | EXPIRE หนึ่งครั้งต่อ key (และอาจเหลื่อมกัน) |
| ลบ object | DEL/UNLINK ครั้งเดียว | ลบทุก key |
กฎเบื้องต้น: เลือกหนึ่ง hash ต่อหนึ่ง object การแยกเป็นหลาย key เพียงเพื่อให้ field หมดอายุได้ไม่ใช่เหตุผลอีกต่อไป
TTL ต่อ field บน hash ของ object
หัวข้อที่มีชื่อว่า “TTL ต่อ field บน hash ของ object”Redis 7.4 เพิ่ม field expiration และ Redis 8 รวมเข้าใน HSETEX/HGETEX field รหัส OTP ที่หมดอายุอยู่ข้าง field โปรไฟล์ถาวร ทั้งหมดใต้ user:1:
127.0.0.1:6379> HSETEX user:1 EX 60 FIELDS 1 otp 123456(integer) 1127.0.0.1:6379> HTTL user:1 FIELDS 2 otp email1) (integer) 602) (integer) -1127.0.0.1:6379> HMGET user:1 name otp1) "Ada"2) "123456"127.0.0.1:6379> EXPIRE user:1 100(integer) 1127.0.0.1:6379> TTL user:1(integer) 100127.0.0.1:6379> OBJECT ENCODING user:1"listpackex"otp มี TTL 60 วินาที email ไม่มี (-1) EXPIRE user:1 100 ยังใช้ได้กับ ทั้ง hash แยกกันอิสระ และ encoding เปลี่ยนเป็น listpackex: listpack ที่พก metadata การหมดอายุต่อ field ดังนั้น hash ที่มี field TTL จึงใหญ่กว่า hash ธรรมดาเล็กน้อย (MEMORY USAGE user:1 ได้ 184 byte ที่นี่ กับแปด field รวม otp) คุ้มค่าสำหรับกรณีที่มันเปิดทางให้ แต่ไม่ฟรี — ตั้ง TTL กับ field ที่ต้องการ ไม่ใช่ทุก field
จาก Node.js
หัวข้อที่มีชื่อว่า “จาก Node.js”hset(key, object) เก็บ object; hgetall คืน string ห่อการแปลงไว้ที่เดียวเพื่อให้ตัวเลขและ boolean กลับมามี type และจำไว้ว่า key ที่ไม่มีคือ object ว่าง:
// see also src/lists-hashes-hashes-cache.ts in the Hashes lessontype User = { name: string; email: string; age: number; points: number };const toUser = (h: Record<string, string>): User | null => Object.keys(h).length === 0 ? null : { name: h.name, email: h.email, age: Number(h.age), points: Number(h.points) };Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียนการอัปเดตแต้มสะสมบน hash ของผู้ใช้ และอธิบายว่า “ปลอดภัยเพราะ Redis เป็น single-threaded”:
🐛 หา bug ในโค้ดนี้
async function addPoints(userId: number, n: number) {const key = 'user:' + userId;const current = Number(await redis.hget(key, 'points')); // readawait redis.hset(key, 'points', current + n); // write back}เฉลย
แต่ละคำสั่ง Redis เป็น atomic แต่ฟังก์ชันนี้คือสองคำสั่งที่มีแอปคั่นอยู่ตรงกลาง เมื่อทำงานพร้อมกัน การเพิ่มจะทับกัน เรารันแล้ว: 20 connection ที่แต่ละตัวให้ 1 แต้ม 50 ครั้ง จบที่ points: 91 ไม่ใช่ 1000; ภาระงานเดียวกันด้วย HINCRBY จบที่ 1000 พอดี (script ข้างบน) วิธีแก้คือคำสั่งเดียว:
await redis.hincrby('user:' + userId, 'points', n); // atomic; creates the field at 0 if missingMental Model
หัวข้อที่มีชื่อว่า “Mental Model”hash ต่อ object คือ การ์ดบันทึก: key คือช่องในตู้เก็บเอกสาร field คือบรรทัดบนการ์ด แก้บรรทัดเดียวโดยไม่เขียนการ์ดใหม่ (HSET) บวกตัวเลขบนการ์ดในที่เดิม (HINCRBY) ประทับบรรทัดเฉพาะเมื่อว่าง (HSETNX) และตั้งวันหมดอายุที่บรรทัดเดียว (HEXPIRE) หรือทั้งการ์ด (EXPIRE) กระดาษโน้ตแยกสี่แผ่น (string key สี่ตัว) กินกระดาษมากกว่าการ์ดใบเดียว — วัดได้ 2.2 เท่า — และเป็นสี่อย่างที่ต้องลบ ตั้งหมดอายุ และรักษาให้สอดคล้องกัน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| หนึ่ง hash ต่อหนึ่ง object | หน่วยความจำน้อยกว่า (45% ของแบบ string ในการทดสอบ); อัปเดต field เดียวด้วย HGET/HSET โดยไม่แตะตัวอื่น; ลบ object ด้วยคำสั่งเดียว | ค่าเป็น string ไม่มี type; field ที่มี TTL ทำให้ encoding เป็น listpackex (ใหญ่ขึ้นเล็กน้อย); hash ที่เกิน 512 field หรือ value เกิน 64 byte เสีย encoding แบบกะทัดรัด |
HINCRBY/HINCRBYFLOAT สำหรับ field ตัวเลข | เพิ่มค่าแบบ atomic ไม่มี race read-modify-write (เสีย 0 เทียบ 909 จาก 1000) | ใช้ได้กับ field ตัวเลขเท่านั้น; field อื่นยังต้องใช้ HSET ธรรมดา |
HSETNX สำหรับ field แบบมีเงื่อนไข | รับประกันว่าผู้เขียนคนแรกเท่านั้นตั้งค่า — เหมาะกับ created_at | อัปเดตค่าที่มีอยู่ไม่ได้; ใช้ HSET ธรรมดาเมื่อต้องการเขียนทับ |
| ใช้ string key หลายตัวแทน | แต่ละ key ถูก evict, หมดอายุ หรือย้ายได้อิสระ; ทำ TTL ต่อ field ได้บน Redis < 7.4 | หน่วยความจำ 2.2 เท่าที่นี่ และมีหลาย key ที่ต้องรักษาให้สอดคล้อง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ต้องแยก key ถ้าอยากได้ TTL ต่อ field” ไม่ใช่ตั้งแต่ Redis 7.4 (
HEXPIREและHSETEXใน 8.0) - “hash ประหยัด 50-80 byte” เราวัดได้ 155 byte ต่อ object สี่ field; ตัวเลขจริงขึ้นกับข้อมูลของคุณ — วัดด้วย
MEMORY USAGE - “single-threaded แปลว่า
HGETแล้วHSETปลอดภัย” มันคือสองคำสั่ง; แอปที่คั่นกลางคือ race (เสีย 909 จาก 1,000) - “field TTL ฟรี” hash กลายเป็น
listpackexซึ่งใหญ่ขึ้นเล็กน้อย
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- แยก field ของ object หนึ่งเป็น string key หลายตัว เช่น
user:1:name,user:1:email— สิ้นเปลืองหน่วยความจำ (วัดได้ 2.2 เท่า) และทวีคูณการลบและการตั้งหมดอายุ รวมเป็น hash เดียว; ใช้ field-level TTL ถ้า field บางตัวต้องหมดอายุ - ใช้
HSETธรรมดาแทนHINCRBYเพื่ออัปเดต field ตัวเลข — ต้องHGETแล้วHSETเป็นสอง round trip เปิดช่องให้เกิด race เมื่อมีผู้เขียนพร้อมกันหลายคน - ลืมว่า
HSETNXไม่เขียนทับค่าที่มีอยู่ — เข้าใจผิดเป็น “ตั้งหรืออัปเดต” ได้ง่าย แต่มันทำงานเมื่อ field ยังไม่มีเท่านั้น - เชื่อกฎเรื่องหน่วยความจำที่ท่องกันมา — วัด
MEMORY USAGEกับ object จริงของคุณ
Real-world example
คำแนะนำของ Redis เอง (Memory optimization, Redis Docs) แนะนำแบบนี้: โมเดล key-value ธรรมดาด้วย hash เพราะ hash ใหญ่ไม่กี่ตัวใช้หน่วยความจำน้อยกว่า key เดี่ยว ๆ มาก เนื่องจาก hash เล็กใช้ encoding แบบ listpack กะทัดรัดที่แสดงข้างบน