ในฐานะวิศวกรสาย JVM/Spring Boot เรามักคุ้นเคยกับ @Transactional บน RDBMS ซึ่งอาศัย Row-Level Locks และ Transaction Isolation Levels (Read Committed / Serializable) แต่ในโลก In-Memory ของ Redis ที่ขับเคลื่อนด้วย Single-Threaded Event Loop กลไก Transaction ถูกออกแบบด้วยวิธีที่แตกต่างกันอย่างสิ้นเชิง
คำสั่ง MULTI ใน Redis ไม่ใช่การเปิด Lock บนข้อมูล แต่เป็นเพียงการ "สั่งให้อัดคำสั่งลง Queue ฝั่ง Client/Connection Buffer" และคำสั่งทั้งหมดจะถูกยิงเข้าไปรันรวดเดียวเมื่อเรียก EXEC:
WATCH จะล้มเหลวเกือบทั้งหมด (High Contention Abort) บังคับให้ JVM App ต้องทำ Exponential Backoff Retry ส่งผลให้เกิด Network Round-Trip และ Thread Exhaustion ใน Spring Boot ทันที
เพื่อแก้ปัญหา Round-Trip และ Concurrency Retry ทางออกระดับสถาปัตยกรรมธนาคารคือการย้าย Business Logic ไปรัน Atomic Server-Side ภายใน Event Loop ของ Redis ผ่านภาษา Lua:
-- atomic_withdraw.lua
-- KEYS[1]: ledger:balance:acc_id
-- ARGV[1]: withdraw_amount
-- ARGV[2]: txn_id
local current_bal = redis.call('GET', KEYS[1])
if not current_bal then
return redis.error_reply("ACCOUNT_NOT_FOUND")
end
local bal_num = tonumber(current_bal)
local amount = tonumber(ARGV[1])
if bal_num < amount then
return -1 -- INSUFFICIENT_FUNDS
end
-- Deduct balance and record transaction log atomically
redis.call('DECRBY', KEYS[1], amount)
redis.call('LPUSH', 'ledger:tx:' .. KEYS[1], ARGV[2])
return bal_num - amount
ในระบบการเงิน ความพร้อมใช้งาน (High Availability) และการป้องกันข้อมูลสูญหาย (Zero-Loss Tolerance) เป็นหัวใจสำคัญ การเข้าใจวงจร Failover จึงเป็นสิ่งจำเป็น
Replication Offset และ Master RunID มาขอทำ Partial Resynchronization (PSYNC) ซึ่ง Master จะส่งเฉพาะ Deltas ที่ตกค้างใน Backlog Buffer กลับไปให้ โดยไม่ต้อง Fork SnapshotBGSAVE สร้าง Full RDB Dump ส่งผ่าน Network ไปให้ Replica ทั้งก้อน ซึ่งก่อให้เกิด Disk I/O, Fork Latency, และ Network Spikes รุนแรงSentinel ทำหน้าที่เป็น Distributed Coordinator (แนะนำให้รัน 3 Nodes ขึ้นไปเพื่อให้เกิด Quorum):
down-after-millisecondsmin-replicas-to-write 1
min-replicas-max-lag 10
หาก Master ไม่ได้รับการยืนยัน ACK จาก Replica อย่างน้อย 1 โหนดภายใน 10 วินาที Master จะปฏิเสธการ Write ทันที ป้องกันไม่ให้เกิด Split-Brain และ Data Divergence
เมื่อปริมาณ Write Throughput หรือขนาด Memory ใน RAM พุ่งเกินขีดจำกัดของเซิร์ฟเวอร์เครื่องเดียว Redis จะเปลี่ยนผ่านสู่ Multi-Master Distributed Sharding ผ่านสถาปัตยกรรม Redis Cluster
ทำไม Salvatore Sanfilippo ถึงเลือกตัวเลข 214 = 16,384 แทนที่จะเป็น 65,536 (216)?:
ใน Redis Cluster คำสั่งที่ต้องแตะหลาย Keys พร้อมกัน (เช่น MGET, Transaction MULTI/EXEC, หรือ EVAL Lua) จะ ล้มเหลวทันทีด้วย Error: CROSSSLOT Keys in request don't hash to the same slot หาก Keys เหล่านั้นตกอยู่คนละเครื่อง
{...} ลงในชื่อ Key Redis จะนำเฉพาะตัวอักษรข้างในปีกกามาคำนวณ CRC16 เท่านั้น!
-- ทั้งสอง Keys จะได้ค่า CRC16 จากคำว่า "acc_9871" เหมือนกัน 100%
-- ทำให้วิ่งไปตกบน Node เดียวกันเสมอ รองรับ Multi-key Atomic Lua ได้อย่างราบรื่น
KEYS[1] = "ledger:{acc_9871}:balance"
KEYS[2] = "ledger:{acc_9871}:audit_log"
เกิดจากข้อมูลเพียงชิ้นเดียวได้รับความนิยมสูงลิ่ว เช่น ข้อมูลสต็อกสินค้า Flash Sale หรืออัตราแลกเปลี่ยนเงินตราประจำวัน ส่งผลให้ CPU Core ของ Redis Node นั้นทำงานแตะ 100% ขณะที่โหนดอื่นว่างงาน
2 กลยุทธ์หลักในการทลาย Hotkey:
val shardIndex = ThreadLocalRandom.current().nextInt(10) // 0 to 9
val shardKey = "flash_sale:stock_item_99:$shardIndex"
redisTemplate.opsForValue().increment(shardKey, -1)
// เวลาอ่านยอดรวม: ใช้ MGET ดึงทั้ง 10 Shards มารวมผลบวกที่ JVM App
Big Key คือ Key ที่มีขนาดข้อมูลใหญ่เกินมาตรฐาน (เช่น String ขนาด > 5MB หรือ Hash/List/Set ที่มีสมาชิกเกิน 10,000 รายการ) ซึ่งทำให้เกิด:
เครื่องมือตรวจสอบใน Production (CLI Diagnostics):
# 1. สแกนหา Big Keys ทั่วระบบโดยไม่บล็อก Event Loop (ใช้ SCAN ภายใน)
redis-cli -h 10.0.0.1 -p 6379 --bigkeys
# 2. สแกนหา Hotkeys แบบ Real-time (ต้องเปิด maxmemory-policy LFU)
redis-cli -h 10.0.0.1 -p 6379 --hotkeys
# 3. ดูประวัติคำสั่งที่กินเวลา CPU เกินเกณฑ์ (Default: 10,000 microseconds)
SLOWLOG GET 20
DEL big_key บน Production เด็ดขาด! ให้ใช้ UNLINK big_key เสมอ เพื่อให้ Redis ปลด Key ออกจาก Keyspace ทันที แล้วส่ง Memory ไป Deallocate บน Background BIO Threads โดยไม่กระทบ Latency ของระบบ
| Policy Mode | พฤติกรรมเมื่อ RAM ชนเพดาน maxmemory | Use Case ที่เหมาะสม |
|---|---|---|
noeviction |
ปฏิเสธทุกคำสั่ง Write พร้อม Return Error OOM command not allowed |
Financial Ledger, Transaction State ที่ข้อมูลห้ามหายเด็ดขาด |
allkeys-lru |
เตะ Key ที่ไม่ได้ใช้งานนานที่สุด (Least Recently Used) ออกจากทุก Key | Web App Cache ทั่วไปที่มีการเข้าถึงข้อมูลแบบ Power-law |
volatile-lru |
เตะ Key เก่าสุดเฉพาะกลุ่มที่มีการตั้งค่า Expire (TTL) ไว้เท่านั้น | เมื่อต้องการแชร์ Redis ก้อนเดียวกันระหว่าง Static Config กับ Dynamic Cache |
allkeys-lfu |
เตะ Key ที่มีความถี่ในการเรียกใช้น้อยที่สุด (Least Frequently Used) | ป้องกันปัญหา One-hit Wonder (Traffic กวาดรอบเดียวแล้วไม่มาอีก) |
สถาปัตยกรรมป้องกันการจ่ายเงินซ้ำซ้อน (Idempotent Payment Ingestion) และการหักบัญชีความเร็วสูงระดับ Core Banking โดยผสาน Redis และ PostgreSQL อย่างสมบูรณ์แบบ:
@Service
class BankingPaymentService(
private val redisTemplate: StringRedisTemplate,
private val ledgerRepository: PostgresLedgerRepository
) {
// โหลด Compiled Lua Script ไว้ล่วงหน้า (ใช้ EVALSHA อัตโนมัติ)
private val paymentScript = DefaultRedisScript(
"""
local set_idemp = redis.call('SET', KEYS[1], 'PROCESSING', 'EX', ARGV[2], 'NX')
if not set_idemp then
return -1 -- ALREADY_PROCESSED_OR_IN_FLIGHT
end
local bal = redis.call('GET', KEYS[2])
if not bal then
return -2 -- ACCOUNT_CACHE_MISS
end
local current_bal = tonumber(bal)
local deduct = tonumber(ARGV[1])
if current_bal < deduct then
return -3 -- INSUFFICIENT_FUNDS
end
redis.call('DECRBY', KEYS[2], deduct)
return current_bal - deduct
""".trimIndent(), Long::class.java
)
@Transactional
fun processPayment(requestId: String, accountId: String, amount: Long): PaymentResult {
val keys = listOf("idemp:{$accountId}:$requestId", "balance:{$accountId}")
val result = redisTemplate.execute(paymentScript, keys, amount.toString(), "300")
return when (result) {
-1L -> throw DuplicateRequestException("Duplicate transaction: $requestId")
-3L -> throw InsufficientBalanceException("Balance insufficient in account $accountId")
-2L -> {
// Cache Miss: โหลดจาก PostgreSQL Buffer Pool แล้ว Hydrate กลับเข้า Redis
hydrateCacheAndRetry(accountId, requestId, amount)
}
else -> {
// Success In-Memory: ยิง Event เข้า Kafka Transactional Outbox ส่งต่อให้ RDBMS Persistence
persistLedgerAsync(accountId, amount, requestId)
PaymentResult.Success(remainingBalance = result)
}
}
}
}
{$accountId} บังคับให้ Idempotency Key และ Balance Key ตกอยู่บน Cluster Hash Slot เดียวกัน ทำให้รัน Atomic Lua Script ข้าม Keys ได้อย่างถูกต้องSET NX EX ผสานกับ Balance Verification ในเสี้ยวไมโครวินาที ป้องกัน Race Condition และ Duplicate Payment ในระดับ Hardware Single Threadชุดข้อสอบประเมินสถาปัตยกรรมระดับลึกสำหรับ Phase 3 เพื่อทดสอบความเข้าใจเรื่อง Concurrency, Failover, Distributed Sharding, Hotkey/Big Key และ Production Tuning พร้อมระบบตรวจคำตอบทันที