Redis 脚本(一文讲透)

更新时间:

💡一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

截止目前, 星球 内专栏累计输出 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 脚本的高级用法

参数化脚本与键校验

在编写脚本时,应通过 KEYSARGV 数组区分键和普通参数。例如:

-- 参数化版本的计数器脚本
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 配置)。若脚本因复杂逻辑超时,可:

  1. 优化算法减少计算量
  2. 分解为多个小脚本
  3. 调整配置(谨慎使用)

问题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 字)

最新发布