Redis 脚本(一文讲透)
💡一则或许对你有用的小广告
欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...
,点击查看项目介绍 ;演示链接: http://116.62.199.48:7070 ;- 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;
截止目前, 星球 内专栏累计输出 90w+ 字,讲解图 3441+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 3100+ 小伙伴加入学习 ,欢迎点击围观
什么是 Redis 脚本?它能解决什么问题?
在分布式系统开发中,Redis 因其高性能和丰富的数据结构被广泛使用。然而,当多个操作需要保证一致性时,仅靠 Redis 的单条命令可能无法满足需求。例如,一个常见的场景是:用户下单时需要先检查库存是否充足,若充足则扣减库存并记录订单。如果这两个步骤分开执行,可能会因网络延迟或并发操作导致库存超卖。
Redis 脚本(Redis Script)正是为了解决这类问题而设计的解决方案。它允许开发者将多条 Redis 操作封装到一个 Lua 脚本中,通过原子性执行确保操作的完整性。可以将 Redis 脚本想象成一个“智能操作手册”——它像快递员一样,严格按照步骤执行任务,中途不会被其他请求打断。
Redis 脚本的核心优势与适用场景
原子性操作:避免竞态条件
Redis 脚本的原子性是其最核心的优势。当脚本被发送到 Redis 服务器后,会立即被放入执行队列,并在执行期间独占 Redis 进程。这意味着即使有其他客户端同时发送命令,脚本也会完整执行完毕后再处理后续请求。这种特性完美解决了“检查库存-扣减库存”这类需要严格顺序操作的场景。
跨命令事务性
虽然 Redis 提供了 MULTI
/EXEC
事务机制,但它仅保证命令按顺序执行,并不能解决中间状态暴露的问题。例如,在事务执行过程中,若某个命令失败,后续命令仍会继续执行。而 Redis 脚本通过 Lua 脚本的自包含特性,所有操作要么全部成功,要么全部失败,避免了数据不一致风险。
性能优化:减少网络开销
将多个操作封装到一个脚本中,可以大幅降低客户端与服务器之间的网络往返时间(RTT)。例如,原本需要三次独立请求的“获取值-修改值-设置值”操作,通过脚本可合并为单次请求,显著提升系统吞吐量。
适用场景举例
场景类型 | 典型需求案例 | 解决方案要点 |
---|---|---|
分布式锁 | 防止多个进程同时操作资源 | 使用脚本实现加锁与解锁原子性 |
订单系统 | 库存扣减与订单记录同步 | 确保操作序列不可中断 |
分布式计数器 | 高并发场景下的精准计数 | 原子性增减操作 |
缓存预热 | 缓存重建与读取操作的平滑切换 | 脚本控制旧缓存与新数据同步 |
如何编写和执行 Redis 脚本?
Lua 脚本基础语法
Redis 脚本使用 Lua 脚本语言编写,这是 Redis 从 2.6 版本开始支持的特性。Lua 语言简洁易学,其核心语法与 JavaScript 类似。编写 Redis 脚本时,可通过以下内置函数与 Redis 交互:
redis.call('命令', 参数1, 参数2...)
:执行 Redis 命令redis.pcall('命令', 参数...)
:执行命令但忽略错误error('错误信息')
:抛出异常终止执行
示例:简单的计数器脚本
-- 增加计数器并返回新值
local current = tonumber(redis.call('GET', KEYS[1]))
if current then
return redis.call('INCR', KEYS[1])
else
redis.call('SET', KEYS[1], 1)
return 1
end
脚本的执行方式
客户端通过 EVAL
命令将 Lua 脚本发送到 Redis 服务器执行。命令格式为:
EVAL "脚本内容" key数量 key1 key2 ... arg1 arg2 ...
示例执行命令
EVAL "..." 1 counter
脚本缓存与性能优化
Redis 会自动缓存已执行的脚本,避免重复编译开销。可以通过 SCRIPT LOAD
预加载脚本,再用 EVALSHA
命令通过 SHA1 哈希值调用,进一步提升性能:
SCRIPT LOAD "脚本内容"
EVALSHA "哈希值" 1 counter
Redis 脚本的高级用法
参数化脚本与键校验
在编写脚本时,应通过 KEYS
和 ARGV
数组区分键和普通参数。例如:
-- 参数化版本的计数器脚本
local key = KEYS[1]
local step = tonumber(ARGV[1] or 1)
local current = tonumber(redis.call('GET', key))
return redis.call('INCRBY', key, step or 1)
执行时:
EVAL "..." 1 counter_key 5 # 将步长设为5
错误处理与回滚
在脚本中,可通过 error()
抛出异常终止执行。例如在扣减库存时:
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock < tonumber(ARGV[1]) then
error("库存不足")
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return "扣减成功"
事务与脚本的区别
虽然 MULTI
/EXEC
事务与脚本都能保证命令顺序执行,但两者有本质区别:
| 特性 | Redis 事务 | Redis 脚本 |
|--------------------|--------------------------|----------------------------|
| 原子性 | 无(中间状态可能暴露) | 完全原子 |
| 错误处理 | 后续命令继续执行 | 单个错误终止全部操作 |
| 脚本执行 | 无法包含逻辑判断 | 支持复杂的条件判断与循环 |
| 性能开销 | 需多次网络交互 | 单次请求完成 |
实战案例:分布式锁的实现
需求背景
在微服务架构中,常需要实现分布式锁来控制资源访问。例如防止两个服务实例同时处理同一个订单。传统 SETNX
命令只能保证加锁原子性,但解锁时需要知道锁的值,且无法处理持有者异常退出的问题。
基于脚本的 Redlock 算法实现
通过 Lua 脚本实现带超时的锁:
-- 锁名称、标识符、超时时间(秒)
local key, id, ttl = KEYS[1], ARGV[1], tonumber(ARGV[2])
if redis.call('GET', key) == id then
-- 延长有效期
return redis.call('EXPIRE', key, ttl)
else
return 0
end
执行加锁命令:
EVAL "..." 1 lock_key my_identifier 10
解锁操作
-- 解锁时验证标识符
local current_id = redis.call('GET', KEYS[1])
if current_id == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
else
return 0
end
常见问题与解决方案
问题1:脚本执行超时
Redis 默认脚本执行时间限制为 5 秒(lua-time-limit
配置)。若脚本因复杂逻辑超时,可:
- 优化算法减少计算量
- 分解为多个小脚本
- 调整配置(谨慎使用)
问题2:内存泄漏
脚本中的 redis.call
返回值若未正确处理,可能导致内存占用过高。建议:
-- 始终返回必要的结果
local result = redis.call('GET', key)
if result then return result else return nil end
问题3:键名冲突
使用 KEYS
数组时,应确保参数顺序正确。建议在代码注释中明确参数含义:
-- KEYS[1]: 用户计数器
-- KEYS[2]: 系统计数器
-- ARGV[1]: 操作类型(increment/decrement)
总结与展望
Redis 脚本作为连接基础命令与复杂业务逻辑的桥梁,其原子性、事务性和性能优势使其成为构建高并发系统的必备工具。通过合理使用 Lua 脚本,开发者可以:
- 有效避免竞态条件
- 构建可回滚的业务流程
- 减少网络交互开销
随着 Redis 7.0 引入的模块化架构,未来脚本开发可能与 Redis 模块(如 RedisJSON、RedisTimeSeries)深度集成,进一步拓展应用场景。建议开发者从简单案例开始实践,逐步掌握条件判断、循环语句等进阶技巧,最终实现复杂业务场景的自动化控制。
(全文统计:约 1850 字)