原子性应该是数据库不可避免的一个话题,在Redis中,它是怎么保证原子性的呢?本篇中我们来了解一下什么是原子性,Redis是怎么保证原子性的。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败

举一个比较经典的转账例子来说:A有1000元,B有1000元,A向B转500元,那么银行系统就会先从A的账户扣五百,然后在B的账户上加五百;假设银行系统在向B账户上加钱的时候宕机了,那么A损失了五百元,且B没有收到五百元,这样就有大问题了。
原子性就是说:A账户扣款和B账户加钱这两个事件是一个整体的操作,不能中断,要么全部执行成功,要么定义为全部执行失败,这样才不会发生A扣款了而B没有收到钱的问题。

Redis中几种保证原子性的方案

根据我们前面的文章可以知道,Redis是单线程的,而且是指处理网络请求只有一个线程。所以 Redis 是使用单线程串行处理客户端的请求来操作命令,当 Redis 执行某个命令操作时,其他命令是无法执行的;所以Redis的单个命令都是原子性的。

背景

Redis单个命令都是原子性的,但是那也不代表Redis没有原子性的问题。

比如说给商品设置一个商品ID,需要先向Redis查询是否已经设置了商品ID,如果没有设置就给这个商品设置一个商品ID;这期间就涉及到两个操作:查询(get)和设置(set)。

执行get和set的时候都是原子性操作,但是在高并发情况下,不保证他们会连续的执行。在Java程序中,一个线程A向Redis请求执行get命令,执行完毕后,Redis给线程A返回商品ID不存在,线程A拿到这个返回判断可以设置商品ID后,向Redis请求执行set命令;而此时线程B也在做和线程A一样的事情,也没有查询到商品ID并去执行set命令;这样商品ID可能就被设置了不止一次,每个线程都认为是自己设置的,而且商品ID是自己设置的,然而商品ID仅能被设置一次,只有一个线程是正确的。这样就会导致两个线程在后续的业务处理中产生意想不到的bug。
这里跟锁的理解很像,锁就相当于这个商品ID,只能被一个线程持有/设置,只有持有/设置锁的才能执行后续的业务处理。

那么在这种情况下,应该怎么办呢?接下来介绍几种解决方案,让get和set成为一个原子性操作,避免相关值被多次设置。

方案一:使用Redis提供的单命令方式

根据以上的情况,可以使用Redis提供的单命令方式,将getset的实现的效果通过一条setnx命令来实现。这条命令的意思是当key在Redis中不存在时,这个key才会被设置成功。

这样通过单命令的方式,再结合Redis单线程执行命令的特点,就保证了在这个业务场景中是原子性的。

方案二:加锁

通过加锁的方式去保证原子性,只有获取到了锁的线程,才能执行对应的业务。获取锁的方式跟方式一一样,通过setnx命令去设置一个固定的代表锁的key,若设置成功,则代表获取锁成功,然后执行上面的get和set操作;操作完成后,再将这个代表锁的key删除掉即可。

但是这样加锁的话需要有两个风险点:

(1)客户端在执行了setnx命令获取到锁之后,在后续的业务操作中发生了异常,没有执行删除锁的操作,导致锁一直被占用,其他线程就无法拿到锁。

解决方案:在设置锁的时候,设置一个过期过期时间,到期后自动释放锁。

(2)客户端A获取到了锁,但是业务操作太久了,导致客户端A获取的锁自动释放掉了,且这时客户端B在锁被释放掉后获取到了锁,开始他的业务操作,此时客户端A的业务执行完了,去执行释放锁的操作,就会把B获取到了锁给释放掉了,导致B的业务没有锁的保证。

解决方案:在设置锁的时候,客户端给自己设置的锁设置一个唯一值,在释放锁的时候,只释放这个唯一值对应的key。

方案三:借助lua脚本来保证

在2.6版本之中,Redis引入了Lua脚本。在上面的业务情况下,可以将多个操作写到一个 Lua 脚本中,Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。

因此也需要特别注意,Lua脚本中不要执行太多代码,最好不要写for循环语句,否则会阻塞住,严重导致Redis节点假死。