一、Redis 如何实现异步队列?常见方式及优缺点是什么?
思考理解:异步队列的核心是搭建"生产者存消息、消费者取消息"的流程,Redis 借助不同数据结构的特性,适配不同场景下的队列需求。选择时主要看两个关键点:一是消息是否需要长久保存,二是消息要发给一个消费者还是多个消费者。
(1)基于 List 结构:用 lpush 存消息、rpop/brpop 取消息
List 是 Redis 里按顺序存储数据的结构,就像一条"排队的队伍",从队尾存、队头取,完全符合队列"先进先出"的规则。这里的两个取消息方式,对应不同的消费逻辑。
生活例子:小区门口的"生鲜快递暂存柜"。
快递员(生产者)送生鲜快递时,会按送达顺序把快递放进暂存柜的格子里(相当于用 lpush 把消息存进 List),比如先放1单元张阿姨的虾、再放2单元李叔叔的牛肉、最后放3单元王奶奶的蔬菜。住户(消费者)取快递时,有两种情况:
• 如果暂存柜里有快递(队列有消息),住户直接从最里面的格子拿(用 rpop 取消息),先拿王奶奶的蔬菜、再拿李叔叔的牛肉、最后拿张阿姨的虾,和快递员存放的顺序反过来,保证"先到的快递先被取走";
• 如果暂存柜空了(队列没消息),住户要是每隔5分钟来查看一次(对应 rpop 轮询),既浪费时间,也相当于反复问 Redis"有没有消息",会增加 Redis 的CPU消耗;但如果住户跟快递柜管理员说"有我的生鲜快递了就打电话叫我"(对应 brpop 阻塞),管理员看到新快递就通知住户,住户不用跑冤枉路,Redis 也不用被频繁查询。
这种方式的优点很明显:一是消息能长久保存,只要 Redis 没出问题,暂存柜里的快递(消息)就不会丢,就算住户暂时没在家,回来还是能拿到;二是操作简单,不用额外配置,新手也能快速上手。但缺点也突出:只能"一对一消费",一个快递只能被对应的住户取走,不能让多个住户同时拿同一个快递(比如张阿姨的虾不能同时被张阿姨和李叔叔取),而且没有"取件确认"------如果住户拿了快递没告诉快递员,快递员不知道要不要补送,Redis 也不知道要不要重新发消息。
(2)基于 Pub/Sub 结构:发布-订阅模式
Pub/Sub 更像"广播喇叭",生产者把消息发到一个"特定频道",所有订阅了这个频道的消费者,都能同时收到消息,相当于"一条消息,多个接收者"。
生活例子:公司的"部门工作群通知"。
行政(生产者)要发"周五下午2点全体员工体检"的通知,会把消息发到"技术部工作群"(对应频道)。所有在这个群里的技术部员工(订阅者),打开微信就能看到通知;但如果有员工当天请假没看微信(订阅者离线),等周一再看时,这条通知已经被新消息刷下去了(消息不保存),就会错过体检时间。再比如小区的"楼道广播",物业喊"今晚8点停水,请提前蓄水",所有在家的住户都能听到,但出门的住户就听不到了,也没有地方"回放"广播内容。
这种方式的优点是支持"一对多分发",一条消息能让所有订阅者收到,适合需要同步通知多个对象的场景。而且它很轻量,不用保存消息,占用的内存少,适合实时性要求高但不要求消息必须被收到的场景。但缺点也很致命:消息不持久化,订阅者一旦离线就会丢消息;没有消息确认机制,生产者不知道消费者有没有收到,比如行政不知道员工们有没有看到体检通知;也不能回溯历史消息,消费者只能接收实时消息,像广播不能回放一样。
两种方式的场景选择
如果业务需要消息可靠保存、只能一个消费者处理,比如"用户下单后发送短信通知"------一个订单对应一条短信,不能丢也不能重复发给别人,就用 List 结构;如果业务需要广播消息、不介意偶尔丢消息,比如"系统日志实时推送给监控平台"------日志丢几条影响不大,多个监控系统需要同时接收,就用 Pub/Sub。但要注意,核心业务比如订单支付、库存扣减,不建议用 Redis 队列,最好用专业的消息队列(比如 RabbitMQ、Kafka),因为它们有更完善的消息保存、确认和重试机制,能保证业务稳定。
二、如何用 Redis 实现延时队列?原理是什么?举生活例子说明场景
思考理解:延时队列的核心是"消息不马上处理,要等指定时间到了再触发"。Redis 没有专门的延时队列功能,但可以利用有序集合(ZSet)的"按分数排序"特性来实现------把"消息触发时间"当作排序依据,让 Redis 自动按时间顺序排列消息,到点后再把消息筛选出来处理。
实现原理
整个流程分三步:
第一步是存消息:用 Redis 的有序集合存储消息,把要处理的"消息内容"(比如订单ID)当作有序集合里的"存储项",把"消息要触发的时间戳"(比如30分钟后的时间)当作"排序分数",通过命令把这两者存进有序集合;
第二步是查消息:后台启动一个定时任务,比如每5秒执行一次,去查询有序集合里"排序分数小于等于当前时间戳"的消息------这些就是"到点该处理的消息";
第三步是处理消息:把查到的消息取出来,执行对应的业务逻辑(比如取消订单、发提醒短信),执行完后再从有序集合里删掉这条消息,避免下次查询时重复处理。
生活例子1:外卖订单超时未支付自动取消
比如用户在中午12:00点了一份"鱼香肉丝盖饭",但一直没付款。平台规则是"30分钟内未支付,自动取消订单",这个需求就可以用 Redis 延时队列实现:
• 存消息:用户下单时,系统把"订单ID:789"作为要存储的内容,把12:30对应的时间戳(比如转换为数字是1696394400)作为排序分数,存进名为"order:delay"的有序集合里;
• 查消息:后台的定时任务每5秒查一次这个有序集合,看看有没有到点的消息。到了12:30,当前时间戳等于1696394400,就会把"订单ID:789"这条消息查出来;
• 处理消息:系统调用"取消订单"的接口,一方面给商家发"订单已取消"的通知,避免商家误做餐;另一方面给用户发"订单超时未支付已取消"的短信提醒;最后从有序集合里删掉"订单ID:789",防止下次再查出来重复取消。
如果用户在12:25付款了,系统会提前从有序集合里删掉"订单ID:789",到12:30时,这条消息就不会被查出来,相当于"订单正常支付,不用取消"。
生活例子2:快递驿站超时未取件提醒
现在很多快递会放在驿站,驿站规则是"24小时内未取件,发短信提醒用户",这个需求也能用延时队列实现:
• 存消息:快递员把快递放进驿站时,系统把"快递单号:YT123456"作为存储内容,把"当前时间+24小时"对应的时间戳作为排序分数,存进有序集合;
• 查消息:定时任务每10秒查询一次,当时间到了"存消息时的时间+24小时",就会查出这条快递消息;
• 处理消息:系统给用户发短信"您的快递单号YT123456已在驿站存放24小时,请尽快取件",然后删掉这条消息。
如果用户在24小时内取走了快递,驿站工作人员会在系统上点击"已取件",系统就会把对应的消息从有序集合里删掉,后续也不会再发提醒。
关键注意点
一是定时任务的频率要合适:不能太频繁,比如每秒查一次,会频繁访问 Redis 增加压力;也不能太稀疏,比如每分钟查一次,会导致消息处理延迟------比如59秒时到点的消息,要等1分钟后才被查到,可能错过最佳处理时间。一般根据业务能容忍的延迟来设,比如允许5秒延迟就每5秒查一次,允许10秒延迟就每10秒查一次。
二是要避免消息重复处理:如果定时任务同时启动了多个实例(比如两台服务器都在查消息),可能会同时查到同一条消息,导致重复执行(比如同一个订单被取消两次)。解决办法是给消息加"分布式锁"------取出消息时先尝试"占锁",抢到锁的实例才执行处理,执行完释放锁,没抢到锁的实例就跳过这条消息。
三是要开启 Redis 持久化:如果 Redis 突然宕机,有序集合里的消息会丢失,比如外卖订单的延时消息丢了,会导致超时订单不取消,商家可能会误发货。所以要开启 Redis 的 RDB 或 AOF 持久化,让消息能保存在磁盘上,Redis 重启后能恢复消息。
三、Redis 支持事务吗?其事务机制与 MySQL 事务有何核心区别?举生活例子对比
思考理解:Redis 支持"简化版的事务",但和 MySQL 那种"严格满足ACID特性"的事务完全不同。Redis 事务的核心是"把多条命令打包,一次性按顺序执行,中间不被其他命令打断",但它不支持回滚,也不能保证数据的严格一致性,更适合对事务要求不高的场景。
(1)Redis 事务的实现方式
Redis 事务靠三个步骤完成:
第一步是启动事务:用命令标记事务开始,之后输入的所有命令都不会立即执行,而是暂时存在"事务队列"里,Redis 会返回一个"已排队"的提示,表示命令已经放进队列了;
第二步是添加命令:比如执行存值、删值、自增等操作,这些命令都会排队等着,不会马上生效;
第三步是执行事务:用命令标记事务结束,Redis 会一次性执行队列里的所有命令,按顺序返回每个命令的执行结果。
因为 Redis 是单线程工作的,一旦开始执行事务里的命令,就会独占这个线程,直到所有命令都执行完,中间不会插入其他客户端的命令,所以能保证"命令顺序执行,不被打断"。
(2)生活例子:超市结账对比两种事务机制
假设你去超市买三样东西:牛奶(12元)、面包(6元)、鸡蛋(10元),结账时的不同处理方式,正好对应 Redis 和 MySQL 的事务逻辑。
场景1:Redis 事务的处理逻辑
收银员(对应 Redis)看到你拿了三样东西,说"先把东西放这儿,我一会儿一起扫"(启动事务),然后把牛奶、面包、鸡蛋依次放进"待扫区"(命令入队)。
• 扫牛奶时:条码很清晰,一下子就扫成功了(命令执行成功);
• 扫面包时:面包袋上的条码被蹭脏了,扫了好几次都没扫出来(命令执行失败,比如给一个字符串做自增操作);
• 扫鸡蛋时:条码没问题,顺利扫成功了(命令继续执行);
最后收银员把牛奶和鸡蛋的金额加起来(12+10=22元),让你付22元(执行事务),完全没管面包扫失败的事------这就是 Redis 事务的特点:即使中间有命令失败,后面的命令还是会继续执行,不支持回滚。
场景2:MySQL 事务的处理逻辑
如果把"结账"看作 MySQL 事务(比如电商下单时"扣库存+减余额+生成订单"的操作),情况就完全不一样了。
假设你用手机支付,需要做三件事:① 扣减超市里牛奶、面包、鸡蛋的库存;② 扣减你账户里的28元(12+6+10);③ 生成你的购物订单。
• 第一步扣库存:牛奶和面包的库存顺利扣减,但鸡蛋的库存不足(扣减失败);
• 这时 MySQL 会触发"回滚":把已经扣减的牛奶、面包库存加回去,你账户里的钱也不扣了,订单也不生成,相当于"这次结账根本没发生过";
最后收银员会告诉你"鸡蛋没货了,要么换其他东西,要么重新选"------这就是 MySQL 事务的特点:要么所有命令都成功,要么都失败,支持回滚。
(3)两种事务的核心区别
从原子性来看:Redis 事务的原子性只体现在"命令顺序执行不被打断",就算中间有命令失败,后面的命令还是会继续执行,不能回滚;而 MySQL 事务的原子性是"严格的全量执行或全量回滚",只要有一条命令失败,之前执行的命令都会撤销,恢复到事务开始前的状态。
从一致性来看:Redis 事务不能保证数据的严格一致,比如前面的例子,面包没扫成功但还是结了其他两样东西的钱,数据是"部分正确";而 MySQL 事务能保证一致性,要么所有操作都完成(库存扣减、余额减少、订单生成),要么所有操作都不完成,数据不会出现"部分正确"的情况。
从隔离性来看:Redis 因为是单线程,事务执行时不会被其他命令打断,天然具备隔离性,不用担心并发问题;而 MySQL 支持四种隔离级别,需要通过配置来解决脏读、不可重复读、幻读等并发问题,隔离性更灵活但也更复杂。
从持久性来看:Redis 事务的持久性依赖 Redis 本身的持久化方式(RDB 或 AOF),如果没开启持久化,事务执行完后 Redis 宕机,数据就会丢失;而 MySQL 事务的持久性靠数据库的日志(比如 InnoDB 的 redo 日志)保证,只要事务执行成功,就算数据库宕机,重启后也能恢复数据。
从适用场景来看:Redis 事务适合"多条命令需要顺序执行、不要求严格回滚"的场景,比如批量给用户加积分、批量更新商品标签;MySQL 事务适合"数据一致性要求高"的场景,比如转账(A的钱减少、B的钱增加,必须同时成功或同时失败)、电商下单(库存、余额、订单必须同步处理)。
(4)Redis 事务的两个特殊情况
第一种是语法错误:如果事务队列里有"命令写错"的情况(比如把"存值"命令写成"存存值"),执行事务时,Redis 会直接拒绝执行整个事务,所有命令都不执行------这是 Redis 事务唯一会"全失败"的情况。比如你让收银员扫牛奶时说"扫牛奶"(正确命令),扫面包时说"扫扫面包"(语法错误),收银员会直接说"你说的我听不懂,这次不结账了",所有东西都不扫。
第二种是逻辑错误:如果命令语法正确,但逻辑有问题(比如给一个字符串类型的键做自增操作),执行事务时,这条有问题的命令会失败,其他命令正常执行。比如你让收银员扫牛奶(正确),扫面包时说"把面包的价格改成负数"(语法正确,但超市规定不能改负数,逻辑错误),收银员会扫完牛奶,然后告诉面包改价失败,最后让你付牛奶的钱,面包不结算。
四、Redis 中 Lua 脚本的作用和优势是什么?举生活场景说明其实际用途
思考理解:Lua 是一种轻量级的脚本语言,Redis 内置了 Lua 解释器,允许我们把多条 Redis 命令写成一个 Lua 脚本,发送到 Redis 后"一次性原子执行"。Lua 脚本解决了 Redis 事务"不支持回滚"和"多命令并发安全"的问题,还能减少网络传输的次数,是 Redis 实现复杂逻辑的核心工具。
(1)Lua 脚本的核心优势
第一个优势是原子执行:脚本一旦开始执行,Redis 会暂停处理其他客户端的命令,直到脚本执行完------这样能避免"多条命令执行过程中被其他命令打断"的并发问题,比如"查库存+扣库存"的操作,不会出现"两个用户同时查到有库存,都去扣减导致超卖"的情况。
第二个优势是减少网络开销:原本需要分多次发送的命令(比如先查库存、再判断库存、最后扣库存),现在打包成一个脚本,一次发送到 Redis,不用多次在客户端和 Redis 之间往返,节省了网络传输的时间,尤其是客户端和 Redis 不在同一台服务器时,优化效果更明显。
第三个优势是可定制化:能编写复杂的逻辑,比如多条件判断、循环等,实现 Redis 原生命令没有的功能。比如"只有库存大于10的时候才扣减,否则返回库存不足""扣减库存后如果库存为0,就把商品标记为售罄",这些逻辑用 Redis 原生命令很难实现,但用 Lua 脚本就能轻松做到。
第四个优势是可复用:脚本可以存入 Redis 中,后续用脚本的ID调用,不用每次都发送完整的脚本内容,进一步减少网络传输的数据量。比如把"扣库存"的脚本存入 Redis,下次用的时候只需要发送脚本ID,不用再发整个脚本的代码。
(2)生活例子1:网红奶茶店"限量秒杀"(解决超卖问题)
某网红奶茶店每天10点限量发售200杯"草莓奶盖茶",顾客通过小程序下单,需要做两件事:一是查当前库存是否还有剩余,二是如果有剩余就扣减1杯库存。
如果不用 Lua 脚本,分两次发送命令,很容易出现"超卖":
• 顾客A和顾客B同时下单,A先查库存:还剩1杯(足够);
• 还没等A扣库存,B也查库存:也看到还剩1杯(足够);
• A扣库存:1杯→0杯;B扣库存:0杯→-1杯;
• 最后原本200杯的限量,卖了201杯,出现超卖------因为"查库存"和"扣库存"是两次命令,中间被B插队了。
用 Lua 脚本把这两步打包成一个脚本,发送到 Redis 后会原子执行:
脚本里先查询库存是否存在,如果不存在就返回"库存不存在";如果存在,再判断库存是否大于0,不够就返回"库存不足";足够的话就扣减1杯库存,并返回扣减后的库存。
这样一来,顾客A和B同时发送脚本,Redis 会先执行A的脚本:查库存有1杯,扣减为0,返回0;再执行B的脚本:查库存有0杯,返回"库存不足"------不会出现同时查库存的情况,彻底解决超卖问题。就像顾客直接对店员说"帮我看看还有没草莓奶盖茶,有的话给我一杯",店员一次做完"查库存+拿奶茶",不用顾客先问"有吗",再下单"要一杯",中间被别人插队。
(3)生活例子2:电影院退票扣手续费(复杂逻辑定制)
电影院有个退票规则:退票时要先查是否已出票(未出票才能退),再查购票时间距离开场的时间(距离开场24小时以上免手续费,24小时内扣10%手续费,2小时内不允许退),最后根据规则处理退款。这个逻辑用 Redis 原生命令很难实现,但用 Lua 脚本可以轻松搞定:
• 用"ticket:status:123"存123号票的状态(0=未出票,1=已出票);
• 用"ticket:buyTime:123"存123号票的购票时间戳;
• 用"user:balance:456"存购票用户456的账户余额(退款会退到这里);
脚本里的逻辑是:先查票的状态,已出票就返回"不能退";未出票的话,计算当前时间和购票时间的差,小于2小时就返回"不允许退";2-24小时之间就扣10%手续费,把剩余的钱退到用户余额;24小时以上就全额退款------整个过程原子执行,不会出现"查完状态后,票突然被改成已出票""算完手续费后,时间又过了2小时"的情况。
如果不用 Lua 脚本,需要分5次发送命令(查状态、查购票时间、算时间差、算手续费、退余额),中间可能被其他命令打断(比如另一个线程同时修改票的状态),导致数据错误;用 Lua 脚本一次性原子执行,既保证逻辑正确,又减少网络传输。
(4)Lua 脚本的注意事项
一是脚本不能太长:如果脚本执行时间超过 Redis 设定的"脚本超时时间"(默认5秒),Redis 会给脚本发"终止信号",但不会强制停止(怕强制停止导致数据不一致),所以脚本要尽量简洁,避免循环耗时的操作,比如不要在脚本里写"循环10万次"的逻辑。
二是避免死循环:如果脚本里写了死循环(比如"一直循环不结束"),会导致 Redis 线程被占用,所有命令都无法执行,这时候需要手动用命令终止脚本。
三是注意数据类型兼容:脚本中要注意 Redis 返回值的类型,比如"查值"命令返回的是字符串,"自增"命令返回的是数字,避免把字符串直接当数字用,导致计算错误。
五、什么是 Redis 管道(Pipelining)?它解决了什么问题?举生活例子对比传统方式与管道方式
思考理解:Redis 管道是优化"多命令执行效率"的核心方案,本质是"客户端把多条命令打包,一次性发送给 Redis,Redis 执行完所有命令后,一次性返回所有结果"------核心解决"多次通信带来的往返时间开销"问题。
(1)先搞懂:什么是往返时间(RTT)?
往返时间(RTT)是"客户端发送请求到 Redis,Redis 处理完请求并返回结果"的总时间,包括网络传输的时间和 Redis 处理的时间。比如客户端在杭州,Redis 在北京,一次往返时间可能要60毫秒------如果要执行10条命令,传统方式需要10次往返,总时间就是600毫秒;用管道一次发送,只要1次往返,总时间就是60毫秒,效率提升10倍。
(2)生活例子:公司给部门送节日福利(对比两种方式)
假设你是公司行政,要给8个部门各送一箱水果,公司到每个部门的往返时间是20分钟(相当于 RTT),每个部门接收水果需要5分钟(相当于 Redis 处理时间)。
传统方式(无管道):送完一个部门再送下一个
• 第一次:拿1箱水果去技术部,往返20分钟,接收5分钟,总共25分钟;
• 第二次:拿1箱水果去产品部,同样25分钟;
• 以此类推,8个部门总共要8×25=200分钟(3小时20分钟)。
对应 Redis 传统方式:执行8条命令,每次发送1条命令,等结果返回后再发下1条,总时间=8×(往返时间+处理时间),往返时间占了大部分,效率很低。
管道方式:一次带8箱水果送完所有部门
• 先把8箱水果装上车,按部门顺序规划好路线;
• 一次出发,先到技术部(接收5分钟)、再到产品部(接收5分钟)......最后到行政部(接收5分钟);
• 送完所有部门后,一次性把8个部门的"接收确认单"带回公司;
• 总时间=往返20分钟 + 8×5分钟=60分钟(1小时),比传统方式节省140分钟。
对应 Redis 管道方式:把8条命令打包,一次发送给 Redis,Redis 处理完8条命令后,一次性返回8个结果,总时间=1×往返时间 + 8×处理时间------因为往返时间是大头,所以总耗时大幅减少。
(3)Redis 管道的核心优势
第一个优势是减少往返时间次数:从"命令数量×往返时间"减少到"1×往返时间",尤其是客户端和 Redis 不在同一地域(比如客户端在上海,Redis 在广州)时,往返时间更长,管道的优化效果更明显。
第二个优势是减少上下文切换:每次网络读写都需要"用户态和内核态之间的切换"(操作系统层面的耗时操作),传统方式10条命令要20次切换(发送10次+接收10次),管道方式只要2次切换(发送1次+接收1次),减少了操作系统的开销。
第三个优势是提升吞吐量:单位时间内能处理更多的命令,比如传统方式每秒能处理100条命令,管道方式可能处理1000条(具体取决于命令数量和往返时间的长短),尤其适合"批量执行命令"的场景。
(4)管道与事务、Lua 脚本的区别
很多人会把这三者搞混,其实它们的核心目标完全不同:
管道的目标是"优化通信效率",只负责把多条命令打包发送,不保证命令的原子性------虽然 Redis 单线程会顺序执行命令,但逻辑上不保证中间不被其他操作影响;
事务的目标是"保证命令原子性",让多条命令顺序执行不被打断,但不优化通信效率------需要分三次发送"启动事务、命令、执行事务"的请求;
Lua 脚本的目标是"既优化通信效率,又保证原子性"------多条命令打包成脚本一次发送,执行时原子不被打断,是三者中功能最全面的。
简单来说:如果只想要"执行快",不要求命令必须原子执行,就用管道;如果想要"命令原子执行",不介意通信慢,就用事务;如果既想要"快",又想要"原子性",就用 Lua 脚本。
(5)管道的实际使用注意事项
一是命令数量不宜过多:如果一次性发送10万条命令,会导致 Redis 的接收缓冲区溢出(Redis 接收命令后会先存在缓冲区,执行完再返回),反而会降低效率。一般建议每次管道发送1000-5000条命令,具体数量根据命令的大小调整------如果命令很短(比如简单的存值命令),可以多送一些;如果命令很长(比如存大段文本),就少送一些。
二是不支持实时交互:管道中不能有"依赖前一条命令结果"的命令,比如"先查A的值,再用A的值作为B的存值内容"------因为管道是一次性发送所有命令,发送时还不知道前一条命令的结果,无法动态调整后一条命令的参数。
三是结果顺序要对应:Redis 返回的结果顺序和发送的命令顺序完全一致,比如发送"存A=1、查A、给A自增",返回的结果顺序就是"存成功、1、2",客户端处理结果时要按顺序解析,不能打乱。
(6)实际场景举例:批量更新用户积分
假设你需要给1000个用户的积分各加20分,用户ID从2001到3000,对应的 Redis 键是"user:score:2001""user:score:2002"......"user:score:3000"。
• 传统方式:循环1000次,每次发送"给用户积分加20"的命令,等结果返回后再发下一个,总时间=1000×往返时间 + 1000×处理时间;
• 管道方式:把1000条"加积分"的命令打包,一次发送给 Redis,Redis 执行完后一次性返回1000个"更新后的积分"结果,总时间=1×往返时间 + 1000×处理时间。
如果往返时间是50毫秒,传统方式总耗时50×1000=50000毫秒(50秒),管道方式总耗时50毫秒,效率提升1000倍,能快速完成批量更新。
六、如何用 Redis 实现分布式锁?演进过程中的问题及解决方案是什么?举生活例子说明
思考理解:分布式锁的核心是"在分布式系统中,多个进程竞争同一个资源时,只有一个进程能拿到锁,其他进程只能等待或放弃"。Redis 实现分布式锁的本质是"在 Redis 中创建一个唯一的键,谁能创建成功谁就拿到锁,用完后删除这个键释放锁",整个演进过程都是为了解决"避免死锁"和"保证加锁操作原子性"这两个问题。
(1)V1 版本:基于 setnx 命令(最基础版)
setnx 是"只有键不存在时才创建"的意思------如果要加锁的键不存在,就创建并赋值,返回成功,代表拿到锁;如果键已经存在,就不做任何操作,返回失败,代表没拿到锁。这是 Redis 实现"抢锁"的最基础命令。
生活例子:公司茶水间的"微波炉使用锁"。
茶水间只有1台微波炉,员工要热饭时,先看微波炉上有没有贴"正在使用"的纸条(对应 Redis 里的键是否存在):
• 如果没有纸条(键不存在),员工A就贴一张纸条(用 setnx 命令创建键"lock:microwave"并赋值"using"),拿到锁,开始热饭;
• 其他员工(比如B)来的时候,看到有纸条(键存在),用 setnx 命令会失败,只能等A用完;
• A热完饭,撕掉纸条(用命令删除键"lock:microwave"),释放锁,B才能贴纸条用微波炉。
这个版本的问题很明显:如果员工A贴了纸条后,突然接到紧急电话,拿着饭匆匆离开(对应进程异常崩溃),没来得及撕掉纸条(删除键的命令没执行),微波炉上的纸条会一直在,其他员工永远用不了微波炉------出现"死锁"。
(2)V2 版本:setnx + expire(加锁时设超时,解决死锁)
为了解决死锁问题,给锁加一个"过期时间":就算拿锁的进程异常退出,超时后锁也会自动释放。具体操作是,用 setnx 命令加锁成功后,再用 expire 命令给这个键设置一个过期时间(比如10分钟),10分钟后 Redis 会自动删除这个键,释放锁。
生活例子:改进后的微波炉锁。
员工A贴纸条时,在纸条上写"10分钟后自动失效"(对应 expire 命令):
• 如果A热饭用了8分钟,用完后主动撕掉纸条(手动释放锁);
• 如果A中途离开没撕纸条,10分钟后纸条会自动掉(锁超时释放),其他员工看到没纸条了,就能贴自己的纸条用微波炉。
这个版本看似解决了死锁,但有个新问题:setnx 和 expire 是两条独立的命令,不是原子执行的------如果员工A贴了纸条(setnx 成功),还没来得及在纸条上写"10分钟后失效"(expire 命令没执行),微波炉突然断电(Redis 宕机),重启后纸条还在,但没有过期时间,还是会出现死锁。比如:用 setnx 创建了键"lock:microwave"→ Redis 宕机→ expire 命令没执行→ 重启后键存在且永不过期→ 其他员工永远拿不到锁。
(3)V3 版本:set 命令的 ex nx 参数(原子加锁+设超时,最终版)
Redis 2.8 版本专门解决了 V2 的问题,给 set 命令增加了两个扩展参数:ex 和 nx。ex 用来设置过期时间,nx 代表"只有键不存在时才创建",这两个参数和 set 命令合并成一条原子命令------执行这条命令时,"创建键"和"设置过期时间"会同时完成,不会出现"只加锁没设超时"的情况。
生活例子:微波炉的"智能贴纸"。
公司给微波炉装了一个智能贴纸,员工按一下贴纸上的"开始"键(对应执行 set 命令"set lock:microwave 'user:A' ex 10 nx"),会同时完成两件事:① 贴出"user:A正在使用"的字样(创建键加锁);② 启动10分钟的倒计时(设置过期时间)。就算按完键后微波炉突然断电,重启后倒计时依然有效,10分钟后贴纸会自动消失,不会出现死锁。
这条命令的执行结果有两种:如果返回"成功",说明拿到锁,10分钟后自动释放;如果返回"失败",说明锁已经被别人拿到,需要等一段时间再重试。
(4)实际开发中的优化:用 Redisson 框架
虽然 V3 版本的 set ex nx 已经能实现基础的分布式锁,但实际开发中还有很多细节要处理,这些细节不用自己写,Redis 官方推荐的 Redisson 框架已经封装好了:
第一个细节是锁续期:如果员工A热饭需要15分钟,10分钟后锁会超时释放,这时候别人可能会抢锁------Redisson 能实现"锁快超时还没用完时,自动续期",比如每次续5分钟,直到员工A主动释放锁。
第二个细节是可重入:员工A拿到锁后,中途去茶水间拿勺子,回来继续热饭,不需要重新抢锁(同一个进程多次拿锁)------Redisson 支持可重入锁,会记录拿锁的次数,释放时要对应次数才能完全释放。
第三个细节是公平锁:让排队等锁的员工按顺序拿锁,避免"插队"(非公平锁可能导致某些员工一直抢不到锁)------Redisson 支持公平锁,会按请求顺序分配锁,保证每个员工都有机会使用微波炉。
在代码中用 Redisson 拿锁很简单,核心逻辑是:先获取锁的实例,然后调用锁的方法拿锁,在业务逻辑执行完后,一定要在finally里释放锁,避免异常时没释放锁。
(5)分布式锁的核心注意点
一是锁的键要唯一:不同的资源要用不同的键,比如"微波炉锁"用"lock:microwave","打印机锁"用"lock:printer",避免拿错锁------如果用同一个键锁不同资源,会导致"拿了微波炉的锁,却去用打印机"的混乱。
二是锁的value建议用唯一标识:比如用 UUID 作为value,释放锁时先判断value是不是自己的,再删除键------避免"自己释放了别人的锁"。比如员工A的锁超时释放,员工B拿到锁,A回来后误删了B的锁,导致B用微波炉时被别人打断。
三是避免锁粒度过大:不要用一个"公司资源锁"锁住微波炉和打印机,应该分开用"微波炉锁"和"打印机锁"------如果员工A只需要用微波炉,却把打印机也锁住,其他员工就算不用微波炉,也用不了打印机,会降低并发效率。