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

การ 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)

วิธีมาตรฐานในการโมเดล domain object ใน Redis คือเก็บทุก field ไว้ใต้ hash key เดียว ตัวอย่างเช่น record ผู้ใช้อยู่ครบภายใต้ user:1:

cli/lists-hashes-hash-objects-1.redis
127.0.0.1:6379> HSET user:1 name "Ada" email "[email protected]" age "28" points "0"
(integer) 4
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Ada"
3) "email"
5) "age"
6) "28"
7) "points"
8) "0"

เปรียบเทียบกับทางเลือกที่ใช้หลาย key:

แนวทางkey ที่ต้องใช้
Hashuser: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:1

สี่ field เดียวกัน (name, email, age, points) เก็บสองแบบ สำหรับ object หนึ่งตัว MEMORY USAGE บอกว่า:

รูปแบบByteEncoding
1 hash user:187listpack
4 string key157 (40 + 56 + 29 + 32)embstr ทุกตัว

สำหรับผู้ใช้ 100,000 คน (used_memory ก่อนและหลังโหลดแต่ละรูปแบบลงเซิร์ฟเวอร์ว่าง):

รูปแบบรวมต่อผู้ใช้
hash 100,000 ตัว12.7 MB127 B
string key 400,000 ตัว28.2 MB282 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 แล้วไม่กะทัดรัด

เมื่อ field ของ hash เก็บตัวเลข Redis เพิ่มค่าให้แบบ atomic ได้ — ไม่มี race แบบ read-modify-write:

  • HINCRBY key field increment — เพิ่ม field จำนวนเต็มด้วยจำนวนเต็ม
  • HINCRBYFLOAT key field increment — เพิ่ม field ทศนิยมด้วยค่าทศนิยม
cli/lists-hashes-hash-objects-2.redis
127.0.0.1:6379> HINCRBY user:1 points 10
(integer) 10
127.0.0.1:6379> HINCRBY user:1 age 1
(integer) 29
127.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

ยี่สิบ connection แต่ละตัวบวก 1 ให้ field points 50 ครั้ง ครั้งหนึ่งด้วยรูปแบบอ่านแล้วเขียน และอีกครั้งด้วย HINCRBY จากนั้นยี่สิบ connection แย่งกัน HSETNX field created_at:

src/lists-hashes-hash-objects-atomic.ts
// @run
import { 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 command
await 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 wins
const 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 key field value ตั้ง field เฉพาะเมื่อยังไม่มี คืน 1 ถ้าสร้าง field แล้ว 0 ถ้ามีอยู่แล้วและไม่เปลี่ยนแปลง

cli/lists-hashes-hash-objects-3.redis
127.0.0.1:6379> HSETNX user:1 email "[email protected]"
(integer) 0
127.0.0.1:6379> HGET user:1 email
127.0.0.1:6379> HSETNX user:1 referrer "bob"
(integer) 1
127.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 referrer
Hash (key เดียว)String key หลายตัว
หน่วยความจำกะทัดรัด: 87 เทียบ 157 byte สำหรับสี่ field สั้น; 2.2 เท่าที่ 100k objectoverhead ต่อ key สูงกว่า
ดึง field เดียวHGET user:1 nameGET user:1:name
ดึงทุก fieldHGETALL user:1MGET user:1:name user:1:email ...
เพิ่มค่าแบบ atomicHINCRBYINCR user:1:points
TTL ต่อ fieldได้ตั้งแต่ Redis 7.4: HEXPIRE / HSETEX (ดูด้านล่าง)ได้ (แต่ละ key มี TTL ของตัวเอง)
TTL ทั้ง objectEXPIRE user:1EXPIRE หนึ่งครั้งต่อ key (และอาจเหลื่อมกัน)
ลบ objectDEL/UNLINK ครั้งเดียวลบทุก key

กฎเบื้องต้น: เลือกหนึ่ง hash ต่อหนึ่ง object การแยกเป็นหลาย key เพียงเพื่อให้ field หมดอายุได้ไม่ใช่เหตุผลอีกต่อไป

Redis 7.4 เพิ่ม field expiration และ Redis 8 รวมเข้าใน HSETEX/HGETEX field รหัส OTP ที่หมดอายุอยู่ข้าง field โปรไฟล์ถาวร ทั้งหมดใต้ user:1:

cli/lists-hashes-hash-objects-4.redis
127.0.0.1:6379> HSETEX user:1 EX 60 FIELDS 1 otp 123456
(integer) 1
127.0.0.1:6379> HTTL user:1 FIELDS 2 otp email
1) (integer) 60
2) (integer) -1
127.0.0.1:6379> HMGET user:1 name otp
1) "Ada"
2) "123456"
127.0.0.1:6379> EXPIRE user:1 100
(integer) 1
127.0.0.1:6379> TTL user:1
(integer) 100
127.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

hset(key, object) เก็บ object; hgetall คืน string ห่อการแปลงไว้ที่เดียวเพื่อให้ตัวเลขและ boolean กลับมามี type และจำไว้ว่า key ที่ไม่มีคือ object ว่าง:

// see also src/lists-hashes-hashes-cache.ts in the Hashes lesson
type 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) };

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')); // read
await 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 missing

hash ต่อ object คือ การ์ดบันทึก: key คือช่องในตู้เก็บเอกสาร field คือบรรทัดบนการ์ด แก้บรรทัดเดียวโดยไม่เขียนการ์ดใหม่ (HSET) บวกตัวเลขบนการ์ดในที่เดิม (HINCRBY) ประทับบรรทัดเฉพาะเมื่อว่าง (HSETNX) และตั้งวันหมดอายุที่บรรทัดเดียว (HEXPIRE) หรือทั้งการ์ด (EXPIRE) กระดาษโน้ตแยกสี่แผ่น (string key สี่ตัว) กินกระดาษมากกว่าการ์ดใบเดียว — วัดได้ 2.2 เท่า — และเป็นสี่อย่างที่ต้องลบ ตั้งหมดอายุ และรักษาให้สอดคล้องกัน

ตัวเลือกข้อดีข้อเสีย
หนึ่ง 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 ที่ต้องรักษาให้สอดคล้อง
  • “ต้องแยก 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 ซึ่งใหญ่ขึ้นเล็กน้อย
  • แยก 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 กะทัดรัดที่แสดงข้างบน

HINCRBY user:1 points 5 ทำอะไรถ้ายังไม่มี field points?
HSETNX user:1 email '[email protected]' คืนอะไรถ้า field มีอยู่แล้ว?
แนวทางไหนใช้หน่วยความจำน้อยกว่าในการเก็บ object ผู้ใช้ใน Redis?
คำสั่งไหนเพิ่มค่า field ทศนิยมใน hash แบบ atomic?
ต้องการให้ field หนึ่งใน hash ต่อผู้ใช้หมดอายุใน 60 วินาทีขณะที่ที่เหลืออยู่ต่อ วิธีสมัยใหม่คืออะไร?
client 20 ตัวแต่ละตัวรัน HGET points แล้ว HSET points (n+1) 50 ครั้ง ค่าสุดท้ายที่คาดหวังคือเท่าไร และเราสังเกตได้เท่าไร?