Redis如何应对并发访问

Redis如果在业务中运用那么肯定需要考虑并发问题,如多个用户对同一个商品进行扣减,这时并发执行很可能导致商品数量不对,那么Redis如何来避免这些问题呢?一般分为两种解决方案分布式锁以及原子性操作。

对于分布式锁显然是可以解决上述问题的,但这不是最优因为分布式锁降低了系统的效率,还需要额外加锁解锁的操作,这里暂不讨论加锁这种方案。

那么对于原子性操作Redis如何实现呢?我们可以先思考并发访问中是对什么进行加锁呢?

以商品库存扣减为例,一般分为三个步骤

  • 读取商品库存数量
  • 将商品库存数量减一
  • 将扣减后的数量写回Redis中

这个流程简称为RMW也就是读取-修改-写回,当进行这三个中的任何一步时我们都需要保证互斥,不允许其它用户读取到商品库存的中间状态,这样才能避免并发导致的库存不正确性。

Redis事务处理

那Redis如何保证RMW的互斥性呢?大多数人这里的第一想法应该是事务,事务去保证要不都成功要不都失败,Redis中的事务怎么玩呢?

redis中提供下面四种命令来保证事务执行

  • MULTI:开启一个事务,执行后客户端可以向服务器发送任意多条命令,这些命令并不会立即执行,而是放到一个队列中,当执行EXEC命令才开始执行,当客户端处于事务状态,执行所有的命令会返回QUEUED。
  • EXEC :执行事务。
  • DISCARD :清空事务队列,并放弃事务。
  • WATCH:类似于乐观锁,当监控的值在WATCH执行后,EXEC命令执行前,修改了该值,那么redis执行当前事务将失败。

简单演示事务操作

-- 开启事务
127.0.0.1:6379> MULTI
OK
-- 会将命令加入到事务队列中,后续exec统一执行
127.0.0.1:6379(TX)> set name zhangsan
QUEUED
127.0.0.1:6379(TX)> get name
QUEUED
127.0.0.1:6379(TX)> set age 12
QUEUED
127.0.0.1:6379(TX)> DECR age
QUEUED
-- 执行事务
127.0.0.1:6379(TX)> EXEC
1) OK
2) "zhangsan"
3) OK
4) (integer) 11

丢弃事务

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set name zhangsan
QUEUED
-- 丢弃事务
127.0.0.1:6379(TX)> DISCARD
OK
-- get事务中添加的值,为空
127.0.0.1:6379> get name
(nil)

WATCH乐观锁

-- 客户端1,中添加age值为12
127.0.0.1:6379> set age 12
OK 
-- 开启监控
127.0.0.1:6379> WATCH age
OK
127.0.0.1:6379> MULTI
127.0.0.1:6379(TX)> set name zhagnsan
QUEUED
-- 同时开启客户端2,将监控的age值改为11
127.0.0.1:6379(TX)> DECR age
QUEUED
-- 事务执行失败
127.0.0.1:6379(TX)> EXEC
(nil)
127.0.0.1:6379> keys *
1) "age"
127.0.0.1:6379> get age
"11"

事务能保证原子性吗?这是不确定的,一般我们熟知的事务遇到错误就会回滚,但Redis是分情况来讨论。

事务中的错误

Redis事务中的错误分为两种

  • 事务在执行EXEC命令之前,入队命令出错,比如命令产生语法错误(参数错误、参数名错误等等),或者一些更加严重的错误如内存不足(Redis设置maxmemory最大内存限制的前提)。
  • 命令在EXEC调用失败后,举个例子,事务命令可能处理了错误类型的键,列表命令用在字符串键上。

模拟EXEC命令执行之前出现错误

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> set name zhangsan
QUEUED
127.0.0.1:6379(TX)> seta age 1
(error) ERR unknown command `seta`, with args beginning with: `age`, `1`, 
-- EXEC执行前出现异常,放弃事务
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name
(nil)

模拟EXEC命令执行之后出现错误

-- 开启事务前先设置一个String类型的键值
127.0.0.1:6379> set name zhangsan
OK
127.0.0.1:6379> MULTI
OK
-- 采用hash方式获取string类型的键值,语法没错
127.0.0.1:6379(TX)> hget name 1
QUEUED
127.0.0.1:6379(TX)> set sex 2
QUEUED
127.0.0.1:6379(TX)> EXEC
-- 执行报错
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) OK
-- 事务没有放弃,执行部分事务
127.0.0.1:6379> get sex
"2"

所以,在EXEC执行之前的错误,Redis会将事务回滚,但是EXEC执行后产生的错误,Redis不会丢弃事务。因为Redis认为在EXEC执行后发生错误一般是语法错误,这个失败是编程造成的,这些错误在开发时就应该被发现,而不是应该出现在生产环境,而且不需要对回滚进行支持所以Redis能保持简单而且快速。

综上严格意义上来讲Redis的事务不支持原子性

既然Redis事务不能支持原子性,那么还能如何保证并发一致性呢?这里还有两种方案。

Redis两种原子操作方法

单命令操作

简单来讲就是将多个操作集成到一个命令上,因为Redis单命令操作是会阻塞主线程的,也就是说是互斥的,在命令执行过程中其它命令不会执行,这种命令形如INCRDECR,例如库存扣减有三个步骤简称RMW,可以通过单命令DECR一次执行。

Lua脚本

Redis从2.6.0版本开始支持Lua脚本,Lua脚本的多样性一般是实现原子操作的最佳选择,Redis会把整个Lua脚本作为一个整体执行,在执行过程中不会被其他命令所打断,从而保证Lua脚本的原子性,如果我们存在多个操作要执行,又无法使用单命令操作实现,那么就可以试试Lua脚本。

Lua脚本简单演示

-- 形如命令 set name zhangsan
127.0.0.1:6379> EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name zhangsan
OK
127.0.0.1:6379> get name
"zhangsan"
-- 形如命令 get name
127.0.0.1:6379> EVAL "return redis.call('get',KEYS[1])" 1 name
"zhangsan"
-- 清空预加载lua脚本
127.0.0.1:6379> SCRIPT FLUSH
OK
-- 加载脚本,可以自动生成一个字符串,不需要每次传输脚本,提升传输速度
127.0.0.1:6379> SCRIPT LOAD "return redis.call('set',KEYS[1],ARGV[1])"
"c686f316aaf1eb01d5a4de1b0b63cd233010e63d"
-- 执行预加载脚本
127.0.0.1:6379> EVALSHA "c686f316aaf1eb01d5a4de1b0b63cd233010e63d" 1 age 1
OK
127.0.0.1:6379> get age
"1"
-- 判断预加载脚本是否存在
127.0.0.1:6379> SCRIPT EXISTS "c686f316aaf1eb01d5a4de1b0b63cd233010e63d"
1) (integer) 1