Redis订单超时取消需用Lua脚本实现被动触发+原子检查,因EXPIRE无可靠回调;须将时间判断与状态更新封装于单个脚本,配合ZSET索引避免全量扫描,并注意时钟同步、状态守卫及重复key处理。

Redis 用 Lua 实现订单超时自动取消,核心不是“定时”,而是“被动触发 + 原子检查”——你无法靠 Redis 自己跑 cron,得靠业务方主动调用或结合外部调度器轮询 EVAL 脚本。
为什么不能直接用 EXPIRE + 过期回调?
Redis 没有可靠的键过期回调机制:Redis Keyspace Notifications 虽然能发 expired 事件,但存在丢失、延迟、需额外消费服务(如订阅 __keyevent@0__:expired)等问题,生产环境难保证订单状态最终一致。更稳妥的做法是:把“是否超时”和“是否可取消”这两步压缩进一个 Lua 脚本里,由下游(比如支付轮询、订单查询接口、或独立的延迟队列消费者)来触发检查。
EVAL 脚本必须同时读取时间戳并原子更新状态
典型错误是先用 GET 拿订单创建时间,再在客户端判断是否超时,最后发 SET 改状态——这中间有竞态:两个请求同时读到“未超时”,都去改状态,结果只应取消一次的订单被重复处理。正确做法是把逻辑全塞进 Lua:
eval "local ctime = redis.call('hget', KEYS[1], 'created_at') if not ctime then return 0 end if tonumber(ctime) < tonumber(ARGV[1]) then redis.call('hset', KEYS[1], 'status', 'cancelled') redis.call('hset', KEYS[1], 'cancel_reason', 'timeout') return 1 else return 0 end" 1 order:12345 1717027200
说明:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
-
KEYS[1]是订单 hash key(如order:12345),避免硬编码 -
ARGV[1]是当前时间戳(秒级),由调用方传入,不依赖redis.call('time')(返回的是数组,且精度不够) - 脚本内用
hget/hset操作 hash 结构,比 string 更适合存订单多字段 - 返回
1表示已取消,0表示未超时或已取消,调用方可据此决定是否发 MQ 或更新 DB
如何让“超时检查”更轻量?别全量扫表
不能每次都在 Lua 里遍历所有订单。常见做法是配合有序集合(ZSET)做时间索引:
- 下单时执行:
ZADD order:timeout:zset 1717027200 order:12345(分值为超时时间戳) - 定时任务(如每分钟)执行:
ZRANGEBYSCORE order:timeout:zset -inf (now_timestamp,拿到待处理订单 ID 列表 - 对每个 ID 调用上面的 Lua 脚本——注意:这里要批量调用,别用 pipeline 包一堆
EVAL,而应写个批量版 Lua,用for i=1, #KEYS do ...循环处理多个 key - 处理完记得
ZREM已处理的 key,防止重复触发
这样既避免了全库扫描,又把“发现超时”和“执行取消”解耦,也方便监控积压量(查 ZCARD order:timeout:zset)。
容易忽略的边界:时钟漂移与重复执行
外部传入的 ARGV[1] 时间戳必须来自可信源(如应用服务器 NTP 同步后的时间),不能用客户端本地时间;Lua 脚本本身无事务回滚能力,一旦 hset 成功就不可逆,所以脚本里要加状态守卫(比如只允许从 pending 变成 cancelled,若已是 paid 则直接返回 0);另外,ZSET 的 ZRANGEBYSCORE 默认包含等于分值的元素,如果多个订单超时时间完全相同,要确保你的批量脚本能正确处理重复 key。

















