NoSQL+Redis
分布式数据库的CAP原理
- Consistency:强一致性、Availability:可用性、Partitition tolerance:分区容错性
- CAP只能三选二:CA:传统关系型数据库、AP:大型网站、CP: Redis、Mongodb
BASE
- Basically Available基本可用
- Soft state 软状态
- Eventually consistent 最终一致性
Redis
Remote dictionary server(远程字典服务器)是一个高性能的(key/value)分布式内存数据库,基于内存运行,并支持持久化的NoSQL数据库。具有如下特点:
1)redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启时可以再次加载进行使用;
2)redis不仅支持key/value类型的数据,还提供list,set,zset,String等数据结构的存储;
3)redis支持数据的备份,即master-salve模式的数据备份。
Redis五大数据类型及应用场景
- String 最多512M,以一种纯字符串作为value的形式存在的。value可以存储json格式、数值型等。
- String使用场景一般是存储简单的键值类型。比如用户信息,登录信息,配置信息等。
- 共享用户session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存cookie,但是可以利用redis将用户的session集中管理,在这种模式只需要保证redis的高可用,每次用户session的更新和获取都可以快速完成,大大提高效率。
- String的incr/decr操作,即自减/自增操作,调用它是原子性的,无论调用多少次,都一一计算成功。例如需要增减库存的操作。
- List 底层是一个链表,在redis中,插入list中的值,只需要找到list的key即可,而不需要像hash一样插入两层的key。list是一种有序的、可重复的集合。
- list可以使用左推、左拉、右推、右拉的方式。所以你可以使用list作为集合存储,比如存储某宝商铺里面的所有商品。
- 也可以用作轻量级别的队列来使用。左推左拉、右推右拉。需要注意的是尽管redis可以使用推拉的队列模式,但是一定要注意场景。因为redis的队列是一种轻量级别的,没有队列重试、队列重放机制。消费完队列消息在redis代表已经删除了。
- Hash String类型的field和value的映射表,hash特别适合用于存储对象。在redis中,hash因为是一个集合,所以有两层。第一层是key:hash集合value,第二层是hashkey:string value。所以判断是否采用hash的时候可以参照有两层key的设计来做参考。并且注意的是,设置过期时间只能在第一层的key上面设置。
- 购物车:以用户id为key,商品id为field,商品数量为value
- 频繁变化的对象
- 使用hash,一般是有那种需要两层key的应用场景,也可以是'删除一个key可以删除所有内容'的场景。例如一个商品有很多规格,规格里面有不同的值。
- hset key value(key value) :向Hash中存入值。
- hget key value(key) :取出Hash中key的值。
- 如果需要删除商品时,可以一次性删除'商品id'的key,则商品里面的所有规格也会删除,而不需要找到对应的规格再做处理。如果查找商品id与规格id1的商品时,则通过两个key查找即可。
- 或者查找所有商品的规格,查找商品id即可。
- 需要注意的是,经过测试,在性能上来说一般hash里面的第二层key,不要超过200个为佳。尽管hash里面的key-value能达到500多MB的存储容量。
- Set 是一种无序的,不能重复的集合。并且在redis中,只有一个key。
- 如保存一些标签的名字。标签的名字不可以重复,顺序是可以无序的。
- ZSet(Sorted Set:有序集合) 每个元素都会关联一个double类型的分数,分数允许重复
- 排行榜
Redis String的实现
Redis虽然是用C语言写的,但却没有直接用C语言的字符串,而是自己实现了一套字符串。目的就是为了提升速度,提升性能。Redis构建了一个叫做简单动态字符串(Simple Dynamic String),简称SDS
struct sdshdr{ // 记录已使用长度 int len; // 记录空闲未使用的长度 int free; // 字符数组 char[] buf; }; |
Redis的字符串也会遵守C语言的字符串的实现规则,即最后一个字符为空字符。然而这个空字符不会被计算在len里头。
- Redis动态扩展步骤:
- 计算出大小是否足够
- 开辟空间至满足所需大小
- 开辟与已使用大小len相同长度的空闲free空间(如果len < 1M),开辟1M长度的空闲free空间(如果len >= 1M)
- Redis字符串的性能优势
- 快速获取字符串长度:直接返回len
- 避免缓冲区溢出:每次追加字符串时都会检查空间是否够用
- 降低空间分配次数提升内存使用效率:(1)空间预分配;(2)惰性空间回收
Redis持久化
- RDB(redis database)在指定时间间隔内将内存中的数据集快照写入磁盘,也就是snapshot快照,恢复时是将快照文件直接读到内存里。Redis会单独创建(fork:复制一个与当前进程一样的进程)一个子进程来进行持久化,会先将数据写入到临时文件,待持久化过程结束,再替换上次持久化好的文件(dump.rdb)。主进程不进行IO操作。如果需要进行大规模数据的恢复,且对数据完整性不敏感,那么RDB比AOF更高效。缺点就是最后一次持久化的数据可能丢失。
- 默认:1分钟set了一万次,5分钟set了10次,15分钟set了一次
- AOF以日志的形式来记录每个写操作,将redis执行过的所有写指令记录下来(读操作不记录),只能追加文件,不可以改写文件,redis重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
- 同步策略:always一直同步、everysec每秒同步、no不同步
- AOF的优点:
- 备份机制更稳健,丢失数据概率低
- 可读的日志文本,可以处理误操作
- AOF的缺点:
- 比RDB占用更多的磁盘空间
- 恢复备份速度慢
- 每次读写同步的话有一定的性能压力
- 存在个别的bug,造成不能恢复
- AOF重写机制:当aof文件的大小超过所设定的阈值时,redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令bgrewriteaof,fork出一条新进程来将文件重写,redis会记录上次重写时的AOF大小,默认配置是当AOF文件大小是上次rewrite后大小的1倍且文件大于64M时触发。
Redis的事务
- 定义:可以一次执行多个命令,部分支持事务
- 命令:MULTI开启事务、EXEC执行事务、DISCARD放弃事务、WATCH监视一个或多个key,如果在事务执行之前这个key被其他命令所改动,那么事务将被打断、UNWACTH一旦执行了EXEC之前加的所有的监控锁都会被取消。
- 特性:
- 单独的隔离操作:事务中所有的命令都会序列化、按顺序地执行。事务执行过程中,不会被其他客户端发送过来的命令请求所打断。
- 没有隔离级别的概念
- 不保证原子性:只要有一条命令执行失败,其他的命令仍然会执行,不支持回滚
LUA脚本
- 定义:LUA是一种小巧的脚本语言,可以很容易地被C/C++调用,也可以调用C/C++函数,一个完整的LUA解释器不超过200k,适合作为嵌入式脚本语言。
- 在redis中的优势
- 将复杂或者多步的redis操作,写为一个脚本,一次性提交给redis执行,减少反复连接redis的次数,提升性能。
- LUA脚本类似redis事务,有一定的原子性,不会被其他命令插队
Redis内存淘汰策略
- 八种内存淘汰策略大体上可以分为4种,lru、lfu、random、ttl。
- volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰。
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰。
- volatile-lfu:从已设置过期时间的数据集挑选使用频率最低的数据淘汰。
- allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
- allkeys-lfu:从数据集中挑选使用频率最低的数据淘汰。
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-enviction(驱逐):禁止驱逐数据,这也是默认策略。意思是当内存不足以容纳新入数据时,新写入操作就会报错,请求可以继续进行,线上任务也不能持续进行,采用no-enviction策略可以保证数据不被丢失。
LRU底层实现
- 通用LRU实现方法:Hashmap + Double Linked List

- 基于LinkedHashMap实现
public class LRUCache{
int capacity;
Map<Integer, Integer> map;
public LRUCache(int capacity) {
this.capacity = capacity;
map = new LinkedHashMap<>();
}
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
// 先删除旧的位置,再放入新位置
Integer value = map.remove(key);
map.put(key, value);
return value;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
map.remove(key);
map.put(key, value);
return;
}
map.put(key, value);
// 超出capacity,删除最久没用的,利用迭代器删除第一个
if (map.size() > capacity) {
map.remove(map.entrySet().iterator().next().getKey());
}
}
}
- Redis实现LRU
Redis 的方法是随机取出若干个 key,然后按照访问时间排序后,淘汰掉最不经常使用的页面
秒杀常见问题
- 连接超时:使用连接池
- 超卖问题:使用事务
- 库存遗留:使用LUA脚本
Redis发布订阅
- SUBSCRIBE c1 c2 c3
- PUBLISH c2 hello-redis
Redis主从复制
- 复制原理
Slave启动成功连接到master后会发送一个sync指令,master接到命令后启动后台存盘进程,同时收集所有接收到的用于修改数据集的指令(写指令),在后台存盘进程执行完毕后,master将传送整个数据文件到slave,以完成一次完全同步(全量复制),之后执行增量复制。只要是重新连接master,一次完全同步就自动被执行。
- 全量复制:slave在接收到数据库文件后,将其存盘并加载到内存中
- 增量复制:master继续将新的所有收集到的修改命令依次传给slave,slave执行命令以完成同步
- 一主二仆(常用招式)
- Info replication:查看信息
- SLAVEOF 127.0.0.1 6379:配置从库
- 配从不配主:slaveof 主库IP 主库port,每次与master断开之后,都需要重新连接,除非配置redis.conf文件。
- 配置文件细节操作:
- 拷贝多个redis.conf文件
- 开启daemonize yes
- Pid文件名字
- 指定端口
- Log文件名字
- Dump.rdb名字
哨兵模式
- 定义:反客为主(slaveof no one)自动化,能够监控主机是否故障,如果故障根据投票数自动将从库转为主库
- 使用步骤:
- 调整结构,6379带着80、81
- 自定义的/myredis目录下新建sentinel.conf文件
- 配置哨兵,填写内容:Sentinel monitor host637(被监控数据库名字(自己起名字))127.0.0.1 6379 1(多于1票则设为主机)
- 启动哨兵:Redis-sentinel /myredis/sentinel.conf
Java使用redis
- 连接:Jedis jedis = new Jedis("127.0.0.1",6379);
- 插入:jedis.set("k1","v1");
- 事务:
- Transaction transaction = jedis.multi();
- transaction.set("k2","v2");
- transaction.set("k3","v3");
- transaction.exec();
- 加锁:
public class TestTransaction { public boolean transMethod() { Jedis jedis = new Jedis("127.0.0.1", 6379); int balance;// 可用余额 int debt;// 欠额 int amtToSubtract = 10;// 实刷额度
jedis.watch("balance"); //jedis.set("balance","5");//此句不该出现,模拟其他程序已经修改了该条目 balance = Integer.parseInt(jedis.get("balance")); if (balance < amtToSubtract) { jedis.unwatch(); System.out.println("modify"); return false; } else { System.out.println("***********transaction"); Transaction transaction = jedis.multi(); transaction.decrBy("balance", amtToSubtract); transaction.incrBy("debt", amtToSubtract); transaction.exec();
balance = Integer.parseInt(jedis.get("balance")); debt = Integer.parseInt(jedis.get("debt"));
System.out.println("*******" + balance); System.out.println("*******" + debt); return true; } }
/** * 通俗点讲,watch命令就是标记一个键,如果标记了一个键, 在提交事务前如果该键被别人修改过,那事务就会失败,这种情况通常可以在程序中重新再尝试一次。 * 首先标记了键balance,然后检查余额是否足够,不足就取消标记,并不做扣减; 足够的话,就启动事务进行更新操作, * 如果在此期间键balance被其它人修改, 那在提交事务(执行exec)时就会报错, 程序中通常可以捕获这类错误再重新执行一次,直到成功。 */ public static void main(String[] args) { TestTransaction test = new TestTransaction(); boolean retValue = test.transMethod(); System.out.println("main retValue-------: " + retValue); } } |
- 主从复制
- 配置从库:Jedis jedis_s = new Jedis("127.0.0.1",6380);
- Jedis_s.slaveof("127.0.0.1",6379);
- JedisPool
JedisPoolConfig poolConfig = new JedisPoolConfig( ); poolConfig.setMaxActive ( 1000); poolconfig.setMaxIdle ( 32); poolconfig. setMaxwait (100*1000); poolconfig.setTestOnBorrow(true); // 创建连接池 jedisPool = new JedisPool(poolConfig, "127.0.0.1",6379); |
- maxActive:控制一个pool可分配多少个jedis实例,通过pool.getResource()来获取;如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted。
- maxIdle:控制一个pool最多有多少个状态为idle(空闲)的jedis实例;
- whenExhaustedAction:表示当pool中的jedis实例都被allocated完时,pool要采取的操作,默认有三种:
- WHEN_EXHAUSTED_FAIL -->表示无jedis实例时,直接抛出NoSuchElementException;
- WHEN_EXHAUSTED_BLOCK -->则表示阻塞住,或者达到maxWait时抛出JedisConnectionException;
- WHEN_EXHAUSTED_GRoW -->则表示新建一个jedis实例,也就说设置的maxActive无用;
- maxWait:表示当borrow一个jedis实例时,最大的等待时间,如果超过等待时间,则直接抛JedisConnectionException;
- testOnBorrow:获得一个jedis实例的时候是否检查连接可用性(ping());如果为true,则得到的jedis实例均是可用的:
保持登录状态的方式
(一)session机制保持会话
- 存在的问题
- 高并发情况下,会占用服务器大量内存
- 分布式(一个业务分成几个子业务,部署在多个服务器)或者集群(一个业务部署在多个服务器)的时候,session不能共享。
- 解决方案
- 高并发的时候可以将session存储到redis,如果用户长时间没有访问,将session存储到redis,就减少了服务器的压力。
- 分布式或者集群的时候,先通过redis来判断用户状态也可以实现session共享.
(二)cookie机制保持会话
- 使用方法
登录验证后,创建登录凭证(比如:用户id+登录时间+过期时间),将登录凭证进行加密,加密后写到浏览器的cookie,以后每次请求都发送cookie,服务器根据对应的解密算法对其进行验证(或者将加密过的cookie内容存储到数据库,请求服务器的时候,服务器在数据库进行查找)。
- 存在的问题
- 每次访问都提交cookie,增加请求数据量
- 其他访问可能需要cookie(比如说购物车的信息存放在cookie),浏览器对每个域存储的cookie的大小有限制,那么需要控制加密后的凭证。
(三)token机制保持会话
- 使用方法
cookie 和session依赖于浏览器,如果客户端不是浏览器,那么需要手动添加token(和cookie类似,也是登录凭证),将token添加到http header或者做为参数添加到url。
- 存在的问题
- 每次访问的时候手动添加token
- 和cookie 的方式一样增加了请求量
解决session存储问题
- 方案一:存在cookie里
- 不安全
- 网络负担重、传输效率低
- 方案二:存在文件服务器或数据库里
- 大量的IO效率问题
- 方案三:session复制
- Session数据冗余
- 节点越多浪费越大
- 方案四:缓存数据库
- 完全存在内存中,速度快数据结构简单
单线程+多路IO复用
多路复用是指用一个线程来检查多个文件描述符(file descriptor)的就绪状态,比如调用select、poll、epoll函数进行监视,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。
- Select:每一个请求都进行询问,最多1024个
- Poll:每一个请求都进行询问不限制数量
- Epoll:监视请求时为每个请求设置标识符,不需要一一询问
多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里"多路"指的是多个网络连接,"复用"指的是复用同一个线程。
Select、poll、epoll
- select的几大缺点:
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select支持的文件描述符数量太小了,默认是1024。
- 为什么epoll比select和poll更高效?
- 减少了用户态和内核态之间文件描述符的拷贝。
- 减少了对就绪文件描述符的遍历。
- select和poll只支持LT模式,而epoll支持高效的ET模式,并且epoll还支持EPOLLONESHOT事件。
- 无论哪种情况下,epoll都比select和poll高效吗?
- epoll适用于连接较多,活动数量较少的情况。
- epoll为了实现返回就绪的文件描述符,维护了一个红黑树和多个等待队列,内核开销很大。如果此时只监听了很少的文件描述符,底层的开销会得不偿失;
- epoll中注册了回调函数,当有事件发生时,服务器设备驱动调用回调函数将就绪的fd挂在rdllist上,如果有很多的活动,同一时间需要调用的回调函数数量太多,服务器压力太大。
- select和poll适用于连接较少的情况。
- 当select和poll上监听的fd数量较少,内核通知用户现在有就绪事件发生,应用程序判断当前是哪个fd就绪,所消耗的时间复杂度就会大大减小。
REDIS缓存穿透,缓存击穿,缓存雪崩原因+解决方案
- 缓存穿透:key对应的数据在数据库和缓存并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据库,从而可能压垮数据库。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
- 解决方案:
- 最常见的则是采用布隆过滤器,在写入数据库时,将数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。
- 简单粗暴的方法:如果一个查询返回的数据为空,我们仍然把这个空结果(或者默认值)进行缓存,但它的过期时间会很短,最长不超过五分钟。
- 非法请求限制
- 缓存击穿:是指一个key非常热点,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
- 解决方案
- 互斥锁:就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
- 设置永不过期,由后台更新缓存
- 缓存雪崩:当缓存服务器宕机重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。
- 大量数据同时过期解决方案
- 均匀设置过期时间,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
- 用互斥锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。加锁排队只是为了减轻数据库的压力,并没有提高系统吞吐量。
- 双key策略,主key设置过期时间,备key设置永久,主key过期时,返回备key内容
- 后台缓存更新,定时更新、消息队列通知更新
- 服务器宕机解决方案
- 服务熔断:在分布式系统中,我们往往需要依赖下游服务,不管是内部系统还是第三方服务,如果下游出现问题,我们不再盲目地去请求,在一个周期内失败达到一定次数,不再请求,及时失败。过一段时间,再逐步放开请求,这样既能防止不断的调用,使下游服务更坏,保护了下游方,还能降低自己的执行成本,快速的响应,减少延迟,增加吞吐量。
- 请求限流:通过对并发访问进行限速。最简单的方式,把多余的请求直接拒绝掉,可以根据一定的用户规则进行拒绝策略(计数器、漏桶、令牌桶)
- 构建redis高可靠集群
注:服务降级是为了解决资源不足和访问量增加的矛盾,在有限的资源情况下,为了能抗住大量的请求,就需要对系统做出一些牺牲,有点"弃卒保帅"的意思。放弃一些功能,保证整个系统能平稳运行。比如:抢购可以暂时限流评论,将流量让给秒杀业务
缓存过期时间的设置
- 使用 slidingExpiration 时间时,如果再次命中,将延长缓存项目的生命期
- 使用 expireTime 时,无论是否命中,时间到时,都将失效。
服务限流
- 限流策略
- 限制接口总并发数:按照 ip 限制其并发连接数
- 平滑限制接口的请求数:限制 ip 每分钟只能调用 120 接口(平滑处理请求,即每秒放过2个请求)
- 限制接口时间窗请求数:限制 ip 每分钟只能调用 120 次接口(允许在时间段开始的时候一次性放过120个请求)
- 限流算法
- 计数器算法
- 定义:采用计数器实现限流有点简单粗暴,一般我们会限制一秒钟的能够通过的请求数,比如限流qps为100,算法的实现思路就是从第一个请求进来开始计时,在接下去的1s内,每来一个请求,就把计数加1,如果累加的数字达到了100,那么后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数。
- 算法实现:对于每次服务调用,可以通过 AtomicLong#incrementAndGet()方法来给计数器加1并返回最新值,通过这个最新值和阈值进行比较。
- 弊端:如果我在单位时间1s内的前10ms,已经通过了100个请求,那后面的990ms,只能眼巴巴的把请求拒绝,我们把这种现象称为"突刺现象"。
- 漏桶算法
- 定义:为了消除"突刺现象",可以采用漏桶算法实现限流,漏桶算法这个名字就很形象,算法内部有一个容器,类似生活用到的漏斗,当请求进来时,相当于水倒入漏斗,然后从下端小口慢慢匀速的流出。不管上面流量多大,下面流出的速度始终保持不变。不管服务调用方多么不稳定,通过漏桶算法进行限流,每10毫秒处理一次请求。因为处理的速度是固定的,请求进来的速度是未知的,可能突然进来很多请求,没来得及处理的请求就先放在桶里,既然是个桶,肯定是有容量上限,如果桶满了,那么新进来的请求就丢弃。
- 算法实现:准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
- 弊端:无法应对短时间的突发流量。
- 令牌桶
- 定义:从某种意义上讲,令牌桶算法是对漏桶算法的一种改进,漏桶算法能够限制请求调用的速率,而令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用。在令牌桶算法中,存在一个桶,用来存放固定数量的令牌。算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。放令牌这个动作是持续不断的进行,如果桶中令牌数达到上限,就丢弃令牌,所以就存在这种情况,桶中一直有大量的可用令牌,这时进来的请求就可以直接拿到令牌执行,比如设置qps为100,那么限流器初始化完成一秒后,桶中就已经有100个令牌了,这时服务还没完全启动好,等启动完成对外提供服务时,该限流器可以抵挡瞬时的100个请求。所以,只有桶中没有令牌时,请求才会进行等待,最后相当于以一定的速率执行。
- 实现思路:可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。
- 集群限流
- 为了限制某个资源被每个用户或者商户的访问次数,5s只能访问2次,或者一天只能调用1000次,这种需求,单机限流是无法实现的,这时就需要通过集群限流进行实现。
- 实现思路:每次有相关操作的时候,就向redis服务器发送一个incr命令,比如需要限制某个用户访问/index接口的次数,只需要拼接用户id和接口名生成redis的key,每次该用户访问此接口时,只需要对这个key执行incr命令,在这个key带上过期时间,就可以实现指定时间的访问频率。
布隆过滤器
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
- 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
- 第二步,将N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;
举个例子,假设有一个位图数组长度为 8,3 个哈希函数的布隆过滤器。在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
为什么要用redis而不用map做缓存?
- Redis 可以用几十 G 内存来做缓存,Map 不行,一般 JVM 也就分几个 G 数据就够大了
- Redis 的缓存可以持久化,Map 是内存对象,程序一重启数据就没了
- Redis 缓存有过期机制,Map 本身无此功能
- Redis 可以实现分布式的缓存,Map只能存在创建它的程序里
- Redis 可以处理每秒百万级的并发,是专业的缓存服务,Map 只是一个普通的对象
- Redis 有丰富的 API,Map 就简单太多了
如何保持缓存和数据库的一致性?
- 淘汰缓存还是更新缓存?
选择淘汰缓存,原因是数据可能为简单数据,也可能为较复杂的数据,复杂数据进行缓存的更新操作,成本较高,因此一般推荐淘汰缓存
- 先淘汰缓存还是先更新数据库?
选择先淘汰缓存,再更新数据库,等待一段时间,最后再淘汰缓存。原因:假如先更新数据库,再淘汰缓存,如果缓存淘汰失败,那么后面的请求都会得到脏数据,直至缓存过期。假如先淘汰缓存再更新数据库,如果数据库更新失败,会产生一次缓存miss。如果一个线程 A 先把缓存删除了,然后去更新数据库,那么在它删了缓存还没有更新到数据库的这个中间时间,线程B进来了,发现缓存没有,就去读库,这时候还是读取还是旧的数据,然后又更新到缓存去了,此时A 才把新数据写到数据库。这是就需要再次删除缓存
- 延时双删策略:解决数据库读写分离
public void write(String key,Object data){
redisUtils.del(key);
db.update(data);
Thread.Sleep(100);
redisUtils.del(key);
}
- 缓存更新失败如何处理?
重试更新次数到达一定次数后(16),如果还是更新失败,就要放弃重试,进行缓存Key值清除,让访问者直接命中数据库后再次进行缓存操作。
Redis分布式锁的实现
- 分布式锁的实现方式
- 基于数据库:在数据库中创建一张表,表里包含方法名等字段,并且在方法名字段上面创建唯一索引,执行某个方法需要使用此方法名向表中插入数据,成功插入则获取锁,执行结束则删除对应的行数据释放锁。
- 基于redis分布式锁
- 基于ZooKeeper的分布式锁
- Redis分布式锁的实现方式
- 使用getSet作为分布式锁控制实现
- getSet本身是支持原子性的,在写入新值的同时会返回旧的值,用这个写入新值并获取旧值做为分布式锁的控制实现,如果返回的值不为空,那就说明前面已经有其它线程修改了该值,则可以认为已经有线程在对该请求正在处理,因而可以放弃后面的处理逻辑。
- 可以将当前系统的时间作为分布式锁key的值,后续其它线程的请求时,将其请求的时间与获取到的锁对应的key的旧值进行比较,比较是否已经超过了一定的时间控制阈值,如果超过了则可以认为前面的交易处理失败(这里存在误判的可能性,因为后续逻辑的数据处理,恰好超过了这个时间比较阀值,就会导致重复执行,因而这里时间控制阀值要设置的比较合理,另外也需要合适的熔断机制用于保证,如服务恰好在设置了用于分布式锁的key后,立即就挂了,没有执行到后面的删除操作),导致用于分布式锁的key没有被删除掉,可以继续处理该请求的后续交易逻辑。
- 使用setnx作为分布式锁控制实现
- setnx如果存在旧值时可以通过参数控制不设置值并返回0,不存在旧值时才设值并返回1。
- 加锁:使用setnx key value命令,如果key不存在,设置value(加锁成功);如果已经存在lock(也就是有客户端持有锁了),则设置失败(加锁失败)。
- 解锁:使用del命令,通过删除键值释放锁。释放锁之后,其他客户端可以通过setnx命令进行加锁。

- Redlock
- 集群模式的分布式锁,在单Redis节点基础上引入的高可用模式。
- 算法流程:在Redis的分布式环境中,我们假设有N个完全互相独立的Redis节点,在N个Redis实例上使用与在Redis单实例下相同方法获取锁和释放锁。现在假设有5个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:
- 获取当前Unix时间,以毫秒为单位
- 依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁
- 当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等
- 客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,这个很重要
- 如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题
Redis集群
- Redis集群解决内存压力,实现了对redis的水平扩容,即启动N个redis节点,将整个数据库分布在N个节点中,每个节点存储数据的1/N。
- Redis集群通过分区来提供一定程度的可用性,即使集群中有一部分节点失效或者无法进行通讯,集群也可以基础处理命令请求。
Redis部署模型
- 模式一:单实例
- 模式二:一主一从
- 模式三:一主多从
- 模式四:多主多从
- 模式五:集群
Redis缓存预热
- 缓存预热的思路
- 提前给redis中嵌入部分数据,再提供服务,肯定不可能将所有数据都写入redis,因为数据量太大了,第一耗费的时间太长了,第二redis根本就容纳不下所有的数据
- 需要根具当天的具体访问情况,试试统计出频率较高的热数据
- 然后将访问频率较高的 热数据写入到redis,肯定热数据也比较多,我们也得多个服务并行的读取数据去写,并行的分布式的缓存预热
- 然后将嵌入的热数据的redis对外提供服务,这样就不至于冷启动,直接让数据库奔溃
- 具体的实时方案:
- nginx+lua将访问量上报到kafka中要统计出来当前最新的实时的热数据是哪些,我们就得将商品详情页访问的请求对应的流量,日志,实时上报到kafka中,
- storm从kafka中消费数据,实时统计出每个商品的访问次数,访问次数基于LRU内存数据结构的存储方案。
优先用内存中的一个LRUMap去存放,性能高,而且没有外部依赖。否则的话,依赖redis,我们就是要防止reids挂掉数据丢失的情况,就不合适了;用mysql,扛不住高并发读写;用hbase,hadoop生态系统,维护麻烦,太重了。其实我们只要统计出一段时间访问最频繁的商品,然后对它们进行访问计数,同时维护出一个前N个访问最多的商品list即可。计算好每个task大致要存放的商品访问次数的数量,计算出大小,然后构建一个LURMap,设定好map的最大大小,就会自动根据LRU算法去剔除多余的数据,保证内存使用限制,即使有部分数据被干掉了,然后下次来重新开始计数,也没什么关系,因为如果他被LRU算法干掉,那么它就不是热数据,说明最近一段时间很少访问。
- 每个storm task启动的时候,基于zookeeper分布式锁,将自己的id写入zookeeper的一个节点中完成注册
- 每个storm task负责完成自己这里的热数据的统计,比如每次计数过后,维护一个前1000个商品的list,每次计算完都更新这个list
- 写一个后台线程,每个一段时间,比如一分钟,将排名前1000的热数据list,同步到zookeeper中。
Redis使用管道PipeLine
PipeLine指的是管道技术,指的是客户端允许将多个请求依次发给服务器,过程中而不需要等待请求的回复,在最后再一并读取结果即可,Redis很早就支持管道(Pipline)技术。
普通请求模型 Pipeline请求模型
使用场景:埋点数据的消费者生产者队列,使用redis中的list类型,把埋点数据存在redis中,一次存储一条,通过定时任务消费缓存中的数据(持久化到数据库),需要保证原子操作,所以每次使用lPop出队(先计算队列总数,然后通过rang()函数返回,再截取剩下的,无法保证原子操作,在截取数据时另一个客户端新加的数据未能截取保留下来),如果缓存中有1万条数据,那么需要执行客户端命令1万次,tcp连接1w次会影响性能,所以考虑使用管道实现,管道可以把客户端命令批量提交给redis服务端,然后一次性返回数据。
- 为什么要使用Pipeline?
性能问题:redis客户端发送多条请求,后面的请求需要等待前面的请求处理完后,才能进行处理,而且每个请求都存在往返时间RRT(Round Trip Time),即使redis性能极高,当数据量足够大,也会极大影响性能,还可能会引起其他意外情况。
浙公网安备 33010602011771号