高并发下的 redis 缓存策略 - 2
超卖问题与一人一单
以下以优惠券(或者理解成门票也行)的抢购为例,列举并解决高并发下各种可能发生的情况。
优惠券的抢购规则如下:提供N张优惠券,在指定时间内可以进行抢购,一人只能抢一张,抢完为止。在一般的服务器中,该需求执行流程如下
- 向数据库查询用户是否抢到过了,抢到过就报错。
- 向数据库查询是否还有库存,没有就报错。
- 没问题就返回成功信息。
sequenceDiagram
participant t1 as Thread1
participant t2 as Thread2
participant t3 as Thread3
t1->>t1: check storage, remain 1
t2->>t2: check storage, remain 1
t3->>t3: check storage, remain 1
t1->>t1: return success, remain 0
t2->>t2: return success, remain -1
t3->>t3: return success, remain -2
可以发现,在高并发状态下,数据库中的一个数据可能同时会被多个线程同时访问,从而导致只有一个线程会读取到正确的数据,最后导致超卖问题。而如果使用正常的互斥锁,就会造成大量无用的性能消耗,严重拖慢响应速度,影响体验。
因此引入乐观锁的概念。普通的互斥锁可以视作悲观锁,因为这种锁假定线程安全问题一定发生。而乐观锁则认为线程安全问题不一定发生,只需要检查在修改数据前读取到的数据有没有发生更改。若发生了更改,说明有其他线程正在使用该关键区,则进行等待,反之则说明没有其他线程使用,可以放心修改数据。
一种比较经典的乐观锁方案叫做CAS(Compare and Swop),它的工作原理与上文一样,通过直接比较需要更改的值前后两次读取的值是否一样。但单纯的CAS方案可能会引起ABA问题:另一个线程在两次读取期间,将关键区数据由A变成B再变成A,但此时本线程仍然认为没有改变,从而进行更改。作为改进,乐观锁通常使用单独的版本号替代关键区数据。
sequenceDiagram
participant t1 as Thread1
participant t2 as Thread2
participant t3 as Thread3
Note over t1, t3: 初始状态: storage=1
par 并发检查
t1->>t1: 检查storage,剩余: 1
and
t2->>t2: 检查storage,剩余: 1
and
t3->>t3: 检查storage,剩余: 1
end
par 并发更新(存在竞态条件)
t1->>t1: 操作成功,剩余: 0
and
t2->>t2: 操作成功,剩余: -1
and
t3->>t3: 操作成功,剩余: -2
end
Note over t1, t3: 最终状态不一致<br/>出现数据竞争问题
除了ABA这个最经典的问题,在高并发场景下,CAS操作可能会频繁失败,导致线程不断重试,增加CPU开销。可以加一个自选次数的限制,超过次数就升级锁。
而在实际操作中,后一次version的判断往往在MySQL中进行,这样就保证了判断相等与改变数据的原子性——其实就是把应该原子化操作的部分扔给MySQL完成了。上图的示例演示了优惠券刚好减到0的状况,如果不是0,没抢到关键区的线程还需要再重试。
对于一人一单问题,如果单纯加一层判断,那么就可能出现和上面超卖问题中类似的问题
sequenceDiagram
participant t1 as Thread1
participant t2 as Thread2
t1->>t1: count = 0? yes
t2->>t2: count = 0? yes
t1->>t1: count++, now count is 1
t2->>t2: count++, now count is 2
因此也需要在修改的时候加锁。
若在秒杀场景下,请求仍然过多,仍然可以通过实现多种限流算法,如计数器限流、令牌桶或漏桶算法等。
分布式锁
对于存在多个后端服务器的场景,就容易出现自己jvm中线程的锁无法顾及另一个jvm的情况,因此需要将一些锁放到redis中,称为分布式锁。在redis中,有命令setnx来方便地设置类似锁的键值对。我们可以通过在Spring中操作setnx操作来获取锁,通过操作的返回值判断获取成功与否。若获取成功后再释放锁,则通过del操作解决。当然,为了解决死锁或其他问题,可以在setnx后添加ex属性,设置超时时间expire time。但是,分布式锁可能会存在一些问题。
误删锁
sequenceDiagram
participant r as Redis Lock
participant t1 as Thread1
participant t2 as Thread2
participant t3 as Thread3
t1->>+r: get lock
r->>+t1: ok
note over t1: blocked for some reason
r->>-r: TTL limit
t2->>+r: get lock
r->>+t2: ok
t1->>-t1: stop being blocked
t1->>+t1: now it begins to work
t1->>-r: release lock
r->>-r:lock released
t3->>+r: get lock
r->>+t3: ok
t3->>-t3: now it begins to work
t2->>-t2: working
r->>-r: when does it release?
本不应该释放的锁却因为其他原因释放了,导致最后有两个线程在关键区域工作。因此,创建锁的时候要给这个锁创建一个标识(可以是UUID),释放锁的时候要先查看锁是不是自己创建的,若不是自己创建的则释放失败。
原子性操作
sequenceDiagram
participant r as Redis Lock
participant t1 as Thread1
participant t2 as Thread2
participant t3 as Thread3
t1->>+r: get lock
r->>+t1: ok
note over t1: working
t1->>-r: is it the lock I settle?
r->>-t1: yes
t1->>+t1: suddenly blocked
note over t1: blocked
t2->>+r: get lock
r->>+t2: ok
t1->>t1: start working
note over t1: working
t1->>-r: release lock
r->>-r: lock released
t3->>+r: get lock
r->>+t3: ok
t3->>-t3: it begins to work
t2->>-t2: working
r->>-r: when does it release?
当判断锁的表示与释放锁不在同一个原子操作时,就可能出现上述情况。因此需要保证判断和释放锁的操作为一个原子操作。而这个只能通过操作lua脚本实现。
redis是用C语言写的,但也支持使用lua脚本语言执行批处理操作。在这个脚本中的操作redis保证实现原子性。
优化
目前,在Tomcat中,需要执行的步骤为
flowchart LR
a[查询优惠券]-->b[判断秒杀库存]-->c[查询订单]-->d[校验一人一单]-->e[减库存]-->f[创建订单]
由于写的速度比读的速度慢很多,因此可以考虑将某些业务部署到redis中实行

因此需要把库存信息和已经拿到优惠券的用户存到redis里,原子性操作也能通过lua脚本统一写好。
但是目前还存在四个问题
- 同一个线程无法多次获取同一把锁
- 目前获取锁只尝试一次就返回false,没有重试机制
- 若任务执行时间较长,会导致锁提前超时释放
- 在多集群redis中,主从的同步存在一定的延迟。当主机宕机时,可能锁会出现问题
Redisson
redisson是一个基于redis的Java客户端,在redis基础上提供了许多Java中许多分布式服务的实现,如各种分布式锁(可重入锁、公平锁、红锁、读写锁等等)。
还可以直接利用redisson中的RRateLimiter来实现分布式限流。
Redisson可重入锁原理
在redis中存入哈希类型,其中的field存储线程的标识、value部分存储重入的次数。
加锁时,首先判断锁是否存在。若不存在,则获取锁并添加线程标示,设置过期时间并执行业务。
若存在,则判断锁表示是不是自己的,若不是自己的则获取失败。
是自己的,就将锁的计数加1,并重新设置锁的有效期,执行业务。
解锁时,首先判断锁是不是自己的。若不是自己的,就说明锁已经释放过了,不用管了。
若是自己的,则将锁的计数减1,最后判断计数是否为0。若不为0,则说明还要用,重置锁有效期,执行业务。
若为0,说明已经用好了资源,可以释放锁了。
流程图如下
graph TD
StartLock[开始加锁] --> CheckLockExist{锁是否存在?}
CheckLockExist -->|不存在| AcquireLock[获取锁]
AcquireLock --> SetThreadId[添加线程标识]
SetThreadId --> SetExpire[设置过期时间]
SetExpire --> LockSuccess[加锁成功]
LockSuccess --> ExecuteBusiness[执行业务]
CheckLockExist -->|存在| CheckOwner{锁标识是否自己的?}
CheckOwner -->|不是| LockFail[获取锁失败]
CheckOwner -->|是| IncreaseCount[锁计数加1]
IncreaseCount --> ResetExpireLock[重置锁有效期]
ResetExpireLock --> LockSuccess
ExecuteBusiness --> StartUnlock[开始解锁]
StartUnlock --> CheckOwnerUnlock{锁标识是否自己的?}
CheckOwnerUnlock -->|不是| Ignore[无需处理]
CheckOwnerUnlock -->|是| DecreaseCount[锁计数减1]
DecreaseCount --> CheckCount{计数是否为0?}
CheckCount -->|不为0| ResetExpireUnlock[重置锁有效期]
CheckCount -->|为0| ReleaseLock[释放锁]
ResetExpireUnlock --> UnlockSuccess[解锁完成]
ReleaseLock --> UnlockSuccess
Ignore --> UnlockSuccess
UnlockSuccess --> End[结束]
锁重试与WatchDog机制
注意:若写明了释放锁的时间,则不会触发看门狗机制。
在redisson的trylock()方法中,在参数中设置等待时间(与释放锁时间)。在该方法运行时,除了获取等待时间,还需要获取当前时间与线程ID。若释放锁时间没有指定,则设为30秒。获取相关参数后开始尝试获取锁。尝试获取锁的操作与上文一致,即只有锁存在且不是自己的时候获取失败。若成功则返回nil。若获取锁失败,则返回锁的剩余有效期。后面执行判断,若剩余有效期为null则表示执行成功,返回true;反之则需要重试。
重试时,先用当前时间减去获取锁前获得的当前时间,再用等待时间减去这个时间差,获取剩下的等待时间。剩余等待时间小于0则结束。大于0则再次获取当前时间。然后,使用subscribe()方法,“订阅”锁释放的消息,在有锁释放的时候再启动,或者在大于剩余等待时间时取消订阅并返回false。而在成功等到时,再次获取剩余等待时间,时间有剩余时尝试获取锁。
若依然获取锁失败,则查看当前剩余时间,再次准备获取锁。但这次与上次不同,此次使用了信号量getLatch()方法,并且对剩余等待时间进行判定。取其他锁的剩余有效期与剩余等待时间的较小值作获取锁的等待时间。等好了再看看时间,若剩余等待时间不够了就返回false,足够则再重复该段操作。
获取锁成功后,为了保证业务先于锁释放执行完,需要运行额外的机制。若抛异常则直接释放。获取锁成功时(即剩余有效期为null),执行过期时间更新的方法。
该方法先往map中放入一个键值对(若不存在),键大致为锁的名称,值为一个独特的Entry对象。使用一个Entry获取插入的数据,若锁原先存在则为null,若不存在则为全新的Entry,以保证每一个锁拿到的是自己的Entry。原先存在时,只需要往Entry中放入ThreadId即可,等价为重入;原先不存在的情况下,除了要往Entry中放入ThreadId,还要更新有效时间。
在更新操作中,先拿到Entry,然后设置一个定时任务,在释放时间参数的1/3(没有指定时为10s)后拿出Entry与线程ID,然后更新有效期,最后再调用自己,从而不断更新。老线程中由于一直在执行这个操作,就不用另行执行时间更新的方法了。
而释放锁时,从map中拿出Entry,然后销毁线程ID,取消任务,最后销毁Entry本身,锁成功释放。
总之,可以归纳为以下流程
加锁流程:
- 调用tryLock()时,若指定释放时间则禁用看门狗,否则设为30秒
- 尝试获取锁:锁不存在或为自己所有则成功,否则失败返回剩余有效期
- 获取失败时进行重试:计算剩余等待时间,订阅锁释放消息,使用信号量等待
- 成功后在map中创建/更新Entry对象管理锁状态
- 未指定释放时间时启动看门狗:每10秒自动续期,防止业务未完成锁过期
解锁流程:
- 从map中获取Entry,移除线程ID
- 取消看门狗定时任务
- 销毁Entry,释放锁资源
反反复复折腾有效期,为什么不直接不设置有效期呢?这个主要是防止服务器宕机时锁还没释放,导致服务器重启时发生各种问题。
总结此处上方获取可重入锁的机制,可以做出如下的流程图
graph TD
Start[调用tryLock] --> CheckLeaseTime{是否指定释放时间?}
CheckLeaseTime -->|是| DisableWatchdog[禁用看门狗机制]
CheckLeaseTime -->|否| SetDefaultLease[设置默认30秒释放时间]
DisableWatchdog --> TryAcquireLock
SetDefaultLease --> TryAcquireLock
subgraph 尝试获取锁
TryAcquireLock{锁状态判断} -->|不存在| CreateLock[创建锁并设置线程ID]
TryAcquireLock -->|存在且为自己| ReentrantLock[重入: 计数+1]
TryAcquireLock -->|存在且为他人| ReturnTTL[返回剩余有效期]
CreateLock --> SetExpireTime[设置过期时间]
ReentrantLock --> ResetExpire[重置过期时间]
SetExpireTime --> AcquireSuccess[获取成功]
ResetExpire --> AcquireSuccess
end
ReturnTTL --> CheckWaitTime{剩余等待时间>0?}
CheckWaitTime -->|否| ReturnFalse[返回false]
CheckWaitTime -->|是| Subscribe[订阅锁释放消息]
Subscribe --> WaitNotification[等待通知/超时]
WaitNotification --> Retry[重新尝试获取锁]
Retry --> TryAcquireLock
AcquireSuccess --> InitWatchdog{是否禁用看门狗?}
InitWatchdog -->|否| StartWatchdog[启动看门狗定时任务: 每10秒续期]
InitWatchdog -->|是| SkipWatchdog[跳过看门狗初始化]
StartWatchdog --> UpdateEntry
SkipWatchdog --> UpdateEntry
subgraph 锁状态管理
UpdateEntry[创建/更新Map中的Entry] --> CheckExisting{Entry是否存在?}
CheckExisting -->|否| CreateNewEntry[创建新Entry+线程ID]
CheckExisting -->|是| UpdateExisting[更新现有Entry+线程ID]
CreateNewEntry --> ScheduleRenewal[安排定时续期任务]
UpdateExisting --> OnlyIncrement[仅增加重入计数]
end
ScheduleRenewal --> BusinessLogic[执行业务逻辑]
OnlyIncrement --> BusinessLogic
BusinessLogic --> UnlockProcess[开始解锁]
subgraph 解锁过程
UnlockProcess --> GetEntry[获取Map中的Entry]
GetEntry --> RemoveThreadID[移除线程ID]
RemoveThreadID --> CancelWatchdog[取消看门狗任务]
CancelWatchdog --> DestroyEntry[销毁Entry]
DestroyEntry --> ReleaseSuccess[锁释放成功]
end
主从一致性
什么是redis主从呢?是设置多个redis节点,一个为主节点,其他的为从节点。主节点处理所有写操作,从节点处理所有读操作,主节点会不断把数据同步到从节点。若主节点宕机,则将一个从节点转化为主节点。但不同机子之间毕竟存在延迟,就可能存在不一致的问题。要是Java应用设置了锁,还没同步到从节点,主节点就宕机了,又应该如何解决呢?
redisson的解决方案比较暴力,就是将所有的redis节点都做读写,不做主从分别,主从设置在每一个节点直接做。换句话说,就是搞多个主从集群。Java应用设置锁则将所有节点加锁,反之亦然。这种多个锁的方法称为multilock(联锁)。
Redlock
红锁用于解决单个redis示例作为分布式锁时存在的单点故障问题。红锁会尝试依次向所有redis示例获取锁,并记录成功获取的锁的数量,当数量达到minLockAmount(默认为locks.size()/2 + 1)就认为获取成功。虽然红锁存在一些争议,比如说时钟漂移问题、网络分区导致的脑裂问题,但它仍然是一个相对成熟的分布式锁解决方案。在实际应用中,可以通过重试机制来提高锁的获取成功率。
持久化
redis中将数据持久化的方式有两种:RDB (快照) 和AOF (只追加文件),二者可以同时使用
- RDB 持久化机制可以在指定时间间隔内将redis某一时刻的数据保存到磁盘上的RDB文件中,redis重启码,可以加载这个RDB文件来恢复数据。
RDB持久化可以通过save和bgsave命令手动触发(前者会阻塞redis进程,后者会fork一个子进程),也可以通过配置文件中的save指令自动触发。
除了自动触发,在主从复制,从节点第一次链接到主节点时,主节点会自动执行bgsave生成RDB文件,并将其发给从节点;在没有开启AOF的情况下执行shutdown指令时,redis也会自动保存一次RDB文件。 - AOF 通过记录每个写操作指令,并将其追加到AOF文件实现持久化。当redis执行写操作时,会将命令追加到AOF缓冲区
server.aof_buf,redis会根据同步策略将缓冲区的数据写入到AOF中。AOF文件过大时,redis会进行AOF的重写,以剔除多余的指令(如set和del的组合)。
AOF
在配置文件中设置appendonly yes即可开启AOF持久化了。
AOF在进行持久化时
append所有的写命令追加到AOF缓冲区中write将AOF缓冲区的数据写入到AOF文件中,这一步需要调用write函数,将数据写入到了系统内核缓冲区fsyncAOF依据持久化方式,向磁盘做同步操作rewrite定期对AOF文件重写,达到压缩目的
其刷盘策略分为三种,区别在于redis执行将OS缓冲区数据刷新到磁盘的系统调用fsync执行的时机。
always每次写命令都调用fsync同步到磁盘everysec每秒调用一次fsyncno不主动调用fsync,由操作系统决定
开启AOF的重写功能,可以调用BGREWRITEAOF命令手动执行,也可以配置下面两个配置项
auto-aof-rewrite-min-size触发AOF重写的阈值,默认为64Mbauto-aof-rewrite-percentage执行AOF重写时,当前AOF大小和上一次重写时AOF大小的比值。默认为100,为0时禁止自动重写
在Redis执行AOF重写期间,系统会创建一个AOF重写缓冲区,用于记录从创建子进程开始,主进程执行的所有写命令。此时,子进程会复制父进程的数据副本,遍历内存数据并生成可重建键值对的最精简指令集。同时,主进程接收的写命令不仅写入原有AOF文件,也会同步至重写缓冲区。当子进程完成重写后,主进程会将缓冲区中的命令追加到新AOF文件末尾,最后通过操作系统的rename操作以新文件替换旧AOF文件,完成整个重写过程。
由于新旧AOF文件在物理上是完全独立的,所有新的写命令会同时写入当前的(旧)AOF文件与重写缓冲区。待子进程完成新AOF文件的创建后,主进程再将重写缓冲区的内容追加到新文件。最后,通过一次原子性的 rename 操作,用新文件整体替换旧文件。这种机制确保了旧文件中的数据不会被重复写入新文件,从而避免了命令重复。
在配置文件中设置aof-use-rdb-preamble yes,可以开启混合持久化。在Redis 4.0引入的混合持久化模式下,AOF文件由前端的RDB格式快照与后端的AOF格式增量命令共同组成。启动加载时,Redis会先校验文件头的RDB部分,若校验失败则直接拒绝启动。通过校验后,服务会加载RDB快照以恢复基础数据,随后开始按AOF格式重放其后的增量命令;此过程中若指令解析出错,服务将中止加载并报错,但此前已成功加载的RDB数据依然可用。此模式结合了RDB的快速恢复与AOF的数据可靠性,但牺牲了纯AOF文件的可读性。
若redis保存的数据丢失一些也没有什么影响的话,可以选择使用RDB,若保存的数据安全性要求比较高的话,建议两种策略都开启或者开启混合持久化。不建议单独启动AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
内存管理
可以通过 redis-cli INFO memory 命令查看redis中的内存使用情况,看看是否发生内存不足的问题。可以修改redis.conf中的maxmemory数据,改变其最大内存限制。或者修改maxmemory-policy 调整内存淘汰策略。
在接近maxmemory限制时,redis会按照内存淘汰策略来决定删除哪些key来缓解内存压力
noeviction不删除任何key,直接报错allkeys-lru使用LRU算法删除最近最少使用key,更适合有时间局部性的场景allkeys-lfu使用LFU算法删除访问频率最低的key,更适合有长期访问模式的场景random随即删除一些keyvolatile-lruvolatile-lfuvolatile-ttlvolatile-random针对设置了过期时间的删除操作