Session Store
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ สคริปต์ใช้ idle timeout 3 วินาทีเพื่อให้เห็นการหมดอายุ ใน production ใช้หลักนาที
ทำไมใช้ Redis เก็บ session?
หัวข้อที่มีชื่อว่า “ทำไมใช้ Redis เก็บ session?”application server ควร stateless ดังนั้น session ต้องอยู่ที่ที่ทุกเซิร์ฟเวอร์เข้าถึงได้ Redis เหมาะ: อ่านเขียนเร็ว key หมดอายุได้ในตัว และ hash แมปกับ object ของ session ที่มีชื่อ field ได้ตรงๆ เพราะ store ใช้ร่วมกัน การ logout บนเซิร์ฟเวอร์เดียวจึง logout ทุกเครื่อง
เก็บ session เป็น hash
หัวข้อที่มีชื่อว่า “เก็บ session เป็น hash”session แต่ละอันอยู่ที่ key session:<id> field อย่าง userId, email และ role คือ hash field EXPIRE บน key ตั้ง idle timeout การรีเฟรชมันทุกคำขอที่ผ่านการยืนยันตัวตนให้ sliding TTL
127.0.0.1:6379> HSET session:abc123 userId 42 email "[email protected]" role "admin"(integer) 3127.0.0.1:6379> EXPIRE session:abc123 1800(integer) 1127.0.0.1:6379> HGETALL session:abc1231) "userId"2) "42"3) "email"4) "[email protected]"5) "role"6) "admin"127.0.0.1:6379> HGET session:abc123 role"admin"127.0.0.1:6379> EXPIRE session:abc123 1800(integer) 1127.0.0.1:6379> TTL session:abc123(integer) 1800127.0.0.1:6379> HSET session:abc123 role "superadmin"(integer) 0127.0.0.1:6379> HGET session:abc123 role"superadmin"127.0.0.1:6379> HSET session:abc123 lastSeen 1700000000(integer) 1127.0.0.1:6379> HSET session:abc123 sudo 1(integer) 1127.0.0.1:6379> HEXPIRE session:abc123 60 FIELDS 1 sudo1) (integer) 1127.0.0.1:6379> HGETEX session:abc123 EX 300 FIELDS 1 role1) "superadmin"127.0.0.1:6379> HTTL session:abc123 FIELDS 3 sudo role userId1) (integer) 602) (integer) 3003) (integer) -1127.0.0.1:6379> RENAME session:abc123 session:new456OK127.0.0.1:6379> TTL session:new456(integer) 1800127.0.0.1:6379> EXISTS session:abc123(integer) 0127.0.0.1:6379> UNLINK session:new456(integer) 1127.0.0.1:6379> EXPIRE session:gone 1800(integer) 0วิธีอ่าน:
HSETกับคู่ field-value หลายคู่สร้าง session ในคำสั่งเดียว (HSETแบบ variadic มีมาตั้งแต่ Redis 4.0) มันคืนจำนวน field ใหม่; การอัปเดตroleคืน0EXPIREครั้งที่สองคือ sliding timeout: ทุกครั้งที่เรียกจะเริ่มนับถอยหลังใหม่ ได้1800เพราะเพิ่งตั้ง (TTLปัดเป็นวินาทีที่ใกล้ที่สุด)HSETไม่แตะ TTL ของ key การเขียนlastSeenไม่ ยืดอายุ session มีแต่EXPIREที่ยืด สคริปต์ด้านล่างพิสูจน์ด้วยนาฬิกา- expiry รายฟิลด์ (
HEXPIRE, Redis 7.4) ให้ field แต่ละตัวมีตัวจับเวลาของตัวเอง: flagsudoอยู่ 60 วินาที,roleได้ 300 วินาทีจากHGETEX ... EX(8.0 อ่านพร้อมตั้ง TTL ในคำสั่งเดียว) และuserIdไม่มี (-1) ใช้กับ flag อายุสั้นใน session ไม่ใช่กับ idle timeout:EXPIREระดับ key ยังเป็นตัวที่ลบทั้ง session RENAMEย้ายข้อมูลไปชื่อ key ใหม่และ คง TTL (1800) ซึ่งเป็นสิ่งที่การหมุน session id ต้องการEXPIREบน key ที่ไม่มีคืน0และไม่สร้างอะไร ซึ่งสคริปต์พึ่งพาอยู่
HSET session:abc123 userId 42 email "[email protected]" role "admin"
EXPIRE session:abc123 1800
HGETALL session:abc123
HGET session:abc123 role
EXPIRE session:abc123 1800
TTL session:abc123HSET session:abc123 role "superadmin"
HGET session:abc123 roleHash เทียบกับ JSON ที่ serialize
หัวข้อที่มีชื่อว่า “Hash เทียบกับ JSON ที่ serialize”สตริง JSON ก้อนเดียวบังคับให้อ่านและเขียนทั้งก้อนเพื่อเปลี่ยน field เดียว และคำขอสองตัวที่อัปเดตคนละ field อาจเขียนทับกัน (ตัวหลังชนะทั้งสตริง) กับ hash HGET ดึง field เดียว HSET เขียนหนึ่ง field และการอัปเดตคนละ field พร้อมกันไม่ชนกัน ราคาที่จ่ายคือ field เป็นสตริงแบบแบน โครงสร้างซ้อนต้อง serialize ลงใน field
session store ใน ioredis
หัวข้อที่มีชื่อว่า “session store ใน ioredis”// @runimport { randomBytes } from 'node:crypto';import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const IDLE = 3; // seconds; production: 1800 (30 min)const key = (sid: string) => `b3:session:${sid}`;
// 128-bit random id from the OS CSPRNG (OWASP asks for >= 64 bits of entropy)const newId = () => randomBytes(16).toString('base64url');
// HSET + EXPIRE inside MULTI: both or neither reach the server, never a session without a TTLasync function create(data: Record<string, string>) { const sid = newId(); await redis.multi().hset(key(sid), data).expire(key(sid), IDLE).exec(); return sid;}
// Read and slide the idle timeout in one round trip. EXPIRE on a missing key does nothing.async function touch(sid: string) { const res = await redis.multi().hgetall(key(sid)).expire(key(sid), IDLE).exec(); const [, data] = res![0]! as [Error | null, Record<string, string>]; return Object.keys(data).length ? data : null;}
// Login must issue a NEW id (session fixation): RENAME keeps the data and the TTLasync function rotate(sid: string) { const next = newId(); await redis.rename(key(sid), key(next)); return next;}
const logout = (sid: string) => redis.unlink(key(sid));
let sid = await create({ userId: '42', role: 'admin' });console.log('id length', sid.length, 'chars, ttl', await redis.ttl(key(sid)));
await sleep(1500);console.log('after 1.5 s idle, ttl', await redis.ttl(key(sid)), '-> touch:', await touch(sid));console.log('touch slid the timeout back to', await redis.ttl(key(sid)));
// HSET does NOT slide the TTL; only EXPIRE doesawait sleep(1500);await redis.hset(key(sid), 'lastSeen', String(Date.now()));console.log('after HSET lastSeen, ttl is still counting down:', await redis.ttl(key(sid)));
const before = await redis.ttl(key(sid));sid = await rotate(sid);console.log('rotate: ttl before', before, 'after', await redis.ttl(key(sid)), '| data kept:', (await redis.hgetall(key(sid))).role);
await sleep(3200);console.log('3.2 s of no requests -> session gone:', (await touch(sid)) === null);
// logout: delete now, do not wait for the TTLsid = await create({ userId: '7' });await logout(sid);console.log('after logout:', (await touch(sid)) === null);
// Redis 8 HGETEX: expire individual FIELDS (here a short-lived "sudo" flag), not the whole keysid = await create({ userId: '42', sudo: '1' });await redis.call('HEXPIRE', key(sid), 1, 'FIELDS', 1, 'sudo');await sleep(1200);console.log('sudo field after 1.2 s:', await redis.hget(key(sid), 'sudo'), '| userId still there:', await redis.hget(key(sid), 'userId'));
await redis.unlink(key(sid));await redis.quit();ผลลัพธ์จริง (Mac เครื่องนี้):
id length 22 chars, ttl 3after 1.5 s idle, ttl 1 -> touch: { userId: '42', role: 'admin' }touch slid the timeout back to 3after HSET lastSeen, ttl is still counting down: 1rotate: ttl before 1 after 1 | data kept: admin3.2 s of no requests -> session gone: trueafter logout: truesudo field after 1.2 s: null | userId still there: 42สิ่งที่พิสูจน์ได้:
- สร้างแบบ atomic
multi().hset().expire().exec()ส่งสองคำสั่งเป็นหนึ่ง transaction ดังนั้น crash ระหว่างสองคำสั่งไม่ทิ้ง session ที่ไม่มีวันหมดอายุ (ต่างจาก await แยกสองตัวที่ทำได้) id คือ 16 ไบต์จาก CSPRNG ของ OS = 128 บิต, 22 ตัวอักษร base64url OWASP กำหนดอย่างน้อย 64 บิตของ entropy จากตัวสร้างที่ปลอดภัยเชิงเข้ารหัส (Session Management Cheat Sheet) อย่าสร้าง id จาก user id, timestamp หรือMath.random() - อ่านและเลื่อนเวลาในรอบเดียว
touchรันHGETALLกับEXPIREด้วยกัน: TTL กลับจาก 1 เป็น 3 หลังอ่าน session ที่ไม่มีคืน hash ว่าง (สคริปต์แมปเป็นnull) และEXPIREบน key ที่ไม่มีไม่สร้างอะไร HSETไม่เลื่อนเวลา หลังผ่านไปอีก 1.5 วินาที การเขียนlastSeenไม่แตะการนับถอยหลัง (ยังเป็น 1) การเขียน “อัปเดตกิจกรรม” ทุกครั้งต้องจับคู่กับEXPIRE- หมุน id เมื่อสิทธิ์เปลี่ยน ออก id ใหม่ตอน login (ไม่เช่นนั้นผู้โจมตีที่ฝัง id ไว้ก่อน login ยังเข้าถึงได้: session fixation)
RENAMEคงทั้งข้อมูลและ TTL (ก่อน 1 หลัง 1) และ id เก่าหายไป - idle timeout ทำงาน 3.2 วินาทีที่ไม่มีคำขอ session ก็หายไป ไม่ต้องมี job ทำความสะอาด
- Logout ลบทันที
UNLINK(หรือDEL) ลบ session ทันที การพึ่ง TTL อย่างเดียวทำให้ cookie ที่ถูกขโมยใช้ได้จนกว่าจะหมดเวลา - expiry ระดับ field field
sudoหายไปหลังตัวจับเวลา 1 วินาที ขณะที่userIdยังอยู่
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน middleware “อัปเดตกิจกรรมล่าสุด” และบอกว่ามัน “ทำให้ session ยังมีชีวิต”:
🐛 หา bug ในโค้ดนี้
app.use(async (req, res, next) => {const key = `session:${req.cookies.sid}`;const session = await redis.hgetall(key);if (!session.userId) return res.status(401).end();await redis.hset(key, 'lastSeen', String(Date.now())); // "keeps the session alive"req.session = session;next();});เฉลย
HSET ไม่เปลี่ยน TTL ของ key ในการรันด้านบน การเขียน lastSeen ปล่อยให้ ttl นับถอยหลังต่อ ผู้ใช้ที่ใช้งานอยู่จึงถูก logout เมื่อ TTL ตอน login เดิมหมด ไม่ว่าจะขยันแค่ไหน เลื่อนเวลาด้วย EXPIRE และทำการอ่าน การเขียน และการรีเฟรชใน MULTI เดียว เพื่อไม่ให้แยกจากกันได้:
const [[, session]] = (await redis .multi() .hgetall(key) .hset(key, 'lastSeen', String(Date.now())) .expire(key, 1800) .exec())!;Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”session คือ ตั๋วฝากเสื้อที่มีตราวันหมดอายุ hash คือเสื้อ id ใน cookie คือตั๋ว และ EXPIRE คือตรา การจับเสื้อ (เปลี่ยน field) ไม่ต่ออายุตรา มีแต่การประทับตราใหม่ การเปลี่ยนเลขตั๋วเมื่อตัวตนของผู้เยี่ยมชมเปลี่ยน (RENAME) ทำให้ตั๋วเก่าที่ถูกคัดลอกใช้ไม่ได้ และการคืนเสื้อ (UNLINK) จบมันทันที
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| hash หนึ่งอันต่อ session | อ่าน/อัปเดตทีละ field โดยไม่ต้อง deserialize ทั้งหมด; อัปเดตคนละ field พร้อมกันไม่ชนกัน | field แบนเท่านั้น; คุณเลือกชื่อ field เอง Redis ไม่บังคับ schema |
| สตริง JSON เดียว | เรียบง่าย เก็บโครงสร้างซ้อนได้ | read-modify-write ทั้งก้อน; อัปเดตพร้อมกันเขียนทับกัน |
Sliding TTL (EXPIRE ต่อคำขอ) | session ที่ใช้งานอยู่ไม่หมดอายุกลางคัน | เพิ่มหนึ่งคำสั่งต่อคำขอ (ฟรีถ้าอยู่ใน MULTI เดียวกับการอ่าน) |
| มีอายุสูงสุดแบบ absolute ด้วย | จำกัด session ที่ถูกขโมย | ต้องเก็บ createdAt และตรวจ; ผู้ใช้ถูก logout แม้กำลังใช้งาน |
HEXPIRE/HGETEX รายฟิลด์ | flag อายุสั้นใน session ที่อายุยาว | ใช้ได้เฉพาะ Redis 7.4/8.0 (ไม่มีบนเซิร์ฟเวอร์เก่าหรือ fork ส่วนใหญ่) และไม่แทน TTL ของ key |
| session อยู่ใน Redis อย่างเดียว | เร็ว หมดอายุเอง | ข้อมูลหายถ้า Redis เสียมัน; ผู้ใช้ต้อง login ใหม่ (ดู persistence) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “เขียนลง session แล้วมันรีเฟรชเอง” มีแต่
EXPIRE(หรือSETเต็มที่พกEXใหม่) ที่เปลี่ยน TTL;HSETไม่เปลี่ยน - “
HSETแล้วEXPIREเป็น await สองตัวก็ไม่เป็นไร” crash ระหว่างสองตัวทิ้ง session ที่อยู่ตลอดกาล ใส่ทั้งคู่ในMULTI - “หมดอายุก็พอสำหรับ logout” TTL เป็นทางสำรอง logout ที่ชัดเจนต้องลบ key
- “TTL รายฟิลด์แทน TTL ของ session” มันแค่ทำให้บาง field หายเร็วขึ้น key ยังต้องมี
EXPIREของตัวเอง
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ไม่รีเฟรชอะไรเมื่อมีกิจกรรม — ผู้ใช้ที่ใช้งานอยู่ถูก logout ตอน TTL เดิมหมด
- รีเฟรชด้วย
HSETแทนEXPIRE— พิสูจน์ข้างบนแล้วว่าไม่เลื่อนเวลา - ไม่
DEL/UNLINKตอน logout — cookie ที่ถูกขโมยยังใช้ได้จน TTL หมด - ใช้ id เดิมหลัง login — ออก id ใหม่ (session fixation)
- id ที่เดาได้ — ใช้บิตสุ่มอย่างน้อย 128 บิตจาก CSPRNG
- เก็บรหัสผ่านหรือ token ใน hash — session เก็บเท่าที่การให้สิทธิ์ต้องใช้ ไม่เกินนั้น
Real-world example
connect-redis ซึ่งเป็น Redis store ของ
express-sessionใน Express ไม่ได้ใช้ hash: source ของมัน serialize แต่ละ session ด้วยJSON.stringifyแล้วเขียนด้วยSET key value EX ttlและtouch()เลื่อน idle timeout ด้วยการเรียกEXPIREนั่นคือดีไซน์แบบสตริง JSON ในตารางด้านบน กับกฎ “EXPIREเลื่อนเวลา” เดียวกับที่บทนี้พิสูจน์กับ hash (บทเวอร์ชันก่อนอ้างว่ามันใช้ hash แต่ source บอกว่าไม่ใช่)