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

Counters

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... เป็น session เดียวเรียงตามลำดับเอกสาร และ harness ของคอร์สรันซ้ำทั้งหมด

Redis เก็บทุกอย่างเป็น string แต่รู้ว่า string ใดเป็นจำนวนเต็ม (หรือทศนิยม) ที่ถูกต้อง และให้คุณคำนวณกับมันด้วยคำสั่งเฉพาะ ประเด็นสำคัญคือการดำเนินการเหล่านี้ atomic — Redis รันแต่ละคำสั่งเป็นขั้นตอนเดียวที่ขัดจังหวะไม่ได้

INCR key เพิ่มค่าจำนวนเต็มขึ้น 1 DECR key ลดลง 1 ถ้า key ไม่มีอยู่ Redis ถือว่าค่าที่หายไปเป็น 0 ก่อนทำการดำเนินการ ไม่มีพื้นที่ศูนย์: DECR ลงไปติดลบได้เลย

cli/keys-strings-counters-1.redis
127.0.0.1:6379> SET pageviews:home 0
OK
127.0.0.1:6379> INCR pageviews:home
(integer) 1
127.0.0.1:6379> INCR pageviews:home
(integer) 2
127.0.0.1:6379> INCR pageviews:home
(integer) 3
127.0.0.1:6379> DECR pageviews:home
(integer) 2
127.0.0.1:6379> DECR pageviews:home
(integer) 1
127.0.0.1:6379> DECR pageviews:home
(integer) 0
127.0.0.1:6379> DECR pageviews:home
(integer) -1
127.0.0.1:6379> INCR views:new
(integer) 1
SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
INCR pageviews:home
DECR pageviews:home

เมื่อต้องการบวกหรือลบค่าอื่นที่ไม่ใช่ 1 ใช้ INCRBY และ DECRBY:

cli/keys-strings-counters-2.redis
127.0.0.1:6379> SET score:player1 100
OK
127.0.0.1:6379> INCRBY score:player1 50
(integer) 150
127.0.0.1:6379> DECRBY score:player1 30
(integer) 120
127.0.0.1:6379> INCRBY score:player1 -10
(integer) 110
SET score:player1 100
INCRBY score:player1 50
DECRBY score:player1 30
INCRBY score:player1 -10

สำหรับเลขทศนิยม (ราคา ค่าเฉลี่ย ข้อมูลเซนเซอร์) ใช้ INCRBYFLOAT ไม่มี DECRBYFLOAT — ส่ง delta ติดลบแทน reply เป็น string (bulk reply) ไม่ใช่ integer:

cli/keys-strings-counters-3.redis
127.0.0.1:6379> SET temperature 22.5
OK
127.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.1
OK
127.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 ใช้ได้กับ string ที่เป็นจำนวนเต็ม signed 64 บิตแบบ canonical เท่านั้น อย่างอื่นเป็น error และ error นี้ควรจำได้ใน log ของ production:

cli/keys-strings-counters-4.redis
127.0.0.1:6379> SET big 9223372036854775806
OK
127.0.0.1:6379> INCR big
(integer) 9223372036854775807
127.0.0.1:6379> INCR big
(error) ERR increment or decrement would overflow
127.0.0.1:6379> SET word hello
OK
127.0.0.1:6379> INCR word
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET dec 3.5
OK
127.0.0.1:6379> INCR dec
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET pad 007
OK
127.0.0.1:6379> INCR pad
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET t 5 EX 100
OK
127.0.0.1:6379> INCR t
(integer) 6
127.0.0.1:6379> TTL t
(integer) 100
127.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)

เพราะ INCR กำหนดค่าเริ่มต้นของ key ที่ไม่มีเป็น 0 ก่อนเพิ่ม คุณไม่ต้อง SET 0 ก่อน:

cli/keys-strings-counters-5.redis
127.0.0.1:6379> INCR views:article:42
(integer) 1
127.0.0.1:6379> INCR views:article:42
(integer) 2
127.0.0.1:6379> INCR views:article:42
(integer) 3
127.0.0.1:6379> GET views:article:42
"3"
INCR views:article:42
INCR views:article:42
INCR views:article:42
GET views:article:42

ยี่สิบ connection แยกกัน แต่ละตัวยิง INCR 500 ครั้งใส่ key เดียวกันพร้อมกัน ถ้า INCR เป็นแบบอ่านแล้วเขียน ผลรวมจะขาด:

src/keys-strings-counters-hits.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:hits');
// 20 connections x 500 INCR each, all racing: exactly 10000, nothing lost
await 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 not
await 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

ติดตามว่า IP หนึ่งส่ง request ไปกี่ครั้งใน window: INCR key ต่อ IP และต้องแน่ใจว่า window หมดอายุ เวอร์ชันตรงไปตรงมาคือสองคำสั่ง:

cli/keys-strings-counters-6.redis
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5
(integer) 1
127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX
(integer) 1
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5
(integer) 2
127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX
(integer) 0
127.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 ได้พอดี:

src/keys-strings-counters-window.ts
// @run
import { 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 TTL
console.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

AI เขียน rate limiter ของ API และเพิ่ม comment ที่ฟังดูสบายใจ:

🐛 หา bug ในโค้ดนี้

async function allow(ip: string): Promise<boolean> {
const key = 'rl:' + ip;
const n = await redis.incr(key); // atomic
if (n === 1) await redis.expire(key, 60); // window starts on the first hit
return 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 ถูกซ่อมเสมอ

ตัวนับใน Redis คือ จำนวนเต็มที่มีปุ่ม “บวก” แบบไม่ต้องล็อก: การกดปุ่มเป็นขั้นตอนเดียวที่แบ่งไม่ได้ ไม่ว่าจะมีคนกดพร้อมกันกี่คน สิ่งที่ปุ่มไม่ทำคือจำไว้ว่าต้องตั้งนาฬิกาปลุก (TTL) — นั่นเป็นการกดที่สองแยกต่างหาก และการกดสองครั้งใด ๆ ถูกขัดจังหวะได้ระหว่างกลาง เมื่อตัวนับต้องมีอายุ ให้รวมสองอย่างเข้าด้วยกัน: EXPIRE ... NX หลังทุกการเพิ่ม, MULTI หรือ script

ตัวเลือกข้อดีข้อเสีย
ใช้ INCR/INCRBY เป็นตัวนับใน Redisatomic โดยค่าเริ่มต้น ไม่ต้องล็อกฝั่งแอป รองรับ 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-timeRedis ไม่ใช่สมุดบัญชีของคุณ: durability ขึ้นกับ config persistence จึงควรเขียนกลับฐานข้อมูลเป็นระยะ
  • “INCR ต้องให้ key มีอยู่ก่อน” ไม่ต้อง; key ที่ไม่มีเริ่มจาก 0
  • “INCR แล้ว EXPIRE atomic เพราะทั้งสองเป็น atomic” สองคำสั่ง atomic คือสองรอบ client อื่น (หรือ crash) แทรกได้ระหว่างกลาง
  • “INCR รีเซ็ต TTL เหมือน SET” มันไม่แตะ TTL
  • “INCRBYFLOAT เป็นเลขฐานสิบที่แม่นยำสมบูรณ์” พิมพ์สวย (0.3) แต่ข้างใต้ยังเป็น binary float
  • อ่านค่าปัจจุบันด้วย 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 — บันทึกเมื่อสิบปีก่อน)

INCR ใช้ค่าอะไรเมื่อ key ยังไม่มี?
คำสั่งไหนเพิ่มค่าทศนิยม?
ทำไม INCR จึงปลอดภัยสำหรับ counter ที่ใช้พร้อมกัน?
จะลบ 5 ด้วย INCRBYFLOAT ได้อย่างไร?
SET big 9223372036854775807 แล้ว INCR big เกิดอะไรขึ้น?
rate limiter ทำ INCR key แล้ว if (n === 1) EXPIRE key 60 failure mode คืออะไร?