Counters
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...เป็น session เดียวเรียงตามลำดับเอกสาร และ harness ของคอร์สรันซ้ำทั้งหมด
จำนวนเต็มที่เก็บเป็น string
หัวข้อที่มีชื่อว่า “จำนวนเต็มที่เก็บเป็น string”Redis เก็บทุกอย่างเป็น string แต่รู้ว่า string ใดเป็นจำนวนเต็ม (หรือทศนิยม) ที่ถูกต้อง และให้คุณคำนวณกับมันด้วยคำสั่งเฉพาะ ประเด็นสำคัญคือการดำเนินการเหล่านี้ atomic — Redis รันแต่ละคำสั่งเป็นขั้นตอนเดียวที่ขัดจังหวะไม่ได้
INCR และ DECR
หัวข้อที่มีชื่อว่า “INCR และ DECR”INCR key เพิ่มค่าจำนวนเต็มขึ้น 1 DECR key ลดลง 1 ถ้า key ไม่มีอยู่ Redis ถือว่าค่าที่หายไปเป็น 0 ก่อนทำการดำเนินการ ไม่มีพื้นที่ศูนย์: DECR ลงไปติดลบได้เลย
127.0.0.1:6379> SET pageviews:home 0OK127.0.0.1:6379> INCR pageviews:home(integer) 1127.0.0.1:6379> INCR pageviews:home(integer) 2127.0.0.1:6379> INCR pageviews:home(integer) 3127.0.0.1:6379> DECR pageviews:home(integer) 2127.0.0.1:6379> DECR pageviews:home(integer) 1127.0.0.1:6379> DECR pageviews:home(integer) 0127.0.0.1:6379> DECR pageviews:home(integer) -1127.0.0.1:6379> INCR views:new(integer) 1SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
INCR pageviews:home
DECR pageviews:homeINCRBY และ DECRBY
หัวข้อที่มีชื่อว่า “INCRBY และ DECRBY”เมื่อต้องการบวกหรือลบค่าอื่นที่ไม่ใช่ 1 ใช้ INCRBY และ DECRBY:
127.0.0.1:6379> SET score:player1 100OK127.0.0.1:6379> INCRBY score:player1 50(integer) 150127.0.0.1:6379> DECRBY score:player1 30(integer) 120127.0.0.1:6379> INCRBY score:player1 -10(integer) 110SET score:player1 100
INCRBY score:player1 50
DECRBY score:player1 30
INCRBY score:player1 -10INCRBYFLOAT
หัวข้อที่มีชื่อว่า “INCRBYFLOAT”สำหรับเลขทศนิยม (ราคา ค่าเฉลี่ย ข้อมูลเซนเซอร์) ใช้ INCRBYFLOAT ไม่มี DECRBYFLOAT — ส่ง delta ติดลบแทน reply เป็น string (bulk reply) ไม่ใช่ integer:
127.0.0.1:6379> SET temperature 22.5OK127.0.0.1:6379> INCRBYFLOAT temperature 1.3"23.8"127.0.0.1:6379> INCRBYFLOAT temperature -5.0"18.8"127.0.0.1:6379> GET temperature"18.8"127.0.0.1:6379> SET f 0.1OK127.0.0.1:6379> INCRBYFLOAT f 0.2"0.3"127.0.0.1:6379> INCRBYFLOAT nokey 5"5"ดู 0.1 + 0.2 = "0.3" Redis คำนวณด้วย long double ของ C และพิมพ์ผลด้วยความแม่นยำคงที่ 17 หลักหลังจุดทศนิยม ตัดศูนย์ท้ายทิ้ง (เอกสาร INCRBYFLOAT) ดังนั้น artefact แบบ binary float คลาสสิก (0.30000000000000004 ใน JavaScript) จึงไม่โผล่ใน reply นั่นคือ การแสดงผล ไม่ใช่ความแม่นยำสมบูรณ์: ค่ายังเป็น binary float และการบวกเล็ก ๆ หลายครั้งอาจคลาดเคลื่อน สำหรับเงินให้นับเป็นสตางค์ด้วย INCRBY
SET temperature 22.5
INCRBYFLOAT temperature 1.3
INCRBYFLOAT temperature -5.0
GET temperatureสิ่งที่ INCR ปฏิเสธ
หัวข้อที่มีชื่อว่า “สิ่งที่ INCR ปฏิเสธ”INCR ใช้ได้กับ string ที่เป็นจำนวนเต็ม signed 64 บิตแบบ canonical เท่านั้น อย่างอื่นเป็น error และ error นี้ควรจำได้ใน log ของ production:
127.0.0.1:6379> SET big 9223372036854775806OK127.0.0.1:6379> INCR big(integer) 9223372036854775807127.0.0.1:6379> INCR big(error) ERR increment or decrement would overflow127.0.0.1:6379> SET word helloOK127.0.0.1:6379> INCR word(error) ERR value is not an integer or out of range127.0.0.1:6379> SET dec 3.5OK127.0.0.1:6379> INCR dec(error) ERR value is not an integer or out of range127.0.0.1:6379> SET pad 007OK127.0.0.1:6379> INCR pad(error) ERR value is not an integer or out of range127.0.0.1:6379> SET t 5 EX 100OK127.0.0.1:6379> INCR t(integer) 6127.0.0.1:6379> TTL t(integer) 100127.0.0.1:6379> OBJECT ENCODING t"int"ค่าสูงสุดคือ 2^63 − 1 = 9223372036854775807; INCR ถัดไปล้มเหลวโดยไม่เปลี่ยน key ทศนิยม (3.5) string ที่มี padding (007) หรือข้อความถูกปฏิเสธ (INCRBYFLOAT รับ 3.5) และ INCR คง TTL ของ key ที่แก้ไว้ — ไม่เหมือน SET ภายในค่าเก็บเป็นจำนวนเต็ม 64 บิต (OBJECT ENCODING เป็น int)
Real-world pattern: ตัวนับ page-view
หัวข้อที่มีชื่อว่า “Real-world pattern: ตัวนับ page-view”เพราะ INCR กำหนดค่าเริ่มต้นของ key ที่ไม่มีเป็น 0 ก่อนเพิ่ม คุณไม่ต้อง SET 0 ก่อน:
127.0.0.1:6379> INCR views:article:42(integer) 1127.0.0.1:6379> INCR views:article:42(integer) 2127.0.0.1:6379> INCR views:article:42(integer) 3127.0.0.1:6379> GET views:article:42"3"INCR views:article:42
INCR views:article:42
INCR views:article:42
GET views:article:42INCR atomic จริงไหม? 10,000 การเพิ่มที่แข่งกัน
หัวข้อที่มีชื่อว่า “INCR atomic จริงไหม? 10,000 การเพิ่มที่แข่งกัน”ยี่สิบ connection แยกกัน แต่ละตัวยิง INCR 500 ครั้งใส่ key เดียวกันพร้อมกัน ถ้า INCR เป็นแบบอ่านแล้วเขียน ผลรวมจะขาด:
// @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:hits');
// 20 connections x 500 INCR each, all racing: exactly 10000, nothing lostawait Promise.all(clients.map(async (c) => { for (let i = 0; i < 500; i++) await c.incr('b1:hits'); }));console.log('INCR total:', await admin.get('b1:hits')); // "10000"
// INCRBYFLOAT: Redis prints 0.3, JavaScript doubles do notawait admin.set('b1:f', '0.1');console.log('Redis 0.1+0.2 =', await admin.incrbyfloat('b1:f', 0.2), '| JS 0.1+0.2 =', 0.1 + 0.2);
await admin.del('b1:hits', 'b1:f');clients.forEach((c) => c.disconnect());output จริง: INCR total: 10000 และ Redis 0.1+0.2 = 0.3 | JS 0.1+0.2 = 0.30000000000000004 ได้ 10,000 พอดี ไม่สูญเสียเลย เทียบกับเวอร์ชัน GET แล้ว SET จากบท What is Redis ซึ่งเสียไป 49 จาก 50
Real-world pattern: rate limiter (และกับดักของมัน)
หัวข้อที่มีชื่อว่า “Real-world pattern: rate limiter (และกับดักของมัน)”ติดตามว่า IP หนึ่งส่ง request ไปกี่ครั้งใน window: INCR key ต่อ IP และต้องแน่ใจว่า window หมดอายุ เวอร์ชันตรงไปตรงมาคือสองคำสั่ง:
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5(integer) 1127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX(integer) 1127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5(integer) 2127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX(integer) 0127.0.0.1:6379> TTL ratelimit:ip:203.0.113.5(integer) 60ในโค้ดแอป คุณตรวจว่า: ถ้าผลของ INCR เกิน limit (เช่น 100) ให้ปฏิเสธ request key ลบตัวเองหลัง 60 วินาที ทำให้ window รีเซ็ต สังเกต flag บน EXPIRE: NX (Redis 7.0+) ตั้ง timeout เฉพาะเมื่อ key ยังไม่มี EXPIRE ครั้งที่สองคืน 0 และไม่แตะ window — ถ้าไม่มี NX ทุก request จะผลักเส้นตายออกไป และ client ที่ใช้งานอยู่จะไม่ถูกปล่อยเลย
กับดัก: INCR และ EXPIRE เป็นสองคำสั่ง ไม่ใช่หนึ่ง แต่ละคำสั่ง atomic; แต่คู่ของมันไม่ใช่ ถ้า process ตาย (หรือเครือข่ายหลุด) ระหว่างสองคำสั่งใน request แรก counter จะมีอยู่โดยไม่มี TTL และไม่รีเซ็ตเลย — IP นั้นถูกบล็อกตลอดไป เรา reproduce ได้พอดี:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:rl:203.0.113.5';await redis.del(key);
// BUGGY: INCR and EXPIRE are two commands. If the process dies between them ...async function hitBuggy(crashAfterIncr: boolean) { const n = await redis.incr(key); if (crashAfterIncr) throw new Error('process died between INCR and EXPIRE'); if (n === 1) await redis.expire(key, 60); return n;}await hitBuggy(true).catch(() => {});await hitBuggy(false); // the next request sees n === 2, so it never sets the TTLconsole.log('buggy: count', await redis.get(key), 'ttl', await redis.ttl(key)); // ttl -1 = blocked forever
// FIXED: EXPIRE ... NX on every hit, in one MULTI. NX only sets a TTL when there is none,// so it cannot extend the window, and it repairs a key that lost its TTL.async function hit() { const res = await redis.multi().incr(key).expire(key, 60, 'NX').exec(); return res![0][1] as number;}console.log('fixed: count', await hit(), 'ttl', await redis.ttl(key)); // ttl 60
await redis.del(key);await redis.quit();output: buggy: count 2 ttl -1 และ fixed: count 3 ttl 60 วิธีแก้ทำสองอย่าง: MULTI/EXEC ส่งสองคำสั่งเป็นหน่วยเดียว และ EXPIRE ... NX ใน ทุก hit (ไม่ใช่แค่เมื่อ n === 1) ทำให้ซ่อมตัวเองได้: key ที่ TTL หายไปด้วยเหตุใดก็ตามจะได้ TTL ใน request ถัดไป รูปแบบ Lua script ของการป้องกันแบบเดียวกันอยู่ในบท Rate Limiting
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน rate limiter ของ API และเพิ่ม comment ที่ฟังดูสบายใจ:
🐛 หา bug ในโค้ดนี้
async function allow(ip: string): Promise<boolean> {const key = 'rl:' + ip;const n = await redis.incr(key); // atomicif (n === 1) await redis.expire(key, 60); // window starts on the first hitreturn n <= 100;}เฉลย
แต่ละคำสั่ง atomic แต่ฟังก์ชันไม่ใช่ ถ้า process crash, connection รีเซ็ต หรือ request ถูกยกเลิกหลัง INCR คืน 1 และก่อน EXPIRE รัน key จะ ไม่มี TTL การเรียกครั้งต่อ ๆ ไปเห็น n > 1 ข้าม EXPIRE และเมื่อ n เกิน 100 client ถูกปฏิเสธตลอดไป เรา reproduce แล้ว: หลังจำลอง crash ระหว่างสองคำสั่ง TTL เป็น -1 และคงอยู่แบบนั้น วิธีแก้ (แสดงข้างบน) คือเรียก EXPIRE key 60 NX ทุก hit ภายใน MULTI/EXEC (หรือ Lua script เดียว) เพื่อให้ TTL ถูกซ่อมเสมอ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ตัวนับใน Redis คือ จำนวนเต็มที่มีปุ่ม “บวก” แบบไม่ต้องล็อก: การกดปุ่มเป็นขั้นตอนเดียวที่แบ่งไม่ได้ ไม่ว่าจะมีคนกดพร้อมกันกี่คน สิ่งที่ปุ่มไม่ทำคือจำไว้ว่าต้องตั้งนาฬิกาปลุก (TTL) — นั่นเป็นการกดที่สองแยกต่างหาก และการกดสองครั้งใด ๆ ถูกขัดจังหวะได้ระหว่างกลาง เมื่อตัวนับต้องมีอายุ ให้รวมสองอย่างเข้าด้วยกัน: EXPIRE ... NX หลังทุกการเพิ่ม, MULTI หรือ script
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
ใช้ INCR/INCRBY เป็นตัวนับใน Redis | atomic โดยค่าเริ่มต้น ไม่ต้องล็อกฝั่งแอป รองรับ throughput สูง | ค่าอยู่ใน key เดียว — ไม่มีประวัติหรือ audit trail แบบตารางฐานข้อมูล |
ผูก EXPIRE กับ counter (rate limiter) | window เลื่อนได้ฟรี ไม่ต้องมี cleanup job ลบ counter เก่า | ถ้า INCR กับ EXPIRE ไม่ถูกรวมกัน (MULTI, NX ทุก hit หรือ Lua) crash ระหว่างสองคำสั่งทำให้ counter ถาวร |
ใช้ INCRBYFLOAT แทน read-modify-write ฝั่ง client | เลี่ยง race condition เหมือน INCR | ไม่มี DECRBYFLOAT; binary floating point สะสมความคลาดเคลื่อนเมื่อบวกหลายครั้ง — เงินให้ใช้จำนวนเต็มของสตางค์ |
| เก็บ counter ใน Redis เทียบกับเก็บในฐานข้อมูลอย่างเดียว | เพิ่มได้หลายล้านครั้งต่อวินาทีอย่างถูก อ่านแบบ real-time | Redis ไม่ใช่สมุดบัญชีของคุณ: durability ขึ้นกับ config persistence จึงควรเขียนกลับฐานข้อมูลเป็นระยะ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
INCRต้องให้ key มีอยู่ก่อน” ไม่ต้อง; key ที่ไม่มีเริ่มจาก 0 - “
INCRแล้วEXPIREatomic เพราะทั้งสองเป็น atomic” สองคำสั่ง atomic คือสองรอบ client อื่น (หรือ crash) แทรกได้ระหว่างกลาง - “
INCRรีเซ็ต TTL เหมือนSET” มันไม่แตะ TTL - “
INCRBYFLOATเป็นเลขฐานสิบที่แม่นยำสมบูรณ์” พิมพ์สวย (0.3) แต่ข้างใต้ยังเป็น binary float
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- อ่านค่าปัจจุบันด้วย
GETบวกในเครื่อง แล้วเขียนกลับด้วยSET— เปิดทางให้เกิด race condition คลาสสิกเมื่อหลาย client เขียนพร้อมกัน ใช้INCR/INCRBYเพื่อ atomicity เสมอ - ตั้ง
EXPIREเฉพาะเมื่อINCRคืน 1 — crash หลังการเพิ่มทิ้ง counter ที่ไม่มี TTL ใช้EXPIRE key ttl NXทุก hit ในMULTIเดียวกัน - คิดว่า
INCRต้องมี key อยู่ก่อน — ไม่ต้องINCRเริ่มต้น key ที่ไม่มีเป็น0ก่อนเพิ่มให้อัตโนมัติ ไม่ต้องSET key 0ก่อน
Real-world example
GitHub เคยเผยแพร่ว่าใช้ counter บน Redis (
INCR+EXPIRE) สำหรับ rate limit ของ API ควบคู่กับข้อมูลชั่วคราว/cache อื่น ก่อนย้าย workload ของ counter ที่ต้อง persist บางส่วนไปยัง store บน MySQL เมื่อระบบโตขึ้น (Moving persistent data out of Redis, GitHub Engineering, 2015 — บันทึกเมื่อสิบปีก่อน)