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

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

transaction จัดกลุ่มคำสั่งแต่แตกกิ่งตามผลระหว่างทางไม่ได้ ถ้าต้องอ่านค่า ตัดสินใจ และเขียน โดยไม่มี client อื่นแทรก ต้องใช้สคริปต์ สคริปต์รันทั้งหมดบนเซิร์ฟเวอร์: ไม่มี round trip ระหว่างขั้น และไม่มีคำสั่งอื่นรันขณะมันทำงาน (Redis Lua API)

EVAL script numkeys [key ...] [arg ...]: key มาใน KEYS[1..] argument อื่นมาใน ARGV[1..] ส่ง key ผ่าน KEYS เสมอ (เซิร์ฟเวอร์และ Redis Cluster ต้องรู้ว่าสคริปต์แตะ key ไหน)

cli/tsl-lua-1.redis
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "hello"
OK
127.0.0.1:6379> GET mykey
"hello"
127.0.0.1:6379> EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 k a b
1) "k"
2) "a"
3) "b"
127.0.0.1:6379> EVAL "return redis.call('INCR', KEYS[1])" 1 counter
(integer) 1
127.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 range
127.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) 3
127.0.0.1:6379> EVAL "return false" 0
(nil)
127.0.0.1:6379> EVAL "return true" 0
(integer) 1
127.0.0.1:6379> EVAL "return {1, 2, {3, 'x'}}" 0
1) (integer) 1
2) (integer) 2
3) 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.call raise ส่วน 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 กลายเป็น nil true กลายเป็น 1 และ table กลายเป็นอาร์เรย์ ถ้าต้องการทศนิยมให้คืนเป็นสตริง
  • EVAL_RO รันสคริปต์ที่ห้ามเขียน: GET ทำงาน และ SET ถูกปฏิเสธ ใช้มัน (หรือ FCALL_RO) กับ logic แบบอ่านอย่างเดียวเพื่อให้รันบน replica ได้

การแตกกิ่งตามผลของ INCR คือสิ่งที่ transaction ทำไม่ได้พอดี:

-- lua/tsl-lua-ratelimit.lua
-- rate limiter: increment; set the window on the first hit; return 1 if under the limit, else 0
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current <= tonumber(ARGV[2]) then
return 1
else
return 0
end

เพราะสคริปต์เป็น 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 counter

การส่งซอร์สทุกครั้งที่เรียกเปลืองแบนด์วิดท์ SCRIPT LOAD คอมไพล์สคริปต์และคืน SHA1 ของมัน EVALSHA sha numkeys ... รันด้วยแฮช SHA1 ของข้อความสคริปต์ที่ตรงกันเป๊ะเหมือนกันทุกที่ ดังนั้นแฮชในบทนี้ (61636018...) คือสิ่งที่ SCRIPT LOAD "return redis.call('INCR', KEYS[1])" คืนบนเซิร์ฟเวอร์ใดก็ได้

cli/tsl-lua-2.redis
127.0.0.1:6379> SCRIPT FLUSH
OK
127.0.0.1:6379> SCRIPT LOAD "return redis.call('INCR', KEYS[1])"
"61636018f4e6b5817b89791bbed242f93fa089e3"
127.0.0.1:6379> EVALSHA 61636018f4e6b5817b89791bbed242f93fa089e3 1 counter
(integer) 2
127.0.0.1:6379> SCRIPT EXISTS 61636018f4e6b5817b89791bbed242f93fa089e3 0000000000000000000000000000000000000000
1) (integer) 1
2) (integer) 0
127.0.0.1:6379> SCRIPT FLUSH
OK
127.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 e0e1f9fabfc9d4800c877a703b823ac0578ff8db
0 # the script cache is gone
$ redis-cli FUNCTION LIST # library "keep" is still there
$ redis-cli GET persist:me
1

script cache ไม่รอดการ restart ส่วน function library ที่โหลดไว้และข้อมูลรอด นั่นคือความต่างเชิงปฏิบัติระหว่างสคริปต์ EVAL/SCRIPT LOAD กับ Functions

src/tsl-lua-scripting.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const a = new Redis(url);
const b = new Redis(url); // a second, independent connection
const 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 B
const 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 NOSCRIPT
await 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 NOSCRIPT
a.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 time
const 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 request
const 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 data
console.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 FCALL
await a.call('FUNCTION', 'LOAD', 'REPLACE', `#!lua name=b3lib
redis.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: 0
A loaded 61636018f4e6b5817b89791bbed242f93fa089e3 | B calls it by SHA -> 1
after SCRIPT FLUSH, B -> NOSCRIPT No matching script. Please use EVAL.
incrCapped x3 -> 1,2,2 | after a SCRIPT FLUSH the next call still works -> 2
2000 scripts with the user id in the text : 603 ms | cached: 500 | evicted: 1500
2000 calls of ONE script, key in KEYS : 642 ms | cached: 1 | evicted: 0
injected through the user id -> b3:lua:pwned = 1
same input passed in KEYS[1] -> b3:lua:pwned = null
FCALL -> hello ioredis
  • cache ร่วมกัน: B รันสคริปต์ด้วย SHA ที่มีเพียง A โหลด
  • NOSCRIPT ปรากฏทันทีที่ cache ถูกล้าง evalsha ที่เขียนเองต้องจับมันแล้วถอยไปใช้ EVAL
  • defineCommand ทำให้เอง: 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) คือทางเลือกที่มีชื่อและถูกเก็บถาวร ต่อไป

function library คือกลุ่มฟังก์ชัน Lua ที่มีชื่อ ลงทะเบียนด้วย redis.register_function ต่างจากสคริปต์ EVAL ตรงที่มันถูกเก็บในชุดข้อมูล: รอดการ restart (ดังที่แสดงข้างบน) รวมอยู่ใน RDB/AOF ถูก replicate และเรียกด้วยชื่อผ่าน FCALL name numkeys ...

cli/tsl-lua-3.redis
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) 1
127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2
(integer) 2
127.0.0.1:6379> FCALL tsl_incr_cap 1 cap 2
(integer) 2
127.0.0.1:6379> FUNCTION LIST
1) 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_lib
OK

Function รับ key และ argument เป็นสองพารามิเตอร์ของ callback (keys, args ตัวพิมพ์เล็ก) library อยู่ในเซิร์ฟเวอร์และผูกเวอร์ชันกับการ deploy ของคุณ และ FUNCTION LOAD REPLACE อัปเดตมัน สำหรับอะไรที่มากกว่าสคริปต์ครั้งเดียว ให้เลือก Functions เก็บ EVAL ไว้กับ logic ชั่วคราวที่ต้องการเร็ว (Redis Functions)

สคริปต์ที่วนไม่จบหยุด 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 PING
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
$ redis-cli SCRIPT KILL
OK
# the looping client then got:
ERR Script killed by user with SCRIPT KILL... script: 694a5fe1ddb97a4c6a1bf299d9537c7d3d0f84e7, on @user_script:1.
$ redis-cli PING
PONG
$ redis-cli EVAL "redis.call('SET','b3:half','1') while true do end" 0 &
$ redis-cli SCRIPT KILL
UNKILLABLE 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 ก้อนใหญ่

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 injection

สคริปต์คือ การยื่นขั้นตอนที่เขียนไว้ให้พนักงานแล้วปิดเคาน์เตอร์ระหว่างที่เขาทำตาม เขาถูกขัดไม่ได้ ซึ่งทำให้มัน atomic และลูกค้าคนอื่นทุกคนรอ ซึ่งเป็นเหตุที่มันต้องสั้น สมุดขั้นตอน (script cache) เป็นของร้าน ใช้ร่วมกันทุกช่อง และถูกทิ้งเมื่อร้านเปิดใหม่ มีแค่ขั้นตอนที่คุณเก็บเข้าแฟ้มอย่างถูกต้องเป็น Function เท่านั้นที่รอด ถ้าพนักงานเขียนอะไรไปแล้วสะดุด สิ่งที่เขียนยังอยู่

ตัวเลือกข้อดีข้อเสีย
สคริปต์ Lua (EVAL) แทน MULTI/EXEClogic แตกกิ่งแบบ atomicเขียนและดีบักยากกว่า; ข้อความ error เป็นสไตล์ Redis
EVALSHA / defineCommandส่งแฮช 40 ตัวอักษรแทนซอร์สต้องจัดการ NOSCRIPT (defineCommand ของ ioredis ทำให้)
Functions แทน EVALมีชื่อ เก็บถาวร replicate ผูกเวอร์ชันกับ deployRedis 7.0+ ต้องโหลดและแทนที่ตอน deploy
EVAL_RO / FCALL_ROปลอดภัยบน replica ปฏิเสธการเขียนเขียนไม่ได้
การรันแบบ atomicไม่มีอะไรแทรกสคริปต์ยาวบล็อกทุก client (BUSY, SCRIPT KILL ก่อนการเขียนครั้งแรกเท่านั้น)
  • “script cache เป็นต่อ connection (หรือต่อ client pool)” มันคือหน่วยความจำเซิร์ฟเวอร์ที่ทุก client ใช้ร่วมกัน ล้างโดย SCRIPT FLUSH และการ restart (พิสูจน์ด้วยสอง connection และการ restart)
  • “สคริปต์ที่ล้มเหลว rollback” มันหยุด; การเขียนก่อนหน้าอยู่ (z1 = a)
  • “SCRIPT KILL ใช้ได้เสมอ” ไม่ใช่หลังสคริปต์เขียนแล้ว (UNKILLABLE)
  • “คืนตัวเลข Lua แล้วทศนิยมยังอยู่” มันตัดเป็นจำนวนเต็ม
  • “สคริปต์ EVAL ถูกเก็บถาวรเหมือนข้อมูล” มีแค่ Functions
  • สคริปต์ยาวหรือวน 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

ในสคริปต์ Lua จะอ่าน key แรกที่ส่งให้ EVAL อย่างไร?
คำสั่งภายในสคริปต์ล้มเหลว redis.call ต่างจาก redis.pcall อย่างไร?
สคริปต์ทำ SET z1 a แล้ว INCR ที่ล้มเหลว หลังจากนั้น GET z1 คืนอะไร?
script cache อยู่ที่ไหน และเกิดอะไรเมื่อเซิร์ฟเวอร์ restart?
EVALSHA คืนอะไรเมื่อสคริปต์ไม่อยู่ใน cache?
สคริปต์วนไม่จบหลังเขียน key ไปแล้ว ทำอะไรได้?
ทำไมการสร้างซอร์สสคริปต์โดยแทรก user id จึงเป็น bug?