如果实现比较简单的悲观锁。基于setnx和lua脚本,这个命令能保证了

加锁流程

set….nx这命令就是说只有当key不存在时才会插入成功,来模拟上锁流程

SET myLock uuid NX PX 10 # 设置过期时间,避免死锁

key:myLock

value:uuid 为什么不用线程ID呢,在分布式部署的情况下,线程ID会出现重复的

PX:设置过期时间,保证线程异常了无法执行解锁逻辑,锁也能自动释放

解锁流程

分为三步:拿到锁,判断是否为当前线程上的锁,释放锁

这三步不是原子性的,因此需要用lua脚本来保证原子性

如果持锁线程挂了怎么办?不会自动释放锁!

如果设置有效期,持锁线程a被阻塞,锁到期了自动释放,其他线程b获取到锁,a使用完后就会释放锁,但这个锁其实是b持有了,这就乱套了

如果添加锁业务标识,不是自己的锁就不释放,因为判断锁是否是自己的和释放锁是两个操作,如果两个操作中间出现了阻塞,仍然有错误释放的可能

那么用lua脚本保证原子性 ,这样是还不错的,但如果真出现了线程a阻塞,锁自动释放,b线程获取到锁,这就相当于临界资源可以进入多个线程了,所以我们最好是锁快过期了能自动续约时长,如果是线程挂了那就不续约了,直接走人。

就像去网吧上网,快下机了但还想玩,那就续费,如果人都直接回家吃饭了,网管肯定把你机子给其它人用了

所谓的乐观锁,其实就是基于CAS机制,即compareAndSwap。就是需要知道一个key在修改前的值,去进行

比较

redis想要实现,可以依赖于Watch命令,这个命令可以实现Watch监视的key在调用exec之前没改变时,才会

去执行后续事务任务

MULTI:开启事务

SET:在事务中添加命令

EXEC:执行事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

public class RedisOptimisticLock {
public static void main(String[] args) {
// 连接到 Redis
Jedis jedis = new Jedis("localhost");

try {
// 监视键
String key = "myKey";
jedis.watch(key);

// 模拟从数据库读取最新值
String value = jedis.get(key);
int intValue = Integer.parseInt(value);

// 开始事务
Transaction t = jedis.multi();

// 在事务中执行操作
t.set(key, String.valueOf(intValue + 1));

// 尝试执行事务
if (t.exec() == null) {
System.out.println("事务执行失败,数据已被其他客户端修改");
} else {
System.out.println("事务执行成功");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis.close();
}
}
}

常见的有三种:mysql,redis,zookeeper

zookeeper和redis分布式锁的技术对比

  • 性能:redis基于内存,zookeeper基于磁盘,所以在性能上redis> zookeeper
  • 自动释放:zookeeper加锁是基于服务端和客户端的连接来保证的,一旦连接断了,那么锁就会自动释放。死锁风险比redis少
  • 一致性和可用性要求:zookeeper本身设计理念就是CP的,它的主从数据同步是基于同步的方式。就是同步的时候主节点会被阻塞住,无法返回客户端响应,更能保证一致性;redis强调可用性,AP

总结

  • 一致性高用 zookeeper
  • 可用高 用 redis

经验之谈

  • 尽量用redis,不要用zk
  • 多实例场景下,RedLock和zk机制很像,都是通过半数以上提交来实现的
  • redis比较方便
  • 业务上做好幂等校验就行了,业务没问题就行
  • 而且用分布式锁时性能要求肯定高,如果不高的话,你直接用数据库的悲观锁就好了。没必要用分布式锁
  • 如果你实在是接受不了短暂的不一致性,重复加锁的问题,or项目强依赖于zk

ps:而且用分布式锁时性能要求肯定高???为什么??

如果让你自己来实现的话,你会怎么设计?

根据业务场景做选择吧,我目前了解的就五种做法

如果要是强一致性的话,那就是加分布式锁,但你引入锁了,性能就很低。这就很矛盾,毕竟你引入缓存是来提升性能的

无锁只能尽可能地保证一样,AP模式

同步双写

进行操作更新时,同时操作mysql or redis 。业务用得最多的方案了,适合单机。

mq异步多写

适合多机redis

定时任务

  • db做逻辑删除和updateTime处理
  • db做而是让定时任务扫描最近变动的数据,然后批量删除key

效率高,但时延时最高的

闪电缓存

  • 缓存过期时间设置很短
  • 只更新db,让缓存自己过期

时延性也还好,看你缓存过期时间的设置,

不过就是给数据库增加了压力

bin log监听

  • 用flink-cdc or canal(阿里已经停止维护了)监听mysql的bin log
  • 解析bin log

好处就是时延低,坏处就是成本高

旁路缓存+延迟双删

删完后,隔一段时间再删一次,但间隔时间很难确定

只存在理论

  1. 啥时候删,这东西很难确定
  2. 高并发不管用,你把缓存删了,然后你让数据库抗高并发

一般有三种:旁路缓存,读穿/写穿,写回策略

我们开发中用的也是第一种,其他后两种

旁路缓存策略

读策略:缓存读不到,就读数据库然后写入

写策略:先更新数据,再删缓存。

但这种只适合读多写少,且并发量较小的情况。

如果写多读少的话,会导致缓存命中率比较低。这些时候可以采用更新缓存的策略

  • 加锁,只有一个线程去更新缓存
  • 缓存时间设置小一点,即使出现缓存不一致的情况

高并发下,你删key了,那请求不就直接打到数据库了吗?

参考guava设置

对于toc来说

可以采用互斥锁,保证只有一个线程去更新数据库。其他线程拿到旧值就返回了,等那个线程写回

读穿/写穿策略

原则就是应用程序只和缓存打交道,而操作数据库是由缓存代理的

redis不支持这种功能

Write Back(写回)策略

更新缓存时,不立马写数据库,立马返回。而是异步批量更新

redis同样也不支持批量更新。

缓存穿透

恶意请求cache or db 中不存在的数据

  • 做好IP限流,黑名单校验,防止大量恶意请求
  • 做好业务判断
  • 布隆过滤器,不过这可能存在误判,但一定能保证拦截请求不存数据的请求

缓存击穿

某一热点key失效,数据库崩

  • 加互斥锁,保证缓存失效时只有一个请求去访问数据库更新缓存,但其他请求会阻塞
  • 提前预热好数据
  • 最重要的是做好降级,熔断。因为某些热点key是不可预判的,比如微博某明星热搜

PS:

缓存雪崩

大面积的热点key失效,数据库崩

  • 过期时间错开
  • 多级缓存
  • 雪崩后要做好降级限流熔断处理

四种基本:string hash list set

五种特殊:zset geo hyperloglog stream bitmaps

底层

sds

dict

ziplist

quicklist

skiplist

SDS

c语言string的增强版

  • 常数获取字符串长度
  • 杜绝缓冲区溢出
  • 内存预分配
  • 二进制安全,可存/0

string存的最大:521m

intset

dict

ziplist

压缩链表。连续的内存空间,类似于数组

每个节点都存有前一个节点的长度,用于从后往前遍历,存有当前节点长度和类型,用于从前往后遍历。

这样更能节省内存

但会出现级联更新的问题

ziplist如何实现有序性的

插入时保证有序 O(N)

  1. 插入时二分查找判断插入节点的位置
  2. 移动数组

补充:在 Redis7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了

ListPack

ListPack可看做是ziplist的升级,它每个元素没有存储前一个元素的长度,但同样支持反序遍历

skiplist

跳表,可实现log(n)查询,即多级索引的链表

思想就是空间换思想,用多级索引去减少检索的路径

为什么zset要基于skiplist实现

首先要明确zset是干什么的?无非就是有序,支持范围查询

那么像这样的数据结构,其实有很多种,数组,列表,多路搜索树Or二叉树

但数组和树结构这种,它增加元素肯定会引发那个数据结构的调整。这种对于追求性能的redis的来说,在内存操作时多余的( CPU 操作才是瓶颈,结构复杂就显得多余,浪费性能 )

而对于链表来说,它的查询性能是不行

那怎么优化,无非就是空间换时间,建二级索引,索引建得足够多,那就足够快

跳表就是这个思想

那这时候选择跳表就是一种根据场景权衡利弊出来的选择

  • 空间占用小 ListNode<TreeNode
  • 支持范围搜索
  • 算法实现简单

zset是怎么实现的?

版本

  • 3.0:ziplist + skipList
  • 4.0:listpack+skiplist

从元素size角度

  • 少时:用zipList(listPack)来紧凑存元素,节约空间
  • 多时:把ziplist(listPack)里的元素按score 顺序插入 skiplist

说人话:根据你的业务要求来,Jedis只是对redis命令进行了简单的封装,没有那么多高级特性。Redisson支持的高级特性就比较多

性能一般是基于两方面,1是计算,2是读写操作

  • 计算操作
    • 基于内存的,所以计算很快
    • 单线程,所以执行命令很快
      • 为什么?因为你单线程执行的话就不需要去加锁,加锁是很重很耗性能的
  • 读写操作,无非就是磁盘IO,网络IO
    • 磁盘IO优化:rdb持久化,会创建一个子进程(系统级别)来生成rdb文件,这样可以避免主线程的阻塞。这也是一种写入时复制的思想
    • 网络IO优化:
      • io多路复用:select epoll 可以监听多个socket连接
      • 事件派发机制:有很多不同性质的socket,redis有不同的Handler来处理这些socket事件,redis6.0使用多线程来处理这些Handler(多线程是用来处理网络请求的,命令还是由主线程来执行的)

https://share.note.youdao.com/ynoteshare/index.html?id=e20f952ed7afde4e9460c3b6db6f107e&type=note&_time=1752253213036

  • 存内存操作,比磁盘块
  • 只需要处理的数据量小
  • 合理的数据结构
  • 网络模型使用了NIO
  • 单线程操作,避免了多线程的频繁操作
  • redis内置了多种优化后的数据结构

首先,io多路复用,并不比传统的阻塞io快,它主要解决了 c10k 问题,所以,epoll 只是在有限的机器资源下,提高了服务端的并发度,使得之前花很多时间阻塞在io上的cpu的利用率提升了。

多路复用对于Redis是为了解决在有限的资源下,解决阻塞IO造成的c10k问题,对单个请求无提升,但提升了整个系统的并发度。

如果可以开无限线程,Redis的网络其实可以设计成多线程的阻塞IO

设计理念就是只需要处理的数据量小,然后用IO多路复用来提高并发,发现单线程也够用,这样足够简单还不用搞多进程处理各种锁之类的

而说单线程没有锁比多线程有锁更快,也是没有根据的。多线程肯定比单线程快,然后花精力在锁设计这块就能解决。比如nginx多进程架构。