Lua Scripting
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ (ส่วนต่อท้ายscript: <sha>, on @user_script:1.ใน error ของสคริปต์เป็น output จริงของเซิร์ฟเวอร์) ผลของSCRIPT KILLและ restart มาจากคอนเทนเนอร์ชั่วคราวของผมเอง harness ไม่ได้รันซ้ำ ตัวแปลภาษา Lua คือ Lua 5.1
ทำไมต้อง Lua scripting?
หัวข้อที่มีชื่อว่า “ทำไมต้อง Lua scripting?”transaction จัดกลุ่มคำสั่งแต่แตกกิ่งตามผลระหว่างทางไม่ได้ ถ้าต้องอ่านค่า ตัดสินใจ และเขียน โดยไม่มี client อื่นแทรก ต้องใช้สคริปต์ สคริปต์รันทั้งหมดบนเซิร์ฟเวอร์: ไม่มี round trip ระหว่างขั้น และไม่มีคำสั่งอื่นรันขณะมันทำงาน (Redis Lua API)
EVAL, redis.call และ redis.pcall
หัวข้อที่มีชื่อว่า “EVAL, redis.call และ redis.pcall”EVAL script numkeys [key ...] [arg ...]: key มาใน KEYS[1..] argument อื่นมาใน ARGV[1..] ส่ง key ผ่าน KEYS เสมอ (เซิร์ฟเวอร์และ Redis Cluster ต้องรู้ว่าสคริปต์แตะ key ไหน)
127.0.0.1:6379> SCRIPT FLUSHOK127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "hello"OK127.0.0.1:6379> GET mykey"hello"127.0.0.1:6379> EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 k a b1) "k"2) "a"3) "b"127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 counter(integer) 1127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 mykey(error) ERR value is not an integer or out of range script: 61636018f4e6b5817b89791bbed242f93fa089e3, on @user_script:1.127.0.0.1:6379> EVAL "return redis.pcall('INCR', KEYS[1])" 1 mykey(error) ERR value is not an integer or out of range127.0.0.1:6379> EVAL "local r = redis.pcall('INCR', KEYS[1]) if type(r) == 'table' and r.err then return 'caught: ' .. r.err end return r" 1 mykey"caught: ERR value is not an integer or out of range"127.0.0.1:6379> EVAL "redis.call('SET', KEYS[1], 'a') return redis.call('INCR', KEYS[1])" 1 z1(error) ERR value is not an integer or out of range script: fcf4f339936374b211090ac0dcb064e100c050a9, on @user_script:1.127.0.0.1:6379> GET z1"a"127.0.0.1:6379> EVAL "return 3.7" 0(integer) 3127.0.0.1:6379> EVAL "return false" 0(nil)127.0.0.1:6379> EVAL "return true" 0(integer) 1127.0.0.1:6379> EVAL "return {1, 2, {3, 'x'}}" 01) (integer) 12) (integer) 23) 1) (integer) 3 2) "x"127.0.0.1:6379> EVAL_RO "return redis.call('GET', KEYS[1])" 1 mykey"hello"127.0.0.1:6379> EVAL_RO "return redis.call('SET', KEYS[1], 'x')" 1 mykey(error) ERR Write commands are not allowed from read-only scripts. script: bd6f3018b28bbdb36260f19d3c44afed740db987, on @user_script:1.session นี้แสดงอะไร:
redis.callraise ส่วนredis.pcallคืนค่า เมื่อคำสั่งล้มเหลวredis.callยกเลิกสคริปต์และ client ได้ error (พร้อม SHA ของสคริปต์และบรรทัดต่อท้าย)redis.pcallส่ง error กลับมาเป็น table{err = ...}ที่คุณตรวจและจัดการได้ ดังบรรทัด “caught:”- ในสคริปต์ก็ไม่มี rollback
z1ถูกตั้งเป็นaก่อนINCRที่ล้มเหลว สคริปต์ยกเลิกด้วย error แต่GET z1ยังเป็น"a"Atomic แปลว่า “ไม่มีอะไรแทรก” ไม่ใช่ “ทั้งหมดหรือไม่เลย” - ค่าที่คืนถูกแปลงและข้อมูลบางส่วนหาย ตัวเลข Lua ถูกตัดเป็นจำนวนเต็ม (
3.7กลายเป็น3)falseกลายเป็น niltrueกลายเป็น1และ table กลายเป็นอาร์เรย์ ถ้าต้องการทศนิยมให้คืนเป็นสตริง EVAL_ROรันสคริปต์ที่ห้ามเขียน:GETทำงาน และSETถูกปฏิเสธ ใช้มัน (หรือFCALL_RO) กับ logic แบบอ่านอย่างเดียวเพื่อให้รันบน replica ได้
สคริปต์แบบมีเงื่อนไข: rate limiter
หัวข้อที่มีชื่อว่า “สคริปต์แบบมีเงื่อนไข: rate limiter”การแตกกิ่งตามผลของ INCR คือสิ่งที่ transaction ทำไม่ได้พอดี:
-- lua/tsl-lua-ratelimit.lua-- rate limiter: increment; set the window on the first hit; return 1 if under the limit, else 0local current = redis.call('INCR', KEYS[1])if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1])endif current <= tonumber(ARGV[2]) then return 1else return 0endเพราะสคริปต์เป็น atomic INCR และ EXPIRE จึงถูก crash หรือ client อื่นแยกออกจากกันไม่ได้ (เทียบกับเวอร์ชันสองคำสั่งใน Rate Limiting)
EVAL "return redis.call('INCR', KEYS[1])" 1 counter
EVAL "return redis.call('INCR', KEYS[1])" 1 counter
GET counterscript cache: SCRIPT LOAD, EVALSHA และ NOSCRIPT
หัวข้อที่มีชื่อว่า “script cache: SCRIPT LOAD, EVALSHA และ NOSCRIPT”การส่งซอร์สทุกครั้งที่เรียกเปลืองแบนด์วิดท์ SCRIPT LOAD คอมไพล์สคริปต์และคืน SHA1 ของมัน EVALSHA sha numkeys ... รันด้วยแฮช SHA1 ของข้อความสคริปต์ที่ตรงกันเป๊ะเหมือนกันทุกที่ ดังนั้นแฮชในบทนี้ (61636018...) คือสิ่งที่ SCRIPT LOAD "return redis.call('INCR', KEYS[1])" คืนบนเซิร์ฟเวอร์ใดก็ได้
127.0.0.1:6379> SCRIPT FLUSHOK127.0.0.1:6379> SCRIPT LOAD "return redis.call('INCR', KEYS[1])""61636018f4e6b5817b89791bbed242f93fa089e3"127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter(integer) 2127.0.0.1:6379> SCRIPT EXISTS 61636018f4e6b5817b89791bbed242f93fa089e3 00000000000000000000000000000000000000001) (integer) 12) (integer) 0127.0.0.1:6379> SCRIPT FLUSHOK127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter(error) NOSCRIPT No matching script. Please use EVAL.(counter เป็น 1 อยู่แล้วจาก session แรก EVALSHA จึงคืน 2)
cache อยู่ที่ไหน มันคือ หน่วยความจำฝั่งเซิร์ฟเวอร์ที่ทุก client connection ใช้ร่วมกัน ไม่ใช่สิ่งที่ client หรือ connection pool จำไว้ สคริปต์ด้านล่างพิสูจน์ (connection A โหลด connection B เรียกด้วย SHA) SCRIPT FLUSH (หรือการ restart เซิร์ฟเวอร์) ล้างมันสำหรับทุกคน ผมยัง restart คอนเทนเนอร์เพื่อตรวจว่าอะไรรอด:
$ redis-cli SCRIPT LOAD "return 1" # then FUNCTION LOAD ..., SET persist:me 1$ docker restart redis-b3-db$ redis-cli SCRIPT EXISTS e0e1f9fabfc9d4800c877a703b823ac0578ff8db0 # the script cache is gone$ redis-cli FUNCTION LIST # library "keep" is still there$ redis-cli GET persist:me1script cache ไม่รอดการ restart ส่วน function library ที่โหลดไว้และข้อมูลรอด นั่นคือความต่างเชิงปฏิบัติระหว่างสคริปต์ EVAL/SCRIPT LOAD กับ Functions
ใช้สคริปต์จาก ioredis
หัวข้อที่มีชื่อว่า “ใช้สคริปต์จาก ioredis”// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const a = new Redis(url);const b = new Redis(url); // a second, independent connectionconst cached = async () => Number(/number_of_cached_scripts:(\d+)/.exec(await a.info('memory'))![1]);
declare module 'ioredis' { interface RedisCommander<Context> { incrCapped(key: string, cap: number): Promise<number>; }}
await a.script('FLUSH');console.log('scripts cached at start:', await cached());
// 1) the script cache lives on the SERVER: loaded on connection A, called by SHA on connection Bconst src = `return redis.call('INCR', KEYS[1])`;const sha = (await a.script('LOAD', src)) as string;console.log('A loaded', sha, '| B calls it by SHA ->', await b.evalsha(sha, 1, 'b3:lua:n'));
// 2) SCRIPT FLUSH empties it for everyone: EVALSHA now fails with NOSCRIPTawait a.script('FLUSH');try { await b.evalsha(sha, 1, 'b3:lua:n');} catch (e) { console.log('after SCRIPT FLUSH, B ->', (e as Error).message);}
// 3) defineCommand hides that: ioredis tries EVALSHA and falls back to EVAL on NOSCRIPTa.defineCommand('incrCapped', { numberOfKeys: 1, lua: `local c = redis.call('INCR', KEYS[1]) if c > tonumber(ARGV[1]) then redis.call('SET', KEYS[1], ARGV[1]) return tonumber(ARGV[1]) end return c`,});await a.del('b3:lua:cap');const r1 = [await a.incrCapped('b3:lua:cap', 2), await a.incrCapped('b3:lua:cap', 2), await a.incrCapped('b3:lua:cap', 2)];await a.script('FLUSH'); // cache wiped behind the client's back...console.log('incrCapped x3 ->', r1.join(','), '| after a SCRIPT FLUSH the next call still works ->', await a.incrCapped('b3:lua:cap', 2));
// 4) the bug: a NEW script text per user (the id is pasted into the source) is a new cache entry each timeconst evicted = async () => Number(/evicted_scripts:(\d+)/.exec(await a.info('stats'))![1]);await a.script('FLUSH');const e0 = await evicted();let t = performance.now();for (let i = 0; i < 2000; i++) await a.eval(`return redis.call('INCR', 'rate:user:${i}')`, 0);console.log(`2000 scripts with the user id in the text : ${(performance.now() - t).toFixed(0)} ms | cached: ${await cached()} | evicted: ${(await evicted()) - e0}`);await a.script('FLUSH');const e1 = await evicted();t = performance.now();for (let i = 0; i < 2000; i++) await a.eval(`return redis.call('INCR', KEYS[1])`, 1, `rate:user:${i}`);console.log(`2000 calls of ONE script, key in KEYS : ${(performance.now() - t).toFixed(0)} ms | cached: ${await cached()} | evicted: ${(await evicted()) - e1}`);
// ...and it is a code-injection hole when the id comes from a requestconst evil = "x') and redis.call('SET','b3:lua:pwned','1') or redis.call('INCR','y";await a.eval(`return redis.call('INCR', 'rate:user:${evil}')`, 0);console.log('injected through the user id -> b3:lua:pwned =', await a.get('b3:lua:pwned'));await a.del('b3:lua:pwned');await a.eval(`return redis.call('INCR', KEYS[1])`, 1, `rate:user:${evil}`); // same input, passed as dataconsole.log('same input passed in KEYS[1] -> b3:lua:pwned =', await a.get('b3:lua:pwned'));
// 5) Functions (Redis 7+): named, persisted, replicated; called with FCALLawait a.call('FUNCTION', 'LOAD', 'REPLACE', `#!lua name=b3libredis.register_function('b3_hello', function(keys, args) return 'hello ' .. args[1] end)`);console.log('FCALL ->', await a.fcall('b3_hello', 0, 'ioredis'));await a.call('FUNCTION', 'DELETE', 'b3lib');
const del: string[] = ['b3:lua:n', 'b3:lua:cap', 'b3:lua:pwned', 'rate:user:x', 'y', `rate:user:${evil}`];for (let i = 0; i < 2000; i++) del.push(`rate:user:${i}`);await a.unlink(del);await a.script('FLUSH');a.disconnect(); b.disconnect();ผลลัพธ์จริง (Mac เครื่องนี้):
scripts cached at start: 0A loaded 61636018f4e6b5817b89791bbed242f93fa089e3 | B calls it by SHA -> 1after SCRIPT FLUSH, B -> NOSCRIPT No matching script. Please use EVAL.incrCapped x3 -> 1,2,2 | after a SCRIPT FLUSH the next call still works -> 22000 scripts with the user id in the text : 603 ms | cached: 500 | evicted: 15002000 calls of ONE script, key in KEYS : 642 ms | cached: 1 | evicted: 0injected through the user id -> b3:lua:pwned = 1same input passed in KEYS[1] -> b3:lua:pwned = nullFCALL -> hello ioredis- cache ร่วมกัน: B รันสคริปต์ด้วย SHA ที่มีเพียง A โหลด
NOSCRIPTปรากฏทันทีที่ cache ถูกล้างevalshaที่เขียนเองต้องจับมันแล้วถอยไปใช้EVALdefineCommandทำให้เอง: ioredis ส่งEVALSHAและถอยไปEVALเมื่อได้NOSCRIPTการเรียกหลังSCRIPT FLUSHจึงทำงานได้เลย- ข้อความสคริปต์คือ cache key สคริปต์ 2000 ตัวที่ต่างกันแค่ user id ที่ยัดลงในซอร์สทำให้ cache เต็มถึงขีดจำกัด 500 และไล่ออก 1500 ตัว (สคริปต์ที่รันด้วย
EVALถูกไล่ออกแบบ LRU ที่ 500 ซอร์สของเซิร์ฟเวอร์มีคอมเมนต์ “Users who abuse EVAL will generate a new lua script on each call, which can consume large amounts of memory over time”, eval.c; สคริปต์จากSCRIPT LOADไม่ถูกไล่ออก) สคริปต์เดียวที่เรียก 2000 ครั้งโดยใส่ key ในKEYSคงอยู่ที่ 1 สคริปต์ใน cache (เวลาใกล้เคียงกันที่นี่เพราะสคริปต์เล็กมาก ต้นทุนคือ churn และความเสี่ยง injection ด้านล่าง) - Functions (
FUNCTION LOAD,FCALL) คือทางเลือกที่มีชื่อและถูกเก็บถาวร ต่อไป
Functions (Redis 7.0+)
หัวข้อที่มีชื่อว่า “Functions (Redis 7.0+)”function library คือกลุ่มฟังก์ชัน Lua ที่มีชื่อ ลงทะเบียนด้วย redis.register_function ต่างจากสคริปต์ EVAL ตรงที่มันถูกเก็บในชุดข้อมูล: รอดการ restart (ดังที่แสดงข้างบน) รวมอยู่ใน RDB/AOF ถูก replicate และเรียกด้วยชื่อผ่าน FCALL name numkeys ...
127.0.0.1:6379> FUNCTION LOAD REPLACE "#!lua name=tsl_lib\nredis.register_function('tsl_hello', function(keys, args) return 'hello ' .. args[1] end)\nredis.register_function{function_name='tsl_incr_cap', callback=function(keys, args) local c = redis.call('INCR', keys[1]) if c > tonumber(args[1]) then redis.call('SET', keys[1], args[1]) return tonumber(args[1]) end return c end}""tsl_lib"127.0.0.1:6379> FCALL tsl_hello 0 world"hello world"127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2(integer) 1127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2(integer) 2127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2(integer) 2127.0.0.1:6379> FUNCTION LIST1) 1) "library_name" 2) "tsl_lib" 3) "engine" 4) "LUA" 5) "functions" 6) 1) 1) "name" 2) "tsl_hello" 3) "description" 4) (nil) 5) "flags" 6) (empty array) 2) 1) "name" 2) "tsl_incr_cap" 3) "description" 4) (nil) 5) "flags" 6) (empty array)127.0.0.1:6379> FUNCTION DELETE tsl_libOKFunction รับ key และ argument เป็นสองพารามิเตอร์ของ callback (keys, args ตัวพิมพ์เล็ก) library อยู่ในเซิร์ฟเวอร์และผูกเวอร์ชันกับการ deploy ของคุณ และ FUNCTION LOAD REPLACE อัปเดตมัน สำหรับอะไรที่มากกว่าสคริปต์ครั้งเดียว ให้เลือก Functions เก็บ EVAL ไว้กับ logic ชั่วคราวที่ต้องการเร็ว (Redis Functions)
สคริปต์บล็อกเซิร์ฟเวอร์: SCRIPT KILL
หัวข้อที่มีชื่อว่า “สคริปต์บล็อกเซิร์ฟเวอร์: SCRIPT KILL”สคริปต์ที่วนไม่จบหยุด client ทุกตัว เพราะคำสั่งรันทีละคำสั่ง หลัง busy-reply-threshold (ค่าเริ่มต้น 5000 ms; ผมตั้ง 200 ms สำหรับเดโม) Redis เริ่มตอบ client อื่นด้วย BUSY และรับเฉพาะ SCRIPT KILL หรือ SHUTDOWN NOSAVE ผลจริงจากคอนเทนเนอร์ของผม:
$ redis-cli EVAL "while true do end" 0 & # never returns$ redis-cli PINGBUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.$ redis-cli SCRIPT KILLOK# the looping client then got:ERR Script killed by user with SCRIPT KILL... script: 694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7, on @user_script:1.$ redis-cli PINGPONG
$ redis-cli EVAL "redis.call('SET','b3:half','1') while true do end" 0 &$ redis-cli SCRIPT KILLUNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.SCRIPT KILL ใช้ได้เฉพาะตอนที่สคริปต์ยังไม่เขียน สคริปต์ที่เขียนไปแล้วเป็น UNKILLABLE (การฆ่าจะทิ้งการเปลี่ยนแปลงที่ทำไปครึ่งเดียว) และทางออกเดียวคือ SHUTDOWN NOSAVE ดังนั้นให้สคริปต์สั้นและมีขอบเขต: ไม่มี loop ไม่จำกัด ไม่มี KEYS ไม่มี SMEMBERS ก้อนใหญ่
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน helper ของ Node รอบ Lua rate limiter และ “ทำให้สคริปต์ครบในตัว” โดยแทรก user id ลงในซอร์ส:
🐛 หา bug ในโค้ดนี้
async function hit(userId: string) {return redis.eval( `local c = redis.call('INCR', 'rate:user:${userId}') if c == 1 then redis.call('EXPIRE', 'rate:user:${userId}', 60) end return c`, 0,);}เฉลย
ปัญหาสองข้อที่พิสูจน์แล้ว ผู้ใช้ทุกคนคือสคริปต์ใหม่: id ต่างกัน 2000 ตัวไล่สคริปต์ 1500 ตัวออกจาก cache ขนาด 500 และไม่มีตัวใดใช้ซ้ำผ่าน EVALSHA ได้ ขณะที่สคริปต์เดียวที่ใส่ key ใน KEYS คงอยู่ที่ 1 รายการ ที่แย่กว่านั้น id คือโค้ด: id ที่ request ควบคุมได้อย่าง x') and redis.call('SET','b3:lua:pwned','1') or redis.call('INCR','y รันคำสั่ง SET ของผู้โจมตีบนเซิร์ฟเวอร์ (pwned = 1) ส่วนสตริงเดียวกันที่ส่งเป็นข้อมูลใน KEYS[1] ไม่ทำอะไร ให้ซอร์สคงที่และส่งค่าเป็น argument:
redis.defineCommand('hit', { numberOfKeys: 1, lua: `local c = redis.call('INCR', KEYS[1]) if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return c`,});await redis.hit(`rate:user:${userId}`, 60); // EVALSHA, one cached script, no injectionMental Model
หัวข้อที่มีชื่อว่า “Mental Model”สคริปต์คือ การยื่นขั้นตอนที่เขียนไว้ให้พนักงานแล้วปิดเคาน์เตอร์ระหว่างที่เขาทำตาม เขาถูกขัดไม่ได้ ซึ่งทำให้มัน atomic และลูกค้าคนอื่นทุกคนรอ ซึ่งเป็นเหตุที่มันต้องสั้น สมุดขั้นตอน (script cache) เป็นของร้าน ใช้ร่วมกันทุกช่อง และถูกทิ้งเมื่อร้านเปิดใหม่ มีแค่ขั้นตอนที่คุณเก็บเข้าแฟ้มอย่างถูกต้องเป็น Function เท่านั้นที่รอด ถ้าพนักงานเขียนอะไรไปแล้วสะดุด สิ่งที่เขียนยังอยู่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
สคริปต์ Lua (EVAL) แทน MULTI/EXEC | logic แตกกิ่งแบบ atomic | เขียนและดีบักยากกว่า; ข้อความ error เป็นสไตล์ Redis |
EVALSHA / defineCommand | ส่งแฮช 40 ตัวอักษรแทนซอร์ส | ต้องจัดการ NOSCRIPT (defineCommand ของ ioredis ทำให้) |
Functions แทน EVAL | มีชื่อ เก็บถาวร replicate ผูกเวอร์ชันกับ deploy | Redis 7.0+ ต้องโหลดและแทนที่ตอน deploy |
EVAL_RO / FCALL_RO | ปลอดภัยบน replica ปฏิเสธการเขียน | เขียนไม่ได้ |
| การรันแบบ atomic | ไม่มีอะไรแทรก | สคริปต์ยาวบล็อกทุก client (BUSY, SCRIPT KILL ก่อนการเขียนครั้งแรกเท่านั้น) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “script cache เป็นต่อ connection (หรือต่อ client pool)” มันคือหน่วยความจำเซิร์ฟเวอร์ที่ทุก client ใช้ร่วมกัน ล้างโดย
SCRIPT FLUSHและการ restart (พิสูจน์ด้วยสอง connection และการ restart) - “สคริปต์ที่ล้มเหลว rollback” มันหยุด; การเขียนก่อนหน้าอยู่ (
z1=a) - “
SCRIPT KILLใช้ได้เสมอ” ไม่ใช่หลังสคริปต์เขียนแล้ว (UNKILLABLE) - “คืนตัวเลข Lua แล้วทศนิยมยังอยู่” มันตัดเป็นจำนวนเต็ม
- “สคริปต์
EVALถูกเก็บถาวรเหมือนข้อมูล” มีแค่ Functions
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- สคริปต์ยาวหรือวน loop — ทุกอย่างหยุด และสคริปต์ที่เขียนไปแล้วฆ่าไม่ได้
- ไม่สนใจ
NOSCRIPT— หลัง restart หรือSCRIPT FLUSHให้ถอยไปใช้EVAL(หรือใช้defineCommand) - ยัดค่าลงในข้อความสคริปต์ — cache churn และ code injection (พิสูจน์ข้างบน)
- ใช้
redis.callทั้งที่อยากจัดการ error เอง — ใช้redis.pcall - แตะ key ที่ไม่ได้ส่งใน
KEYS— ทำลายการ route ของ Redis Cluster และความปลอดภัยบน replica
Real-world example
เอกสารของ Redis เอง แนะนำ Functions แทน
EVALสำหรับ logic ที่ต้องดูแล และอธิบายความหมายของ script cache (“scripts are meant to stay indefinitely in the cache”) ใน Scripting with Lua และ Redis functions