Redis 事务实现原理 —— multi、watch、lua

77 阅读2分钟

前言

Redis 的事务模式具备如下特点:

  • 原子性:Redis 事务中的命令可以顺序执行,只有单条命令具备原子性,整体不具备原子性,不支持回滚。
  • 一致性:Redis 先检测语法错误,保证了一致性(有分歧)。
  • 隔离性:Redis 以单线程的方式执行事务,按顺序串行化一条一条执行,保证了隔离性。
  • 持久性:Redis 持久化策略 RDB 和 AOF 保证持久性。

事务实现

例如:当姓名为“xyc”且年龄为“20”的时候,将他们分别改为“neil”和“18”

127.0.0.1:6379> get name  # 查询数据
"xyc"
127.0.0.1:6379> get age  # 查询数据
"20"
# 满足修改的条件
127.0.0.1:6379> multi  # 开启事务
OK
127.0.0.1:6379> set name neil  # 添加数据
QUEUED
127.0.0.1:6379> set age 18  # 添加数据
QUEUED
127.0.0.1:6379> exec  # 执行事务
1) (integer) 1
2) (integer) 1
127.0.0.1:6379> get name  # 查询数据
"neil"
127.0.0.1:6379> get age  # 查询数据
"18"

潜在问题及规避方式:

问题1:查询判断满足修改条件后开启事务修改数据,这个过程中其他客户端对是可以 name 和 age 进行修改的。此时事务中是无感知的,最终会造成不满足修改条件的情况下也修改了。

  • 乐观锁:watch 监听key,有被修改的话就执行 discard 丢弃指令,执行 exec 后不管结果如何都会 unwatch 取消监听。实现如下:
    127.0.0.1:6379> watch name age
    OK
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> set name xyc
    QUEUED
    127.0.0.1:6379> set age 20
    QUEUED
    
    
    127.0.0.1:6379> set age 19  # 此时其他客户端对 age 进行修改成功
    OK
    
    
    127.0.0.1:6379> exec
    (nil)
    127.0.0.1:6379> get name
    "neil"
    127.0.0.1:6379> get age
    "19"
    
  • 悲观锁:判断和修改数据之前加上分布式锁,将整个代码块串行化

问题2:修改姓名和修改年龄两条命令在事务中是没法保证原子性的。有可能姓名修改成功而年龄没修改成功,这样就产生脏数据了。

  • 可以使用 lua 脚本保证原子性:[EVAL] [脚本内容] [key参数的数量] [key …] [arg …]
127.0.0.1:6379> eval "local name = redis.call('get', KEYS[1]) local age = redis.call('get', KEYS[2]) if name == ARGV[3] and age == ARGV[4] then redis.call('set', KEYS[1], ARGV[1]) local result = redis.call('set', KEYS[2], ARGV[2]) return result else return nil end" 2 name age neil 18 xyc 20
OK

题外话:lua 常用来保证自增和过期的原子性:对 key 自增并设置1秒过期

127.0.0.1:6379> eval "local num = redis.call('incr', KEYS[1]) if tonumber(num) == 1 then redis.call('expire', KEYS[1], ARGV[1]) return tonumber(num) else return tonumber(num) end" 1 key 1
(integer) 1

multi 与 pipeline 提交指令的差别

  • 缓冲位置不同:pipeline 选择客户端缓冲,multi 选择服务端缓冲。
  • 请求次数不同:multi 需要每个命令都发送一次给服务端,pipeline最后一次性发送给服务端。