面试题答案
一键面试SETNX 与 EXPIRE 组合实现分布式锁产生性能瓶颈的根本原因
- 单线程模型角度
- 操作非原子性:SETNX 用于尝试设置锁,如果键不存在则设置成功返回 1,否则返回 0。而 EXPIRE 用于给设置的锁键设置过期时间,防止死锁。在 Redis 单线程模型下,这两个操作不是原子的。例如,在高并发场景中,一个客户端执行 SETNX 成功获得锁,但还未来得及执行 EXPIRE 时,进程崩溃,这个锁就会一直存在,导致其他客户端无法获取锁,造成性能瓶颈。
- 顺序执行开销:虽然 Redis 是单线程按顺序处理命令,但多个客户端竞争锁时,SETNX 和 EXPIRE 这两个命令的顺序执行会增加额外的时间开销。因为每个客户端都需要等待前一个客户端执行完这两个命令,才能开始自己的尝试获取锁操作,这在高并发下会显著降低系统的吞吐量。
- 数据结构角度
- 简单字符串结构限制:Redis 中锁通常以简单字符串类型存储。SETNX 只是基于简单的键值对存在性判断来设置锁,没有针对锁的复杂操作优化。当需要处理更复杂的锁逻辑(如锁续约、公平锁等)时,简单的字符串结构无法高效支持,可能需要额外的操作来模拟这些功能,从而增加性能开销。
- 网络通信角度
- 多次往返开销:SETNX 和 EXPIRE 是两个独立的命令,在网络通信中,客户端需要与 Redis 服务器进行两次往返通信。在高并发且网络延迟较高的情况下,这种多次往返会极大地增加获取锁的时间,降低系统的响应速度,成为性能瓶颈。
从 Redis 本身设计层面避免这些问题的方法
- 使用 SET 命令的原子操作
- Redis 2.6.12 版本后,SET 命令增加了可选参数,可以通过
SET key value NX EX seconds
这样的方式,将 SETNX 和 EXPIRE 合并为一个原子操作。这样既避免了操作非原子性带来的问题,又减少了网络往返次数,提高了获取锁的效率。
- Redis 2.6.12 版本后,SET 命令增加了可选参数,可以通过
- 引入 Lua 脚本
- 复杂锁逻辑封装:可以将锁的获取、释放以及续约等复杂逻辑封装在 Lua 脚本中。因为 Redis 执行 Lua 脚本是原子的,这样可以在保证操作原子性的同时,减少命令的多次执行开销。例如,对于锁续约逻辑,可以在 Lua 脚本中判断锁的持有者是否是当前客户端,然后再进行续约操作,避免了多命令执行可能产生的竞争问题。
- 减少网络通信:客户端只需一次将 Lua 脚本发送到 Redis 服务器执行,而不是多次发送不同的命令,减少了网络往返次数,提高了性能。
- 数据结构优化
- 使用 Redisson 等框架优化结构:Redisson 是一个基于 Redis 实现的分布式协调框架,它在 Redis 简单数据结构基础上进行了封装和优化。例如,Redisson 使用 Hash 数据结构来存储锁的相关信息,不仅可以存储锁的状态,还可以存储锁持有者的标识、锁的过期时间等更多元信息,支持更复杂的锁逻辑,如公平锁、读写锁等,提升了分布式锁在复杂场景下的性能。