订单是电商、外卖、票务等业务系统的核心数据模型,一份订单从创建到最终完成往往要经历多个状态:待支付、已支付、待发货、已发货、已完成、已取消等等。状态之间并不是随意跳转的,比如已取消的订单不能再变为已支付,已完成的订单不能回到待发货。这套约束规则就是所谓的订单状态机。如果只是在数据库里简单地更新一个status字段,在高并发场景下极易出现脏写、非法跳转、重复回调覆盖等问题。Redis凭借其单线程原子操作和Lua脚本的原子执行能力,为状态机流转提供了轻量高效的实现思路。

一、为什么订单状态机需要Redis来辅助
先看一个典型的反例。假设订单当前状态是待支付(状态值为1),用户支付成功后,支付回调服务要将状态更新为已支付(状态值为2),代码逻辑通常是:先查询订单当前状态,判断是否允许流转,再执行UPDATE语句。这个查询加更新的过程不是原子的。当支付回调和超时取消任务同时触发时,两个请求都读到待支付状态,都通过了校验,随后取消任务把订单改成已取消,支付回调又把订单改成已支付,最终产生一个已支付但逻辑上已被取消的脏数据。
数据库层面虽然可以用乐观锁(带条件UPDATE)来兜底,但在订单量大的促销场景下,频繁回库查询会给数据库带来不小压力,且状态流转的规则校验散落在各个业务代码里,维护成本高。Redis的单线程命令执行模型天然保证了操作的原子性,配合Lua脚本可以把状态校验和状态更新合并成一个不可分割的操作,同时把状态机的流转规则集中管理,既解决了并发安全问题,也让代码结构更清晰。
需要注意的一点是,Redis在这里承担的是流转控制和快速校验的角色,数据库仍然是订单数据的最终存储。两者需要配合使用,Redis保证流转的正确性,数据库保证数据的持久性。
二、状态机模型设计与Redis存储结构
设计状态机的第一步是定义状态集合和流转规则表。假设订单有六种状态,可以用一个Map描述所有合法流转:1(待支付)可以流转到2(已支付)或6(已取消);2可以流转到3(待发货);3可以流转到4(已发货);4可以流转到5(已完成);只有待支付状态允许取消。
// 状态定义
public class OrderStatus {
public static final int WAIT_PAY = 1; // 待支付
public static final int PAID = 2; // 已支付
public static final int WAIT_SHIP = 3; // 待发货
public static final int SHIPPED = 4; // 已发货
public static final int FINISHED = 5; // 已完成
public static final int CANCELLED = 6; // 已取消
}
// 流转规则表:key为当前状态,value为允许流转的目标状态集合
Map<Integer, Set<Integer>> transitions = Map.of(
1, Set.of(2, 6),
2, Set.of(3),
3, Set.of(4),
4, Set.of(5),
5, Set.of(),
6, Set.of()
);在Redis侧,推荐使用Hash结构存储订单的运行时状态,key为order:status:{orderId},field包括status(当前状态)、version(版本号)、updateTime(更新时间)。Hash的好处是可以只读写单个字段,避免大key序列化开销。示例命令如下:
HSET order:status:10001 status 1 version 1 updateTime 1690000000 HGETALL order:status:10001 HINCRBY order:status:10001 version 1
状态创建时机要与订单落库保持一致:订单写入数据库成功后,立刻初始化Redis中的状态为待支付。如果初始化失败需要重试或回滚订单创建,避免出现数据库有订单而Redis无状态的空档。也可以增加一个兜底逻辑,状态查询不到时回源数据库并重建Redis缓存。
三、用Lua脚本实现原子性状态流转
核心的流转操作必须用Lua脚本完成。脚本内部先读取当前状态,判断目标状态是否在合法流转表中,校验通过才执行更新,否则返回失败标识。由于Redis执行Lua脚本是单线程原子操作,整个判断加更新期间不会有其他命令插入,从根本上避免了竞态条件。
-- KEYS[1]: 订单状态key
-- ARGV[1]: 目标状态
-- ARGV[2]: 当前时间戳
local key = KEYS[1]
local target = tonumber(ARGV[1])
local now = ARGV[2]
-- 定义合法流转规则
local transitions = {
[1] = {2, 6},
[2] = {3},
[3] = {4},
[4] = {5}
}
local current = tonumber(redis.call('HGET', key, 'status'))
if current == nil then
return -1 -- 订单状态不存在
end
local allowed = transitions[current]
if allowed == nil then
return -2 -- 当前状态不允许流转(终态)
end
for _, v in ipairs(allowed) do
if v == target then
redis.call('HSET', key, 'status', target, 'updateTime', now)
redis.call('HINCRBY', key, 'version', 1)
return 1 -- 流转成功
end
end
return 0 -- 非法流转在Java中通过EVAL命令执行该脚本,根据返回值决定后续动作。返回1表示流转成功,此时再去更新数据库的状态字段;返回0表示目标状态不合法,比如试图把已取消订单改为已支付,业务层直接拒绝并记录日志;返回-2表示订单已处于终态。
String luaScript =
"local key = KEYS[1] " +
"local target = tonumber(ARGV[1]) ...";
public boolean transferStatus(String orderId, int targetStatus) {
String key = "order:status:" + orderId;
Long result = redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(key),
String.valueOf(targetStatus),
String.valueOf(System.currentTimeMillis() / 1000)
);
if (result == null || result <= 0) {
log.warn("订单{}流转到状态{}失败,原因码:{}", orderId, targetStatus, result);
return false;
}
// Redis流转成功后,同步更新数据库
orderMapper.updateStatus(orderId, targetStatus);
return true;
}这里有一个工程上的取舍:Redis先改、数据库后改,中间可能失败导致两边短暂不一致。常见的处理方式是把数据库更新放入本地消息表或事务消息,失败后异步补偿重试;由于Redis中的状态已经是新状态,重试更新数据库时携带期望的前置状态条件,即使重复执行也不会造成错误覆盖。
四、订单超时自动取消与延迟任务
状态机里最常见的一条流转是超时取消:订单创建后若在规定时间内未支付,自动流转为已取消。实现延迟触发有多种方案,其中基于Redis的ZSet延迟队列简单实用。以到期时间戳作为score,订单ID作为member写入ZSet,后台任务定期轮询取出到期订单执行取消流转。
// 创建订单时投递延迟任务,30分钟超时
public void addCancelTask(String orderId) {
long expireAt = System.currentTimeMillis() / 1000 + 30 * 60;
redisTemplate.opsForZSet()
.add("order:cancel:queue", orderId, expireAt);
}
// 后台轮询任务,每秒执行一次
@Scheduled(fixedDelay = 1000)
public void processCancelTask() {
long now = System.currentTimeMillis() / 1000;
Set<String> orderIds = redisTemplate.opsForZSet()
.rangeByScore("order:cancel:queue", 0, now);
if (orderIds == null || orderIds.isEmpty()) {
return;
}
for (String orderId : orderIds) {
// 复用前面的Lua脚本尝试流转到已取消
boolean ok = transferStatus(orderId, OrderStatus.CANCELLED);
if (ok) {
redisTemplate.opsForZSet()
.remove("order:cancel:queue", orderId);
}
}
}这套机制和Lua流转脚本天然配合得很好:即使用户在超时前一刻完成了支付,支付回调先一步把状态改为已支付,取消任务再执行Lua脚本时会发现待支付状态已不允许流转到已取消,脚本返回失败,任务安全退出,不会误杀已支付订单。反过来,若取消任务先执行,支付回调到达时同样会被脚本拒绝,业务层只需根据拒绝结果走退款流程即可。
如果对延迟精度和可靠性要求更高,也可以考虑Redisson提供的延迟队列,或者使用ZSet加分布式锁防止多实例重复消费。轮询方案的优点是逻辑简单、易于排查问题;缺点是存在最多一个轮询周期的延迟,对于订单取消这类场景通常完全够用。
五、落地时的注意事项
第一,Redis数据与数据库的一致性要有兜底。Redis异常重启或缓存丢失时,应能从数据库重建状态数据,状态key建议设置合理的过期时间或采用懒加载重建策略。第二,Lua脚本要保持短小精悍,不要在脚本里做耗时的网络调用,避免阻塞Redis主线程。第三,脚本的流转规则如果需要动态调整,可以把规则写在Redis的配置key中,脚本启动时读取,而不是硬编码在脚本里,方便运营侧调整流程。第四,所有流转失败都要有明确的监控和告警,异常的原因码分布能快速定位是并发冲突还是规则配置问题。
总结来说,Redis加Lua脚本的组合为订单状态机提供了原子性的流转校验能力,配合ZSet延迟队列实现了超时自动处理,相比纯数据库条件更新方案,既降低了数据库压力,也让状态规则集中可维护。在中等规模并发的订单系统中,这是一套经过验证、可以直接落地的实用方案。