当前位置:首页 > 问答 > 正文

Redis里头那些key之间到底咋互锁,互斥关系是怎么回事儿?

主要综合自Redis官方文档关于WATCH、MULTI/EXEC、SETNX等命令的说明,以及《Redis实战》、《Redis设计与实现》等书籍中关于事务和锁的讨论,同时参考了社区中如Stack Overflow上关于分布式锁的常见实践模式)

Redis本身是一个单线程处理命令的内存数据库,这意味着在单个Redis实例中,两条命令永远不会同时执行,听起来好像不会有什么冲突,对吧?但问题就出在,我们实际的应用场景往往是多线程或者多进程的,会有很多个客户端同时连上Redis,它们可能会一起去争抢、修改同一个key,这时候,如果没有一种机制来保证操作的顺序和独占性,就会乱套,最经典的例子就是“超卖”:商品库存只剩下1件了,结果两个请求同时来查询库存,都发现大于0,然后都执行了扣减操作,最终库存变成了负数,这显然是不对的。

我们需要一种办法,让某个客户端在对一个或多个key进行操作的时候,能够暂时地“霸占”它们,不让别的客户端插手,等它自己的操作全部完成之后,再放开,这种“霸占”的关系,就是key之间的互斥关系,而实现这种“霸占”的技术,通常就被叫做“锁”。

Redis里头那些key之间到底咋互锁,互斥关系是怎么回事儿?

在Redis里,实现这种互斥关系,主要有几种不同的思路和命令,它们适用的场景和严格程度不一样。

第一种,比较古老但现在不太推荐单独使用的方法:SETNX命令。 SETNX是“SET if Not eXists”的缩写,意思是如果这个key不存在,我就设置它,这个特性天生就适合拿来做个简单的锁,它的玩法很简单:客户端A想要操作某个资源,比如叫“order:123”,它就先尝试用SETNX命令去设置一个代表锁的key,比如叫“lock:order:123”,并给它一个值(比如客户端的标识符),如果设置成功了(返回1),那就说明客户端A成功抢到了锁,它可以放心地去修改“order:123”这个key了,操作完成后,它再通过DEL命令把“lock:order:123”这个锁key删掉,释放锁,如果客户端B也想来操作,它同样用SETNX去设置“lock:order:123”,但此时这个key已经存在了,SETNX会失败(返回0),客户端B就知道现在锁被别人占着,它要么放弃,要么过会儿再来试试。 这种方法有个大问题:万一客户端A在持有锁的时候崩溃了,或者网络断开了,它就没法执行DEL命令来释放锁了,这个锁就会永远留在Redis里,其他客户端再也拿不到锁,这就是“死锁”,为了解决死锁,可以给锁key设置一个过期时间(TTL),比如10秒,这样即使客户端A崩溃,10秒后锁也会自动释放,早期Redis版本需要组合使用SETNX和EXPIRE两个命令,这中间可能出问题,后来Redis提供了SET命令的扩展参数,可以直接一条命令SET lock:order:123 client_id NX PX 10000,意思是如果key不存在(NX)就设置,并设置毫秒级的过期时间(PX 10000),这成了现在实现分布式锁的基础。

Redis里头那些key之间到底咋互锁,互斥关系是怎么回事儿?

第二种,Redis自带的事务机制:WATCH / MULTI / EXEC。 这个方法不是传统意义上的锁,而是一种乐观锁,它不主动去“霸占”key,而是采取一种“监视”的策略,客户端A可以先使用WATCH命令,监视一个或多个它准备要修改的key,比如WATCH stock:1001,它开始一个事务(MULTI),在事务里面放入一系列命令(比如GET stock:1001,DECR stock:1001),它尝试提交事务(EXEC)。 关键在于EXEC命令执行的那一刻:Redis会检查所有被WATCH的key,从WATCH开始到EXEC执行之前,有没有被其他客户端修改过,如果任何一个被WATCH的key被碰过了,那么整个事务就会失败,EXEC返回nil,客户端A就知道有人抢先了一步,它需要重试整个逻辑(重新WATCH,重新MULTI/EXEC),如果期间所有被WATCH的key都没变,事务就顺利执行。 这种方式实现了key之间的互斥关系,但不是通过阻塞其他客户端实现的,而是通过检测冲突来保证安全,它适用于冲突不那么频繁的场景,它的互斥性是“软”的,是一种约定,如果所有客户端都遵守WATCH的规则,那么就能保证安全。

第三种,也是目前最严谨、最常用的方法:基于SET NX PX实现的分布式锁。 这就是对第一种方法的完善和标准化,它已经形成了一个成熟的模式,通常被称为“Redlock”算法(虽然Redlock特指在多实例环境下实现更强一致性的算法,但单实例下的模式是基础),其核心步骤就是前面提到的:

  1. 获取锁:使用SET lock_name unique_value NX PX timeout命令,unique_value必须是全局唯一的,通常可以用UUID或者客户端ID+线程ID,用于安全释放锁,timeout是锁的超时时间,防止死锁。
  2. 操作共享资源:获取锁成功后,执行对业务key的读写操作。
  3. 释放锁:通过Lua脚本执行操作,先检查锁的值是否还是自己设置的unique_value,如果是,才删除锁,使用Lua脚本是因为Redis保证脚本的原子性执行,可以避免判断值和删除锁两个操作之间的间隙被其他客户端钻空子。

这种方法通过在逻辑上创建一个独立的“锁key”(lock_name)来与一个或多个“业务key”建立强烈的互斥关系,只要这个锁key存在(且未过期),其他任何尝试获取同名锁的客户端都会被拒绝,从而保证了在此期间,持有锁的客户端对相关业务key的操作是独占的、安全的。

Redis中key的互斥关系,本质上是通过一个额外的、代表锁的key来控制对真正业务key的访问权限,实现这种关系的手段从简单的SETNX到乐观的WATCH,再到如今功能完备的分布式锁模式,其演进都是为了在分布式环境下,安全、高效地解决多个客户端并发访问同一数据时的冲突问题,选择哪种方式,取决于你的业务对数据一致性要求的严格程度、并发冲突的概率以及系统的复杂程度。