Redis 作为一款高性能的内存数据库,除了为我们提供极快的读写操作外,还具备事务功能,帮助我们确保数据操作的一致性。然而,Redis 事务在某些场景下存在局限性。今天我们就来探讨 Redis 事务的工作原理和局限性,并为大家介绍同样可以实现数据一致性的补充方案。
Redis 事务
Redis 事务可以理解为一组操作的集合。事务中的所有命令要么全部执行成功,要么完全不执行。Redis 使用 MULTI 和 EXEC 命令控制事务,事务中的所有命令会按顺序依次执行,但不支持传统的回滚机制。
举个例子,假设客户端要实现用户购买商品的功能。
每次购买商品时,我们不仅要减少库存数量,还要记录用户的购买次数。用 Redis 事务的方式,我们可以把这些操作打包到一个事务里,然后交给 Redis 一次性完成:
MULTI
DECR stock:product:jacket # 库存减少 1
INCR user:purchase:Sam # 用户购买次数增加 1
EXEC
这里我们在 MULTI 和 EXEC 之间写下了想要完成的操作,最后由 EXEC 一次性执行,保证了操作的原子性。
需要注意的是, Redis 的事务无法支持客户端代码”读取并根据结果进行逻辑判断“的功能。这是因为,Redis 事务是通过命令队列方式实现的,所有命令在 MULTI 命令之后只会加入队列,直到执行 EXEC 后才会真正执行,因此在事务块内无法立即获取读取的结果。
让我们继续通过商品购买的例子来进一步说明:客户端希望在实现购买操作前检查库存状况,如果库存数量为零则购买失败。显然,单纯的事务无法一次性满足查询,修改两种需求。此时我们就需要引入 Redis 的乐观锁—— WATCH 机制,目的是确保在查询-修改过程中没有其他的并发修改。
WATCH stock:product:jacket
GET stock:product:jacket
# 判断GET返回值,如果不为0则执行事务
MULTI
DECR stock:product:jacket # 库存减少 1
INCR user:purchase:Sam # 用户购买次数增加 1
EXEC
当被监控的键(stock:product:jacket)在执行EXEC 之前被其他客户端修改时,事务会失败,一般情况下客户端需要在代码中实现循环操作以确保修改总有机会被执行。
那么有没有一种方法能避免这种多并发场景下由冲突修改造成的事务失败呢?答案就是 Lua 脚本。
Lua 脚本
Lua 是一个轻量级的脚本语言,它可以被Redis 服务端直接执行,并确保所有命令作为一个整体完成。也就是说,整个 Lua 脚本都会被 Redis 视为“单一”操作,不会被打断或插入。
现在让我们用 Lua 脚本来实现上文中的检查库存并购买商品功能:
local stock = redis.call("GET", "stock:product:jacket")
if tonumber(stock) > 0 then
redis.call("DECR", "stock:product:jacket")
redis.call("INCR", "user:purchase:Sam")
return 1
else
return 0
end
通过这种方式,我们可以让 Redis 在服务器端一次性处理所有逻辑,并通过返回值判断购买请求是否成功。Lua 脚本能够最大程度地减少网络来回的开销,但请注意不要将复杂逻辑放入 Lua 脚本中执行,以免造成阻塞。
Redis 分布式锁
与事务和 Lua 脚本不同,Redis 分布式锁是完全由客户端控制的锁,我们可以在使用某些资源的时候主动申请锁,来保证只有锁的持有者可以“独享”这些资源。
一般来说,Redis 分布式锁多用于客户端之间互斥访问,比如在我们的购买商品上增添支付功能,用户可能在不同客户端同时支付同一笔订单,我们就可以通过分布式锁完成唯一的客户端支付操作。
那么,Redis 分布式锁可不可以实现Redis数据一致性呢?答案是当然可以啦,Redis 分布式锁(RedLock)还可以解决跨多个 Redis 实例上的Redis 集群数据一致性问题。只是分布式锁会带来额外的性能开销,也需要程序员主动管理锁操作,针对简单场景的 Redis 数据一致性来说,多少有点“大材小用”了。
总结
「READING」
如果需要顺序执行一组简单 Redis 命令,可以选择 Redis 事务。它更适合简单场景和轻量级的多命令组合。
如果需要较为复杂的逻辑控制(如条件判断、循环)且希望在 Redis 端减少网络往返带来的开销,Lua 脚本是更好的选择。
分布式锁则更适合于复杂场景下的互斥资源占用或多实例的 Redis 操作。