Redis使用
什么是Redis
一种快速缓存数据库
- 缓存层有相关数据,则直接返回(1、2)
- 没有相关数据,则穿透查询(3,4,5)
- **熔断:**如果存储层挂掉了,则最大限度提供服务(7,8)
Memcache和Redis的区别
Memcache
- 支持简单数据类型
- 不支持数据持久化存储
- 不支持主从同步
- 不支持分片(将大数据库打碎到不同的主机上)
Redis
- 数据类型丰富
- 支持数据磁盘持久化存储
- 支持主从同步
- 支持分片
为什么Redis能这么快
- 完全基于内存,纯粹内存操作,执行效率高
- 数据结构简单,对数据操作简单,键值对查找
- 采用单线程,单线程也能处理高并发请求,串行化处理,多个客户端访问时,避免频繁的上下文切换和锁竞争,(CPU不是瓶颈),多核也可采用多实例
- 使用多路I/O复用模型,非阻塞IO,同一个线程内同时处理多个IO请求的目的联想一下NIO
从海量数据里查询某一固定前缀的key
KEYS pattern:查找所有符合给定模式的key
对线上业务的影响:
- 一次性返回所有匹配的key
- 数量过大会使服务卡顿
SCAN指令
无阻塞提取出少量key列表,不会对生产环境造成影响
SCAN cursor [pattern] [count]
Redis服务器与客户端的启动
服务器的启动:进入redis/src:
./redis-server
指定配置文件的启动:
redis-server${redis.conf}
指定端口的启动
redis-server --port${port}
客户端的启动:进入redis/src:
./redis-cli
客户端换端口连接
redis-cli -p ${port}
客户端换ip启动
redis-cli -h ${ip}
客户端待认证启动:
redis-cli -a ${password}
客户端关闭:
redis-cli shutdown
redis-cli -p ${port} -h${ip} shurdonw
Redis命令
Redis基础命令
查看系统信息(版本,连接数,主从同步CPU)
info
Keyspace切换:
select 1
清除当前、全部Keyspace
flushdb # 清除当前
flushall# 清除所有
人工触发的持久化(存入硬盘):
save
退出
quit
Redis键命令
set test test #添加test-test键值对
exists a #判断key a是否存在
ttl a #判断剩余时间
expire a 10 #设置剩余时间
setex a 10 #加入的时候设置时间
psetex d 1000 #毫秒单位
type b #返回值的类型
hest hash a a #加入哈希类型的key
rename a b #重命名
redis中的nx命令:
renamenx a b #首先判断b是否存在,如果存在就不能改名了
Redis中的数据结构
String结构
就是最常用的KV键值对,
- 可以包含任何数据,包含图像、文件
- 最基本的数据类型,二进制安全
set a a #添加
get a #获取
getrange word 0 2 #拿一段元素
getset a aa #先get 后set(可以拿到旧的值)
mset a1 a1 b1 b1 c1 c1 #一下设置多个
setnx a a #先进行判断
strlen a #判断长度
msetnx q q u u#带判断的多次操作具有原子性,必须所有的都满足条件才行
set 1 1
incr 1 #对key为1的数值类型增长1
incrby 1 100 #同上,直接增长100
decr 1 减少1
decrby 1 100 减少100
Hash结构
String元素组成的字典,适合用于存储对象,存储方式可以理解为excel表格那些
hset map name jim
hmset user:1 name Tom age 15 #写入了一个名为user:1的key 并且有两个属性
hgetall map
hmset map key1 value1 key2 value2 #在map中设置多个key与value
hsetnx map color red #为map加入属性,并且做一个判断
列表结构-list
- 列表,按照String元素插入顺序排序,越新越向前
lpush list 1 2 3 4 5 6 7 8 9 #往list中添加值
type list #类型
llen list #长度
lrange list 0 #拿一定范围
lindex list 5 #拿第五个元素
lpop list #移除第一个元素
集合结构
String元素组成的无序集合,通过哈希表实现(查找O(1)),不允许重复
sadd s1 a b c d #名为s1内容为a b c d
scard s1 #返回元素数
smembers s1 #查看元素成员,无重复
sdiff s1 s2 #查看差集
sunion s1 s2 #查看并集
srandmember s1 2 #随即返回
srem set1 a b #移除ab两个元素
spop s2 #移除并返回一个值(例如,随机订单号)
有序集合-sortedset
和上一个一样,但是关联了分数,可以自动排序,比如:学号、得分或普通消息、重要消息
zadd sortedset1 100 a 200 b 300 c
type s1
rename s1 s2
zcard s1 #查看个数
zscore s1 a #分数从小到大排序
zrank s1 a#返回索引
Redis session存储实战
对连接池进行初始化
maven引入jedis
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.6.0</version>
</dependency>
public class RedisPool {
private static JedisPool pool;//jedis连接池
private static Integer maxTotal = Integer.parseInt(PropertiesUtil.getProperty("redis.max.total","20"));
//最大连接数
private static Integer maxIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.max.idle","20"));
//在jedispool中最大的idle状态(空闲的)的jedis实例的个数
//从配置文件中找到了值,并且以防万一设置了默认值
private static Integer minIdle = Integer.parseInt(PropertiesUtil.getProperty("redis.min.idle","20"));
//在jedispool中最小的idle状态(空闲的)的jedis实例的个数
private static Boolean testOnBorrow = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.borrow","true"));
//在borrow一个jedis实例的时候,是否要进行验证操作,如果赋值true。则得到的jedis实例肯定是可以用的。否则则销毁并且不拿
private static Boolean testOnReturn = Boolean.parseBoolean(PropertiesUtil.getProperty("redis.test.return","true"));
//在return一个jedis实例的时候,是否要进行验证操作,如果赋值true。则放回jedispool的jedis实例肯定是可以用的。
private static String redisIp = PropertiesUtil.getProperty("redis.ip");
//从mmall.properties配置文件中获取这些裴矩数据
private static Integer redisPort = Integer.parseInt(PropertiesUtil.getProperty("redis.port"));
private static void initPool(){
JedisPoolConfig config = new JedisPoolConfig();
//只使用一个config实例就可以对连接池初始化
config.setMaxTotal(maxTotal);
config.setMaxIdle(maxIdle);
config.setMinIdle(minIdle);
config.setTestOnBorrow(testOnBorrow);
config.setTestOnReturn(testOnReturn);
config.setBlockWhenExhausted(true);//连接耗尽的时候,是否阻塞,false会抛出异常,true阻塞直到超时。默认为true。
pool = new JedisPool(config,redisIp,redisPort,1000*2);
//新建了连接池,并且进行了初始化
}
static{
initPool();
}
public static Jedis getJedis(){
return pool.getResource();
//从连接池里拿一个实例,jedis提供的一种方法
}
public static void returnBrokenResource(Jedis jedis){
pool.returnBrokenResource(jedis);
}
public static void returnResource(Jedis jedis){
pool.returnResource(jedis);
}
public static void main(String[] args) {
Jedis jedis = pool.getResource();
jedis.set("geelykey","geelyvalue");
returnResource(jedis);
pool.destroy();//临时调用,销毁连接池中的所有连接
System.out.println("program is end");
}
}
jedis用到的各种api
Set方法
public static String set(String key,String value){
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
//从池中拿到一个jedis实例,用于写入
result = jedis.set(key,value);
} catch (Exception e) {
log.error("set key:{} value:{} error",key,value,e);
RedisPool.returnBrokenResource(jedis);
return result;
}
RedisPool.returnResource(jedis);
return result;
}
Get方法
public static String get(String key){
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.get(key);
} catch (Exception e) {
log.error("get key:{} error",key,e);
RedisPool.returnBrokenResource(jedis);
return result;
}
RedisPool.returnResource(jedis);
return result;
}
Expire
public static Long expire(String key,int exTime){
Jedis jedis = null;
Long result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.expire(key,exTime);
} catch (Exception e) {
log.error("expire key:{} error",key,e);
RedisPool.returnBrokenResource(jedis);
return result;
}
RedisPool.returnResource(jedis);
return result;
}
Setex
public static String setEx(String key,String value,int exTime){
Jedis jedis = null;
String result = null;
try {
jedis = RedisPool.getJedis();
result = jedis.setex(key,exTime,value);
} catch (Exception e) {
log.error("setex key:{} value:{} error",key,value,e);
RedisPool.returnBrokenResource(jedis);
return result;
}
RedisPool.returnResource(jedis);
return result;
}
Redis存储Session应用场景
1.服务器端有多台服务器,不知道负载均衡到哪一台
2.服务器会重启
这就造成了session登录信息的更改,从而导致session丢失,id更改
解决方法Cookies+Session缓存:
public class CookieUtil {
private final static String COOKIE_DOMAIN = ".happymmall.com";
//这个DOMAIN又有什么用呢?
//任何以此为结尾的网址都可以看到这个cookie
aCookie.path=/Example20/;bCookie.path=/Example20/jsps/;cCookie.path=/Example20/jsps/cookie
// 访问路径是:http://localhost:8080/Example20/index.jsp
//浏览器发送给服务器的cookie 有:aCookie;
// 访问路径是:http://localhost:8080/Example20/jsps/a.jsp
//浏览器发送给服务器的cookie 有:aCookie,bCookie;
// 访问路径是:http://localhost:8080/Example20/jsps/cookie/b.jsp
//浏览器发送给服务器的cookie 有:aCookie,bCookie,cCookie。
//这是由浏览器决定的
private final static String COOKIE_NAME = "mmall_login_token";
//所谓cookie其实就是服务器发给浏览器的一小段认证字
//拿到一个cookie如果不等于空的话做一个遍历
public static String readLoginToken(HttpServletRequest request){
Cookie[] cks = request.getCookies();
//找到存储cookie的数据结构,对其中的元素进行遍历与筛选
//这个cookie结构是客户端浏览器发过来的,需要服务器自己进行判断
if(cks != null){
for(Cookie ck : cks){
log.info("read cookieName:{},cookieValue:{}",ck.getName(),ck.getValue());
if(StringUtils.equals(ck.getName(),COOKIE_NAME)){
//假如与列表里的某个cookie匹配上了
log.info("return cookieName:{},cookieValue:{}",ck.getName(),ck.getValue());
return ck.getValue();
//这是cookie的value值,相当于redis存储的key
}
}
}
return null;
}
//X:domain=".happymmall.com"
//a:A.happymmall.com cookie:domain=A.happymmall.com;path="/"
//b:B.happymmall.com cookie:domain=B.happymmall.com;path="/"
//c:A.happymmall.com/test/cc cookie:domain=A.happymmall.com;path="/test/cc"
//d:A.happymmall.com/test/dd cookie:domain=A.happymmall.com;path="/test/dd"
//e:A.happymmall.com/test cookie:domain=A.happymmall.com;path="/test"
//c,d可以使用a,e的cookies,看域名,匹配原则
//cookies domain
public static void writeLoginToken(HttpServletResponse response,String token){
Cookie ck = new Cookie(COOKIE_NAME,token);
ck.setDomain(COOKIE_DOMAIN);
ck.setPath("/");
//代表设置在根目录,根目录或子目录下的页面代码可以使用本cookie
ck.setHttpOnly(true);
//单位是秒。
//如果这个maxage不设置的话,cookie就不会写入硬盘,而是写在内存。只在当前页面有效。
ck.setMaxAge(60 * 60 * 24 * 365);//如果是-1,代表永久
log.info("write cookieName:{},cookieValue:{}",ck.getName(),ck.getValue());
response.addCookie(ck);
//将cookies加入回复的response中
}
//用于删除Cookie
//这个response对象相当于服务器返回浏览器的一个值
public static void delLoginToken(HttpServletRequest request,HttpServletResponse response){
Cookie[] cks = request.getCookies();
if(cks != null){
for(Cookie ck : cks){
if(StringUtils.equals(ck.getName(),COOKIE_NAME)){
ck.setDomain(COOKIE_DOMAIN);
ck.setPath("/");
ck.setMaxAge(0);//设置成0,代表删除此cookie。
log.info("del cookieName:{},cookieValue:{}",ck.getName(),ck.getValue());
response.addCookie(ck);
return;
}
}
}
}
}
分布式Redis
分布式Redis算法原理:
直接的hash方法
直接使用哈希除数取余获取应该在的节点,增加或删除节点后,一部分节点受到了影响
现实情况:哈希倾斜性,因为并不知道所存储数据真实的哈希分布
虚拟节点:影子节点:
随着数据变多,会越来越均匀
分布式Redis实现
对于之前的初始化类进行修改,对其提供两个ip信息,并且使用Shard(碎片)
private static void initPool(){
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(maxTotal);
config.setMaxIdle(maxIdle);
config.setMinIdle(minIdle);
config.setTestOnBorrow(testOnBorrow);
config.setTestOnReturn(testOnReturn);
config.setBlockWhenExhausted(true);//连接耗尽的时候,是否阻塞,false会抛出异常,true阻塞直到超时。默认为true。
JedisShardInfo info1 = new JedisShardInfo(redis1Ip,redis1Port,1000*2);
JedisShardInfo info2 = new JedisShardInfo(redis2Ip,redis2Port,1000*2);
List<JedisShardInfo> jedisShardInfoList = new ArrayList<JedisShardInfo>(2);
jedisShardInfoList.add(info1);
jedisShardInfoList.add(info2);
pool = new ShardedJedisPool(config,jedisShardInfoList, Hashing.MURMUR_HASH, Sharded.DEFAULT_KEY_TAG_PATTERN);
//修改为ShardedJedisPool
//MURMUR_HASH为上文提到的映射算法
}
Redis分布式锁原理
- 互斥性
- 安全性
- 死锁
- 容错:部分节点宕机
Redis分布式锁命令
setnx #先判度是否存在,然后再插入,O(1),存在返回1不存在返回0
getset #先返回旧的,然后再插入新的
expire #设置键有效期
del #删除
setnx已存在则不能设置,并且是原子操作,用来实现setnx,如果设值成功,则无其他线程。
风险:
- 来不及expire就挂掉了,key会被一直占用,造成死锁,删除操作组合不是原子的
Redis分布式锁运行流程图
setnx(lockkey,currenttime+timeout)
1.set获取锁成功
2.expire设置有效期
传入参数:key,时间戳,以及计时时间
-这种方式实际上为nx加入了时间限制
如何使用redis做异步队列
生产者-消费者模型
List+RPUSH、LPOP
缺点
- 没有等待队列里有值就直接消费
- 弥补:可以通过在应用层引入Sleep机制去调用LPOP重试
BLPOP
BLPOP key timeout:阻塞直到队列有消息或超时
缺点
- 只能让一个消费者消费,无法实现多个消费者
pub\sub:主题订阅者模式
- 发送者发送消息,订阅者接收消息
- 订阅者可以订阅任意数目的频道
缺点
- 消息发布是无状态的,无法保证可达,即发即失,消费者下线后,重新上线无法接收到消息
解决这一问题需要使用专业的消息队列,如卡夫卡
Redis持久化的方式
将内存中的数据保存到磁盘里
RDB持久化
特定时间点保存那一时刻全量信息
时间策略
900s有一次,300s有十次,60秒有10000次就进行一次备份
容错策略
备份出错的时候主进程停止响应的写入操作
压缩策略
保存的时候是否进行压缩
一个二进制文件
RDB命令(手动方式)
RDB自动触发
BGSAVE原理
父进程创建的子进程的资源指向父进程,子进程有修改时,再创建相应的空间(副本)
- 内存数据的全量同步,数据量大会由于I/O而严重影响性能
- 一定间隔时间做一次,如果有redis挂掉,则丢失数据,AOF解决
AOF备份写指令
写缓存的时间间隔
子进程计算最小命令集合
混合持久化
AOF读写时会首先读取全量数据并保存(先全量,后增)
RDB保存数据时,也会从管道读取数据
Pipeline
主从同步(多redis)
master:写
slave:读,持久化操作
全量同步
增量同步
缺点
- 不能高可用,主机挂掉之后,集群就挂了
Redis Sentinel
哨兵机制,解决主从同步master宕机后的主从切换问题:
- 监控:检查主从服务器是否运行正常
- 提醒:通过API向管理员或其他应用程序发送故障通知
- 自动故障迁移:主从切换,将从服务器升级,并且重置其他服务器,通知客户端
Redis集群
如何从海量数据中快速找到所需
- 分片:按照某种规则划分数据,分散存储再多个节点上
- 常规的按照哈希划分无法实现节点的动态增减
一致性哈希算法与数据倾斜
如上文所示
Redis分布式
Redis问题
穿透、击穿、雪崩
- 穿透:数据不存在,无论是r还是M都不存在,直接对数据库进行方位
- 击穿:缓存中没有,但数据库中有同一组数据到数据库
- 雪崩:大量不同的数据击穿到数据库
穿透
- 缓存穿透:应对数据不存在问题:尝试设置无效标志位代表数据不存在到redis,例如item id对应的数据设置为null,则每次不用查找数据库了,new Item(-1)(默认值)
- 缓存击穿:初始化时redis为空
- 解决方式1:排队,攒够一定数量之后再去mysql查找,有一个不成功则全部阻塞,复杂
- 设置内部锁,获取锁之后才可以读mysql写redis
- 解决方式1:排队,攒够一定数量之后再去mysql查找,有一个不成功则全部阻塞,复杂
- 缓存雪崩:大量数据同时失效
- 不让大量数据在同一个时间失效
redis.set(itemid,data,10+radom())
- 不让大量数据在同一个时间失效
脏读和多级缓存
数据库已经更新,而缓存没有更新,非强一致,无法避免,尽可能解决
两种方案
- 修改后台通知redis,下一次修改
- 修改后台直接修改
如何防止redis脏读
Redis缓存数据的加载可以分为懒加载和主动加载两种模式,下面分别介绍在这两种模式下的数据一致性如何处理。
懒加载
所谓懒加载就是在读取的时候进行更新,读取即更新
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存和数据库间的数据一致性问题。不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举个例子:
- 如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
- 如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。如何解决?
所以结合前面例子的两种删除情况,我们就考虑前后双删加懒加载模式。那么什么是懒加载?就是当业务读取数据的时候再从存储层加载的模式,而不是更新后主动刷新,它涉及的业务流程如下如所示:
理解了懒加载机制后,结合上面的业务流程图,我们讲解下前后双删如何做?
延迟双删
在写库前后都进行redis.del(key)操作,并且第二次删除通过延迟的方式进行。
方案一(一种思路,不严谨)具体步骤是:
1)先删除缓存;
2)再写数据库;
3)休眠500毫秒(根据具体的业务时间来定);
4)再次删除缓存。
那么,这个500毫秒怎么确定的,具体该休眠多久呢?
需要评估自己的项目的读数据业务逻辑的耗时。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
当然,这种策略还要考虑 redis 和数据库主从同步的耗时。最后的写数据的休眠时间:则在读数据业务逻辑的耗时的基础上,加上几百ms即可。比如:休眠1秒。
方案二,异步延迟删除:
1)先删除缓存;
2)再写数据库;
3)触发异步写入串行化mq(也可以采取一种key+version的分布式锁);
4)mq接受再次删除缓存。
异步删除对线上业务无影响,串行化处理保障并发情况下正确删除。
为什么要双删?
db更新分为两个阶段,更新前及更新后,更新前的删除很容易理解,在db更新的过程中由于读取的操作存在并发可能,会出现缓存重新写入数据,这时就需要更新后的删除。
双删失败如何处理?
1、设置缓存过期时间
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致。
双删失败如何处理?
1、设置缓存过期时间
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
结合双删策略+缓存超时设置,这样最差的情况就是在超时时间内数据存在不一致。
2、重试方案
重试方案有两种实现,一种在业务层做,另外一种实现中间件负责处理。
业务层实现重试如下:
主动加载
主动加载模式就是在db更新的时候同步或者异步进行缓存更新,常见的模式如下:
写流程:
第一步先删除缓存,删除之后再更新DB,之后再异步将数据刷回缓存。
读流程:
第一步先读缓存,如果缓存没读到,则去读DB,之后再异步将数据刷回缓存。
这种模式简单易用,但是它有一个致命的缺点就是并发会出现脏数据。
试想一下,同时有多个服务器的多个线程进行’步骤1.2更新DB’,更新DB完成之后,它们就要进行异步刷缓存,我们都知道多服务器的异步操作,是无法保证顺序的,所以后面的刷新操作存在相互覆盖的并发问题,也就是说,存在先更新的DB操作,反而很晚才去刷新缓存,那这个时候,数据也是错的。
读写并发:再试想一下,服务器A在进行’读操作’,在A服务器刚完成2.2时,服务器B在进行’写操作’,假设B服务器1.3完成之后,服务器A的2.3才被执行,这个时候就相当于更新前的老数据写入缓存,最终数据还是错的。
而对于这种脏数据的产生归其原因还是在于这种模式的主动刷新缓存属于非幂等操作,那么要解决这个问题怎么办?
前面介绍的双删操作方案,因为删除每次操作都是无状态的,所以是幂等的。
将刷新操作串行处理。
这里把基于串行处理的刷新操作方案介绍一下:
写流程:
第一步先删除缓存,删除之后再更新DB,我们监听从库(资源少的话主库也ok)的binlog,通过分析binlog我们解析出需要需要刷新的数据标识,然后将数据标识写入MQ,接下来就消费MQ,解析MQ消息来读库获取相应的数据刷新缓存。
关于MQ串行化,大家可以去了解一下 Kafka partition 机制 ,这里就不详述了。
读流程:
第一步先读缓存,如果缓存没读到,则去读DB,之后再异步将数据标识写入MQ(这里MQ与写流程的MQ是同一个),接下来就消费MQ,解析MQ消息来读库获取相应的数据刷新缓存。