Redis事务通过MULTI命令开启,之后输入的命令不会立即执行,而是进入一个队列。当执行EXEC时,Redis才会按顺序一次执行队列中的全部命令。这个过程保证了事务内的命令不会被其他客户端的命令穿插,因为Redis单线程处理命令,但它不会提供关系型数据库那样的回滚能力。理解这一点,是正确使用Redis事务的前提。

在实际开发中,Redis事务常用于需要一次性提交多个写操作的场景,例如扣减库存、更新用户余额、记录操作日志。事务可以确保这一组命令按顺序连续执行,减少客户端与Redis之间的往返次数。但仍然需要处理命令执行错误和并发修改问题,下面结合redis-cli命令和编程语言代码分别展开。
一、Redis事务核心命令与执行流程
Redis事务主要涉及五个命令:MULTI、EXEC、DISCARD、WATCH和UNWATCH。MULTI用于开启事务,进入事务模式后,后续命令都会被放入队列并返回QUEUED。EXEC负责触发队列中的命令顺序执行。DISCARD可以清空队列并退出事务模式。而WATCH用于在事务开始前监控一个或多个键,当这些键在EXEC之前被其他客户端修改时,事务不会执行。UNWATCH则取消所有监控。
下面是使用redis-cli执行一个简单事务的完整过程。事务中先写入用户姓名,再增加访问次数,最后一次性提交:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET user:1:name "Tom" QUEUED 127.0.0.1:6379> INCR user:1:visits QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (integer) 1
可以看到,SET和INCR在MULTI之后并没有立刻执行,而是返回QUEUED表示已入队。执行EXEC后返回一个数组,数组中的每个元素对应队列中命令的执行结果,顺序与入队顺序完全一致。如果事务执行过程中某条命令出现语法错误,EXEC会直接失败;如果是运行时错误,例如对字符串执行自增,则只影响该命令自身,其他命令继续执行。
这种队列化设计让Redis事务非常适合批量执行命令,但也要注意它并不具备传统数据库事务的原子性和隔离性。多个客户端的事务命令在Redis单线程模型下会按顺序执行,不会被其他命令中途插入,这提供了隔离性;但一旦命令执行出错,不会自动回滚之前已经成功执行的命令。
二、WATCH乐观锁与并发控制
Redis事务本身无法保证多个客户端同时修改同一个键时的数据一致性。例如两个客户端同时读取库存数量,再基于旧值写入扣减后的结果,就可能覆盖彼此更新。为了解决这个问题,Redis提供了WATCH命令,它可以在事务开始前监控一个或多个键。在EXEC执行时,Redis会检查被监控键是否被修改过,如果发现变化,整个事务不会执行,返回空数组。
从实现角度看,Redis内部为每个被监控的键记录了一个版本号。任何修改键的命令都会递增该版本号。EXEC时只要版本号与WATCH时不一致,就判定为并发冲突。开发者收到空结果后,可以在业务层重试整个读取到写入的过程。这种方式属于乐观锁:先假设不会发生冲突,只在提交阶段检测冲突。
下面用Python的redis-py库演示WATCH和事务的结合使用。示例实现了一个安全的余额更新流程:
import redis
client = redis.Redis(host='127.0.0.1', port=6379, db=0)
def update_balance(user_id, amount):
key = f"user:{user_id}:balance"
with client.pipeline(transaction=True) as pipe:
while True:
try:
# 监控余额键
pipe.watch(key)
balance = int(pipe.get(key) or 0)
new_balance = balance + amount
# 开启事务
pipe.multi()
pipe.set(key, new_balance)
# 提交事务,如果键已被修改则抛出 WatchError
pipe.execute()
break
except redis.WatchError:
# 键被其他客户端修改,重新读取并重试
continue
update_balance(1, 100)
在pipe.watch(key)之后,先读取当前余额并计算新值,然后通过pipe.multi()开启事务,写入新值后提交。如果在读取后、提交前,另一个客户端修改了该余额键,execute()会抛出WatchError异常。代码捕获异常后进入下一轮循环,重新读取最新值并再次尝试提交,直到成功。
如果不再需要监控,可以调用UNWATCH主动取消所有被监控的键。需要注意的是,EXEC或DISCARD执行后,事务也会自动取消对所有键的监控,无需额外调用UNWATCH。
三、Java代码调用Redis事务
在Java程序中,可以使用Jedis客户端操作Redis事务。Jedis提供了Transaction对象,通过jedis.multi()获取。事务对象同样采用命令入队模式,调用set、decrBy等方法时命令不会立即发送执行,只有调用exec()后才会批量提交。
以下代码演示了在Jedis中开启事务、写入订单状态、扣减库存并设置过期时间:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import java.util.List;
public class RedisTxDemo {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
// 开启事务
Transaction tx = jedis.multi();
tx.set("order:1001:status", "paid");
tx.decrBy("inventory:sku:200", 1);
tx.expire("order:1001:status", 3600);
// 执行事务
List<Object> results = tx.exec();
for (Object result : results) {
System.out.println(result);
}
jedis.close();
}
}
执行上述代码后,exec()会返回一个List<Object>,其中每个元素依次对应事务内命令的执行结果。第一个结果是OK,第二个结果是扣减后的库存数量,第三个结果是1表示过期时间设置成功。如果事务因WATCH冲突而放弃,exec()会返回null,程序需要根据返回值进行重试或提示冲突。
如果需要放弃已入队但尚未执行的命令,可以调用tx.discard()。下面的代码先开启事务并写入临时键,随后放弃执行:
Transaction tx = jedis.multi();
tx.set("temp:key", "value");
String result = tx.discard();
System.out.println(result);
调用discard()后,队列被清空,事务模式结束,Redis不会执行SET temp:key value这条命令。这在程序中发现前置条件不满足时非常有用,可以避免无效写入。
四、Redis事务局限与替代方案
Redis事务与关系型数据库事务最明显的差异在于缺少回滚能力。当事务中存在运行时错误时,错误命令之前的命令不会被撤销。例如下面这个事务先写入一个字符串,再对同一个键执行自增:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET foo bar QUEUED 127.0.0.1:6379> INCR foo QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (error) ERR value is not an integer or out of range
最终结果中,第一条SET成功执行,第二条INCR返回错误,但foo键的值仍然被设置为bar。这说明Redis事务不会因为单个命令失败而影响其他命令,也不能回滚已经生效的修改。因此,不能把Redis事务当成数据库事务来依赖其原子回滚特性。
对于需要条件判断、循环或错误处理逻辑的原子操作,使用Lua脚本是更好的选择。Lua脚本会被Redis服务端整体执行,执行期间不会被其他命令插入,可以保证真正的原子性。例如以下Lua脚本实现了余额扣减操作,只有余额充足时才执行扣减:
local balance = redis.call('GET', KEYS[1])
if balance and tonumber(balance) >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
else
return 0
end
该脚本在服务端完成读取、条件判断和写入,整个流程原子执行,不会出现并发窗口。相比之下,Redis事务更适合命令数量固定、无需根据中间结果判断是否执行后续命令的场景。WATCH加事务的模式虽然能实现乐观锁,但逻辑复杂时重试代码较为繁琐,而且可能出现多次重试才能成功的情况。
综合来看,如果只是把若干个相互独立的写命令批量提交,使用MULTI/EXEC完全够用;如果涉及读取后根据结果决定写入,并且希望保持原子性,优先选择Lua脚本;如果业务允许提交失败重试,并且需要在并发环境下保证不被覆盖,可以使用WATCH配合事务。理解每种方式的边界,可以避免在项目中过度依赖Redis事务而产生数据一致性问题。