Redis의 Lua 스크립팅: 원자적 연산, EVAL 및 Redis 기능
Redis에서 Lua 스크립팅을 사용하여 다단계 작업을 원자적으로 수행하는 방법 경쟁 조건 없음: 캐시된 스크립트의 경우 EVAL, EVALSHA, MULTI/EXEC와의 비교 트랜잭션 및 새로운 Redis Functions API(Redis 7+).
Redis의 원자성 문제
Redis는 명령 실행을 위한 단일 스레드입니다. 각 명령은 다음과 같습니다. 다른 것과 관련하여 원자적으로 실행됩니다. 하지만 달려야 할 때는 단일 원자 작업(읽기-수정-쓰기)으로 이루어진 일련의 명령, 경쟁 클라이언트 간에 경쟁 조건 문제가 발생합니다.
Redis는 세 가지 솔루션을 제공합니다. MULTI/EXEC (낙관적인 거래
시계), EVAL (가장 유연한 솔루션인 Lua 스크립팅) e
새로운 것 Redis Functions API (Redis 7+, 라이브러리로서의 Lua 스크립트
버전 관리 시 영구적임).
무엇을 배울 것인가
- MULTI/EXEC: 낙관적인 트랜잭션 및 제한 사항
- EVAL: Redis에서 직접 Lua 스크립트 실행
- EVALSHA: 캐시된 스크립트 실행(네트워크 오버헤드 없음)
- Lua의 KEYS 및 ARGV: 매개변수 전달 방법
- 패턴: 비교 및 교환, 원자 속도 제한, 분산 잠금
- Redis 기능: Redis 7+의 영구 Lua 라이브러리
MULTI/EXEC: 낙관적 트랜잭션
MULTI/EXEC는 명령을 원자적으로 실행되는 대기열로 그룹화합니다. WATCH는 낙관적 잠금을 추가합니다. 감시된 키가 변경된 경우 EXEC 이전에 다른 클라이언트로부터 거래가 취소됩니다.
# MULTI/EXEC: transazione base
MULTI
SET counter 0
INCR counter
INCR counter
EXEC
# 1) OK
# 2) (integer) 1
# 3) (integer) 2
# WATCH + MULTI/EXEC: optimistic locking
WATCH balance:user:1001
# Leggi il saldo corrente
GET balance:user:1001 # "1000"
# Se nessuno modifica balance:user:1001 prima di EXEC, la transazione esegue
MULTI
DECRBY balance:user:1001 100
INCRBY balance:user:1002 100
EXEC
# Se balance:user:1001 era stato modificato nel frattempo:
# (nil) -- transazione annullata, il client deve riprovare
# DISCARD: annulla una transazione in corso
MULTI
SET key1 value1
DISCARD # Annulla tutto
MULTI/EXEC의 제한은 명령이 대기열에 추가되지만 그렇지 않다는 것입니다. 한 명령의 결과를 동일한 명령의 다음 명령에 대한 입력으로 사용할 수 있습니다. 거래. 이것이 Lua 스크립팅의 목적입니다.
EVAL: Atomic Lua 스크립팅
EVAL Redis 서버에서 직접 Lua 스크립트를 실행합니다.
스크립트는 원자적으로 실행됩니다. 다른 명령은 실행할 수 없습니다.
그것을 중단하십시오. 값을 읽고, 계산하고, 결과를 쓸 수 있습니다.
단일 원자 작업에서.
# EVAL sintassi: EVAL script numkeys key [key ...] arg [arg ...]
# KEYS[1], KEYS[2], ... per i nomi delle chiavi
# ARGV[1], ARGV[2], ... per gli argomenti aggiuntivi
# Esempio 1: incremento condizionale
# Incrementa solo se il valore e' minore di un massimo
EVAL "
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1])
return tonumber(ARGV[1])
end
local val = tonumber(current)
local max = tonumber(ARGV[2])
if val < max then
return redis.call('INCR', KEYS[1])
end
return val
" 1 counter:requests 0 100
# Incrementa counter:requests (KEYS[1]) se < 100 (ARGV[2])
# Default iniziale = 0 (ARGV[1])
# Esempio 2: get-set-expire atomico (GETSET + EXPIRE)
EVAL "
local old = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return old
" 1 session:token:abc123 "new_value" 3600
# Esempio 3: compare-and-swap (CAS)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
" 1 config:version "v1" "v2"
# Ritorna 1 se CAS riuscito, 0 se il valore era gia' cambiato
# Python: EVAL con redis-py
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Script Lua per compare-and-swap atomico
CAS_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
"""
def compare_and_swap(key: str, expected: str, new_value: str) -> bool:
"""Aggiorna key a new_value solo se il valore corrente e' expected."""
result = r.eval(CAS_SCRIPT, 1, key, expected, new_value)
return bool(result)
# Uso
r.set('config:feature-flag', 'disabled')
success = compare_and_swap('config:feature-flag', 'disabled', 'enabled')
print(f"CAS succeeded: {success}") # True
# Secondo tentativo con valore sbagliato
success2 = compare_and_swap('config:feature-flag', 'disabled', 'enabled')
print(f"CAS succeeded: {success2}") # False (era gia' 'enabled')
EVALSHA: 성능을 위해 캐시된 스크립트
전화할 때마다 EVAL, 스크립트는 네트워크를 통해 Redis로 전송됩니다.
자주 호출되는 스크립트의 경우 EVALSHA 네트워크 트래픽을 줄입니다.
클라이언트는 전체 코드 대신 스크립트의 SHA1 해시(40자)만 보냅니다.
Redis는 다음과 같이 로드된 스크립트의 캐시를 유지합니다. SCRIPT LOAD.
# SCRIPT LOAD: carica lo script nel server, ottieni SHA1
SCRIPT LOAD "
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
else
return 0
end
"
# "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d" (SHA1 hash)
# EVALSHA: esegui con solo l'hash (molto piu' efficiente in rete)
EVALSHA "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d" 1 key "expected" "new"
# SCRIPT EXISTS: verifica se uno script e' in cache
SCRIPT EXISTS "7d5c3d9b8e5d5f9b3c3d9b8e5d5f9b3c3d9b8e5d"
# 1) (integer) 1
# SCRIPT FLUSH: rimuove tutti gli script dalla cache (utile in test)
SCRIPT FLUSH
# Python: gestione SHA con fallback automatico
import redis
import hashlib
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
class LuaScript:
"""Wrapper per script Lua con caching SHA automatico."""
def __init__(self, script: str):
self.script = script
self.sha = None
def _load(self) -> str:
"""Carica lo script e memorizza lo SHA."""
self.sha = r.script_load(self.script)
return self.sha
def execute(self, keys: list, args: list):
"""Esegue lo script, con fallback a EVAL se SHA non in cache."""
if self.sha is None:
self._load()
try:
return r.evalsha(self.sha, len(keys), *keys, *args)
except redis.exceptions.NoScriptError:
# Script non piu' in cache (SCRIPT FLUSH chiamato)
self._load()
return r.evalsha(self.sha, len(keys), *keys, *args)
# Script rate limiter atomico: sliding window
RATE_LIMIT_SCRIPT = LuaScript("""
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- finestra in secondi
local max_requests = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) -- timestamp corrente in ms
-- Rimuovi entries scadute
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
-- Conta requests nella finestra
local count = redis.call('ZCARD', key)
if count < max_requests then
-- Aggiungi la request corrente
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1 -- Allowed
else
return 0 -- Rate limited
end
""")
def check_rate_limit(user_id: str, window_sec: int = 60, max_req: int = 100) -> bool:
"""Rate limiting sliding window atomico via Lua. Ritorna True se allowed."""
import time
now_ms = int(time.time() * 1000)
key = f"ratelimit:{user_id}"
result = RATE_LIMIT_SCRIPT.execute(
keys=[key],
args=[str(window_sec), str(max_req), str(now_ms)],
)
return bool(result)
# Test
for i in range(5):
allowed = check_rate_limit("user:1001", window_sec=60, max_req=3)
print(f"Request {i+1}: {'ALLOWED' if allowed else 'RATE LIMITED'}")
패턴: Lua를 사용한 분산 잠금
분산 잠금은 구현할 수 있는 가장 중요한 패턴 중 하나입니다. 루아와 함께. Redlock 알고리즘은 원자적 SETNX + EXPIRE를 사용하여 잠금을 획득합니다. 삭제하기 전에 토큰을 확인하는 발급용 Lua 스크립트.
# Distributed Lock: SETNX + EXPIRE atomici
# SET key value NX PX milliseconds (Redis 2.6.12+)
SET lock:resource:order-1234 "worker-uuid-abc" NX PX 30000
# OK se il lock e' stato acquisito, nil altrimenti
# Script Lua per rilascio ATOMICO (verifica token prima di cancellare)
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
" 1 lock:resource:order-1234 "worker-uuid-abc"
# Python: Distributed Lock context manager
import redis
import uuid
import time
from contextlib import contextmanager
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
RELEASE_LOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
@contextmanager
def distributed_lock(resource: str, timeout_ms: int = 30000, retry_ms: int = 100):
"""
Context manager per distributed lock.
Usa SET NX PX per acquisizione atomica.
Usa Lua per rilascio atomico (evita rilascio di lock altrui).
"""
lock_key = f"lock:{resource}"
token = str(uuid.uuid4())
acquired = False
try:
# Tentativo di acquisizione lock
acquired = r.set(lock_key, token, nx=True, px=timeout_ms)
if not acquired:
raise Exception(f"Could not acquire lock on {resource}")
yield token # Esegui codice protetto
finally:
if acquired:
# Rilascio atomico: cancella solo se il token e' ancora il nostro
released = r.eval(RELEASE_LOCK_SCRIPT, 1, lock_key, token)
if not released:
# Il lock e' scaduto e qualcun altro lo ha acquisito nel frattempo
print(f"WARNING: Lock {lock_key} expired before release")
# Uso
def process_order(order_id: str):
with distributed_lock(f"order:{order_id}", timeout_ms=10000):
# Questo codice e' eseguito in modo esclusivo
print(f"Processing order {order_id}...")
time.sleep(0.5) # Simula lavoro
print(f"Order {order_id} processed")
# Due worker concorrenti
import threading
t1 = threading.Thread(target=process_order, args=("ORD-123",))
t2 = threading.Thread(target=process_order, args=("ORD-123",))
t1.start()
t2.start()
t1.join()
t2.join()
Redis 기능: 영구 Lua 라이브러리(Redis 7+)
Redis 7.0에는 다음과 같은 Lua 라이브러리를 로드하는 시스템인 Functions가 도입되었습니다. 서버의 영구 코드. EVAL/EVALSHA와 달리 기능은 Redis 재부팅 후에도 유지되고 버전 관리를 지원하며 활성화됩니다. 관련 스크립트를 네임스페이스 라이브러리로 구성합니다.
# Redis Functions: caricare una libreria
# Definisci la libreria in Lua (sintassi Redis 7+)
FUNCTION LOAD "#!lua name=mylib\n
local function rate_limit(keys, args)
local key = keys[1]
local window = tonumber(args[1])
local max_req = tonumber(args[2])
local now = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < max_req then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1
end
return 0
end
local function atomic_cas(keys, args)
if redis.call('GET', keys[1]) == args[1] then
redis.call('SET', keys[1], args[2])
return 1
end
return 0
end
redis.register_function('rate_limit', rate_limit)
redis.register_function('atomic_cas', atomic_cas)
"
# Chiama la funzione con FCALL
FCALL rate_limit 1 ratelimit:user:1001 60 100 1710000000000
# Lista librerie caricate
FUNCTION LIST
# Elimina una libreria
FUNCTION DELETE mylib
# Dump e restore (per backup/migration)
FUNCTION DUMP # Ritorna binary dump
FUNCTION RESTORE [FLUSH] [APPEND] [REPLACE] <payload>
# Python: Redis Functions con redis-py
import redis
import time
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
LIBRARY_CODE = """#!lua name=ratelimit_lib
local function sliding_window_rl(keys, args)
local key = keys[1]
local window = tonumber(args[1])
local max_req = tonumber(args[2])
local now = tonumber(args[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
local count = redis.call('ZCARD', key)
if count < max_req then
redis.call('ZADD', key, now, tostring(now))
redis.call('EXPIRE', key, window + 1)
return {1, max_req - count - 1} -- {allowed, remaining}
end
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local retry_after = math.ceil((tonumber(oldest[2]) + window * 1000 - now) / 1000)
return {0, 0, retry_after} -- {blocked, remaining, retry_after_sec}
end
redis.register_function('sliding_rl', sliding_window_rl)
"""
def load_library():
"""Carica o ricarica la libreria Functions."""
try:
r.function_load(LIBRARY_CODE)
except redis.exceptions.ResponseError as e:
if 'Library already exists' in str(e):
r.function_load(LIBRARY_CODE, replace=True)
else:
raise
def check_rate_limit_fn(user_id: str, window: int = 60, max_req: int = 10):
"""Usa Redis Function per rate limiting sliding window."""
key = f"rl:user:{user_id}"
now_ms = int(time.time() * 1000)
result = r.fcall('sliding_rl', 1, key, str(window), str(max_req), str(now_ms))
allowed, remaining = result[0], result[1]
retry_after = result[2] if len(result) > 2 else None
return {
'allowed': bool(allowed),
'remaining': remaining,
'retry_after': retry_after,
}
# Setup
load_library()
# Test
for i in range(12):
result = check_rate_limit_fn("user:1001", window=60, max_req=10)
status = "OK" if result['allowed'] else f"BLOCKED (retry in {result['retry_after']}s)"
print(f"Request {i+1}: {status}, remaining: {result['remaining']}")
MULTI/EXEC vs Lua vs 함수: 비교
선택할 메커니즘
- 멀티/EXEC: 조건부 논리가 없는 간단한 명령 시퀀스입니다. 동일한 트랜잭션의 후속 명령에 대한 입력으로 사용되는 중간 결과가 없습니다.
- 평가: 복잡한 조건부 논리, 원자성 읽기-수정-쓰기, 신속한 프로토타이핑. 애플리케이션 코드의 인라인 스크립트.
- EVALSHA: EVAL과 비슷하지만 자주 호출되는 스크립트에 대한 네트워크 페이로드가 줄어듭니다. SHA 캐시 관리가 필요합니다.
- 기능(Redis 7+): 서버에서 최고 수준의 스크립트: 재부팅 후에도 유지되고, 버전 관리가 가능하며, 라이브러리로 구성 가능합니다. 여러 애플리케이션에서 안정적인 공유 코드를 위한 것입니다.
결론
Redis의 Lua 스크립팅은 복잡한 원자성을 위한 최종 도구입니다. MULTI/EXEC는 간단한 시퀀스를 다루는 반면, EVAL을 사용하면 패턴을 구현할 수 있습니다. 속도 제한, 분산 잠금, 비교 및 스왑과 같은 정교한 기능 경쟁 조건에 대해 걱정하지 마십시오. Redis 함수는 이러한 패턴을 제공합니다 Lua 코드를 버전이 지정된 인프라로 처리하여 더 높은 수준으로 애플리케이션 코드에 하드코딩된 문자열 대신.
Redis 시리즈의 향후 기사
- 제4조: 고급 속도 제한 — 토큰 버킷, Redis의 슬라이딩 윈도우
- 제5조: 세션 관리 및 캐시 패턴 — 캐시 배제, 연속 기입
- 제6조: RedisJSON 및 RediSearch — JSON 문서 및 전체 텍스트 검색







