找回密码
 会员注册
查看: 25|回复: 0

一篇文章入门redis

[复制链接]

2万

主题

0

回帖

6万

积分

超级版主

积分
64454
发表于 2024-9-20 19:47:11 | 显示全部楼层 |阅读模式
目录Redis介绍Redis是什么?Redis特性Redis典型使用场景Redis高并发原理Redis安装下载Redis解压并安装Redis移动Redis目录(可选)编译安装Redis启动redisconf配置文件Redis数据结构与命令使用通用全局命令常用全局命令简单使用截图字符串使用常用字符串命令字符串简单使用截图字符串使用场景哈希hash常用哈希命令哈希简单使用截图哈希使用场景列表(lists)常用列表命令列表简单使用截图列表使用场景set集合和zset有序集合常用集合命令集合简单使用截图常用有序集合命令有序集合简单使用截图集合和有序集合使用场景关于跳跃列表-列表、集合和有序集合异同小功能大用处慢查询分析Pipeline(流水线)机制事务与Luamulti和exec命令Redis事务缺陷的解决–LuaSCRIPTLOAD与EVALSHA命令通过Lua脚本执行Redis命令BitmapsHyperLogLog发布订阅GEORedis客户端持久化、主从同步与缓存设计持久化RDBRedisRDBAOFRedis4 混合持久化主从同步—简单了解消息丢失Redis最终一致缓存缓存的收益与成本缓存更新策略—算法剔除缓存更新策略—超时剔除缓存更新策略—主动更新缓存更新策略—总结缓存可能会遇到的问题知识拓展缓存与数据库同步策略,即如何保证缓存Redis与数据库MySQL的一致性删除缓存对比更新缓存先更新数据库,再更新缓存先删除缓存,再更新数据库先更新数据库,再删除缓存方案三四脏数据解决方案分布式锁分布式锁存在的问题关于分布式锁的Redlock算法关于集群Redis介绍Redis是什么?Redis是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。Redis支持多种类型的数据结构,如字符串(strings),散列(hashes),列表(lists),集合(sets),有序集合(sortedsets),范围查询,bitmaps,hyperloglogs和地理空间(geospatial)索引半径查询。Redis内置了复制(replication),LUA脚本(Luascripting),LRU驱动事件(LRUeviction),事务(transactions)和不同级别的磁盘持久化(persistence),Redis通过哨兵(Sentinel)和自动分区(Cluster)提供高可用性(highavailability)。Redis特性速度快单节点读110000次/s,写81000次/s数据存放内存中用C语言实现,离操作系统更近单线程架构,6.0开始支持多线程(CPU、IO读写负荷)持久化数据的更新将异步地保存到硬盘(RDB和AOF)多种数据结构不仅仅支持简单的key-value类型数据,还支持:字符串、hash、列表、集合、有序集合,支持多种编程语言功能丰富HyperLogLog、GEO、发布订阅、Lua脚本、事务、Pipeline、Bitmaps,key过期简单稳定源码少、单线程模型主从复制Redis支持数据的备份(master-slave)与集群(分片存储),以及拥有哨兵监控机制。Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。Redis典型使用场景缓存:计数器:消息队列:排行榜:社交网络:Redis高并发原理Redis是纯内存数据库,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在IO上,所以读取速度快Redis使用的是非阻塞IO,IO多路复用,使用了单线程来轮询描述符,将数据库的开、关、读、写都转换成了事件,减少了线程切换时上下文的切换和竞争。Redis采用了单线程的模型,保证了每个操作的原子性,也减少了线程的上下文切换和竞争。Redis存储结构多样化,不同的数据结构对数据存储进行了优化,如压缩表,对短数据进行压缩存储,再如,跳表,使用有序的数据结构加快读取的速度。Redis采用自己实现的事件分离器,效率比较高,内部采用非阻塞的执行方式,吞吐能力比较大。Redis安装这里只提供linux版本的安装部署下载Redis进入官网找到下载地址:https://redis.io/download右键Download按钮,选择复制链接地址,然后进入linux的shell控制台:输入wget将上面复制的下载链接粘贴上,如下命令:wgethttps://download.redis.io/releases/redis-6.2.4.tar.gz回车后等待下载完毕。解压并安装Redis下载完成后需要将压缩文件解压,输入以下命令解压到当前目录:tar-zvxfredis-6.2.4.tar.gz解压后在根目录上输入 ls 列出所有目录会发现与下载redis之前多了一个redis-6.2.4.tar.gz文件和redis-6.2.4的目录。移动Redis目录(可选)若你不想在下载的目录安装Redis,可以将Redis移动到特定目录安装,我习惯放在‘/usr/local/’目录下,所以我这里输入命令将目前在‘/root’目录下的'redis-6.2.4'文件夹更改目录,同时修改其名字为redis:mv/root/rredis-6.2.4/usr/local/rediscd 到'/usr/local'目录下输入 ls 命令可以查询到当前目录已经多了一个redis子目录,同时'/root'目录下已经没有'redis-6.2.4'文件:编译cd 到'/usr/local/redis'目录,输入命令 make 执行编译命令,接下来控制台会输出各种编译过程中输出的内容:make最终运行结果如下:安装输入以下命令:makePREFIX=/usr/local/redisinstall这里多了一个关键字'PREFIX='这个关键字的作用是编译的时候用于指定程序存放的路径。比如我们现在就是指定了redis必须存放在'/usr/local/redis'目录。假设不添加该关键字linux会将可执行文件存放在'/usr/local/bin'目录,库文件会存放在'/usr/local/lib'目录。配置文件会存放在'/usr/local/etc目录。其他的资源文件会存放在'usr/local/share'目录。这里指定好目录也方便后续的卸载,后续直接 rm-rf/usr/local/redis 即可删除Redis。执行结果如下图:到此为止,Redis已经安装完毕,可以开始使用了~Redis启动根据上面的操作已经将redis安装完成了。在目录‘/usr/local/redis’输入下面命令启动redis:./bin/redis-server&./redis.conf上面的启动方式是采取后台进程方式,下面是采取显示启动方式(如在配置文件设置了daemonize属性为yes则跟后台进程方式启动其实一样):./bin/redis-server./redis.conf两种方式区别无非是有无带符号&的区别。redis-server后面是配置文件,目的是根据该配置文件的配置启动redis服务。redis.conf配置文件允许自定义多个配置文件,通过启动时指定读取哪个即可。启动可以概括为:最简默认启动安装后在bin目录下直接执行redis-server验证(ps–aux|grepredis)动态参数启动(可配置一下参数,例如指定端口)./bin/redis-server–port6380配置文件启动./bin/redis-server&./redis.conf生产环境一般选择配置启动单机多实例配置文件可以用端口区分开注:若在进行redis命令操作,直接在redis中的bin目录下运行redis-cli命令即可,若开启了多个则需要加上对应的端口参数:若运行redis-cli提示不未安装,则安装一下即可:redis.conf配置文件在目录'/usr/local/redis'下有一个redis.conf的配置文件。我们上面启动方式就是执行了该配置文件的配置运行的。我们可以通过 cat、vim、less 等linux内置的读取命令读取该文件。这里列举下比较重要的配置项:配置项名称配置项值范围说明daemonizeyes、noyes表示启用守护进程,默认是no即不以守护进程方式运行。其中Windows系统下不支持启用守护进程方式运行port指定Redis监听端口,默认端口为6379bind绑定的主机地址,如果需要设置远程访问则直接将这个属性备注下或者改为bind*即可,这个属性和下面的protected-mode控制了是否可以远程访问protected-modeyes、no保护模式,该模式控制外部网是否可以连接redis服务,默认是yes,所以默认我们外网是无法访问的,如需外网连接rendis服务则需要将此属性改为nologleveldebug、verbose、notice、warning日志级别,默认为noticedatabases16设置数据库的数量,默认的数据库是0。整个通过客户端工具可以看得到rdbcompressionyes、no指定存储至本地数据库时是否压缩数据,默认为yes,Redis采用LZF压缩,如果为了节省CPU时间,可以关闭该选项,但会导致数据库文件变得巨大dbfilenamedump.rdb指定本地数据库文件名,默认值为dump.rdbdir指定本地数据库存放目录requirepass设置Redis连接密码,如果配置了连接密码,客户端在连接Redis时需要通过AUTH命令提供密码,默认关闭maxclients0设置同一时间最大客户端连接数,默认无限制,Redis可以同时打开的客户端连接数为Redis进程可以打开的最大文件描述符数,如果设置maxclients0,表示不作限制。当客户端连接数到达限制时,Redis会关闭新的连接并向客户端返回maxnumberofclientsreached错误信息maxmemoryXXX指定Redis最大内存限制,Redis在启动时会把数据加载到内存中,达到最大内存后,Redis会先尝试清除已到期或即将到期的Key,当此方法处理后,仍然到达最大内存设置,将无法再进行写入操作,但仍然可以进行读取操作。Redis新的vm机制,会把Key存放内存,Value会存放在swap区。配置项值范围列里XXX为数值这里我要将daemonize改为yes,不然我每次启动都得在redis-server命令后面加符号&,不这样操作则只要回到linux控制台则redis服务会自动关闭,同时也将bind注释,将protected-mode设置为no。这样启动后我就可以在外网访问了。修改方式通过vim或者你喜欢的方式即可:vim/usr/local/redis/redis.conf通过/daemonize 查找到属性,默认是no,更改为yes即可。(通过/关键字查找出现多个结果则使用n字符切换到下一个即可,按i可以开始编辑,ESC退出编辑模式,输入 :wq 命令保存并退出),如下图:其他属性也是同样方式查找和编辑即可。安装部署部分参考:https://www.cnblogs.com/hunanzp/p/12304622.htmlRedis数据结构与命令使用Redis的数据结构有:string(字符串)、hash(哈希)、list(列表)、set(集合)、zset(有序集合)。但这些只是Redis对外的数据结构,实际上每种数据结构都有自己底层的内部编码实现,而且是多种实现,这样Redis会在合适的场景选择合适的内部编码。可以看到每种数据结构都有两种以上的内部编码实现,例如list数据结构包含了linkedlist和ziplist两种内部编码。同时,有些内部编码,例如ziplist,可以作为多种外部数据结构的内部实现,可以通过 objectencoding 命令查询内部编码。objectencodingxxx#xxx为键名Redis所有的数据结构都是以唯一的key字符串作为名称,然后通过这个唯一key值来获取相应的value数据。不同类型的数据结构的差异就在于value的结构不一样。通用全局命令常用全局命令keys:查看所有键dbsize:键总数existskey:检查键是否存在delkey[key...]:删除键expirekeyseconds:键过期ttlkey:通过ttl命令观察键键的剩余过期时间typekey:键的数据结构类型简单使用截图根据上面的命令解释,大家应该比较容易看懂截图里面的所有命令含义,这里就不过多解释了。字符串使用字符串string是Redis最简单的数据结构。Redis的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于Java的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。字符串结构使用非常广泛,一个常见的用途就是缓存用户信息。我们将用户信息结构体使用JSON序列化成字符串,然后将序列化后的字符串塞进Redis来缓存。同样,取用户信息会经过一次反序列化的过程。常用字符串命令setkeyvalue[exseconds][pxmilliseconds][nx|xx]:设置值,返回ok表示成功exseconds:为键设置秒级过期时间。pxmilliseconds:为键设置毫秒级过期时间。nx:键必须不存在,才可以设置成功,用于添加。可单独用setnx命令替代xx:与nx相反,键必须存在,才可以设置成功,用于更新。可单独用setxx命令替代getkey:获取值msetkeyvalue[keyvalue...]:批量设置值,批量操作命令可以有效提高业务处理效率mgetkey[key...]:批量获取值,批量操作命令可以有效提高业务处理效率incrkey:计数,返回结果分3种情况:值不是整数,返回错误。值是整数,返回自增后的结果。键不存在,按照值为0自增,返回结果为1。decr(自减)、incrby(自增指定数字)、decrby(自减指定数字)字符串简单使用截图根据上面的命令解释,大家应该比较容易看懂截图里面的所有命令含义,这里就不过多解释了。字符串使用场景缓存数据,提高查询性能。比如存储登录用户信息、电商中存储商品信息可以做计数器(想知道什么时候封锁一个IP地址(访问超过几次)),短信限流共享Session,例如:一个分布式Web服务将用户的Session信息(例如用户登录信息)保存在各自服务器中,这样会造成一个问题,出于负载均衡的考虑,分布式服务会将用户的访问均衡到不同服务器上,用户刷新一次访问可能会发现需要重新登录,为了解决这个问题,可以使用Redis将用户的Session进行集中管理,在这种模式下只要保证Redis是高可用和扩展性的,每次用户更新或者查询登录信息都直接从Redis中集中获取,如图:哈希hash哈希相当于Java中的HashMap,以及Js中的Map,内部是无序字典。实现原理跟HashMap一致。一个哈希表有多个节点,每个节点保存一个键值对。与Java中的HashMap不同的是,rehash的方式不一样,因为Java的HashMap在字典很大时,rehash是个耗时的操作,需要一次性全部rehash。Redis为了高性能,不能堵塞服务,所以采用了渐进式rehash策略。渐进式rehash会在rehash的同时,保留新旧两个hash结构,查询时会同时查询两个hash结构,然后在后续的定时任务中以及hash操作指令中,循序渐进地将旧hash的内容一点点迁移到新的hash结构中。当搬迁完成了,就会使用新的hash结构取而代之。当hash移除了最后一个元素之后,该数据结构自动被删除,内存被回收。常用哈希命令hsetkeyfieldvalue:设置值hgetkeyfield:获取值hdelkeyfield[field...]:删除fieldhlenkey:计算field个数hmsetkeyfieldvalue[fieldvalue...]:批量设置field-valuehmgetkeyfield[field...]:批量获取field-valuehexistskeyfield:判断field是否存在hkeyskey:获取所有fieldhvalskey:获取所有valuehgetallkey:获取所有的field-valueincrbyfloat和hincrbyfloat:就像incrby和incrbyfloat命令一样,但是它们的作用域是filed哈希简单使用截图根据上面的命令解释,大家应该比较容易看懂截图里面的所有命令含义,这里同样不过多解释了哈希使用场景Hash也可以同于对象存储,比如存储用户信息,与字符串不一样的是,字符串是需要将对象进行序列化(比如json序列化)之后才能保存,而Hash则可以讲用户对象的每个字段单独存储,这样就能节省序列化和反序列的时间。如下:此外还可以保存用户的购买记录,比如key为用户id,field为商品id,value为商品数量。同样还可以用于购物车数据的存储,比如key为用户id,field为商品id,value为购买数量等等:列表(lists)Redis中的lists相当于Java中的LinkedList,实现原理是一个双向链表(其底层是一个快速列表),即可以支持反向查找和遍历,更方便操作。插入和删除操作非常快,时间复杂度为O(1),但是索引定位很慢,时间复杂度为O(n)。常用列表命令rpushkeyvalue[value...]:从右边插入元素lpushkeyvalue[value...]:从左边插入元素linsertkeybefore|afterpivotvalue:向某个元素前或者后插入元素lrangekeystartend:获取指定范围内的元素列表,lrangekey0-1可以从左到右获取列表的所有元素lindexkeyindex:获取列表指定索引下标的元素llenkey:获取列表长度lpopkey:从列表左侧弹出元素rpopkey:从列表右侧弹出lremkeycountvalue:删除指定元素,lrem命令会从列表中找到等于value的元素进行删除,根据count的不同分为三种情况:·count>0,从左到右,删除最多count个元素。countconfigsetslowlog-log-slower-than1000OK>configsetslowlog-max-len1200OK>configrewriteOK3、如何获取慢查询日志?可以使用 slowlogget 命令获取慢查询日志,在 slowlogget 后面还可以加一个数字,用于指定获取慢查询日志的条数,比如,获取2条慢查询日志:>slowlogget31)1)(integer)61072)(integer)16163989303)(integer)31094)1)"config"2)"rewrite"2)1)(integer)61062)(integer)16137017883)(integer)360044)1)"flushall"可以看出每一条慢查询日志都有4个属性组成:唯一标识ID命令执行的时间戳命令执行时长执行的命名和参数此外,可以通过 slowloglen 命令获取慢查询日志的长度;通过 slowlogreset 命令清理慢查询日志。Pipeline(流水线)机制Redis提供了批量操作命令(例如mget、mset等),有效地节约RTT。但大部分命令是不支持批量操作的,例如要执行n次hgetall命令,并没有mhgetall命令存在,需要消耗n次RTT。Redis的客户端和服务端可能部署在不同的机器上。例如客户端在北京,Redis服务端在上海,两地直线距离约为1300公里,那么1次RTT时间=1300×2/(300000×2/3)=13毫秒(光在真空中传输速度为每秒30万公里,这里假设光纤为光速的2/3),那么客户端在1秒内大约只能执行80次左右的命令,这个和Redis的高并发高吞吐特性背道而驰。Pipeline(流水线)机制能改善上面这类问题,它能将一组Redis命令进行组装,通过一次RTT传输给Redis,再将这组Redis命令的执行结果按顺序返回给客户端。不使用Pipeline的命令执行流程:使用Pipeline的命令执行流程:Redis的流水线是一种通信协议,没有办法通过客户端演示给大家,这里以Jedis为例,通过JavaAPI或者使用Spring操作它(代码来源于互联网):/***测试Redis流水线*@authorliu*/publicclassTestPipelined{/***使用JavaAPI测试流水线的性能*/@SuppressWarnings({"unused","resource"})@TestpublicvoidtestPipelinedByJavaAPI(){JedisPoolConfigjedisPoolConfig=newJedisPoolConfig();jedisPoolConfig.setMaxIdle(20);jedisPoolConfig.setMaxTotal(10);jedisPoolConfig.setMaxWaitMillis(20000);JedisPooljedisPool=newJedisPool(jedisPoolConfig,"localhost",6379);Jedisjedis=jedisPool.getResource();longstart=System.currentTimeMillis();//开启流水线Pipelinepipeline=jedis.pipelined();//测试10w条数据读写for(inti=0;iresult=pipeline.syncAndReturnAll();longend=System.currentTimeMillis();//计算耗时System.out.println("耗时"+(end-start)+"毫秒");}/***使用RedisTemplate测试流水线*/@SuppressWarnings({"resource","rawtypes","unchecked","unused"})@TestpublicvoidtestPipelineBySpring(){ApplicationContextapplicationContext=newClassPathXmlApplicationContext("spring.xml");RedisTemplatert=(RedisTemplate)applicationContext.getBean("redisTemplate");SessionCallbackcallback=(SessionCallback)(RedisOperationsops)->{for(inti=0;imultiOK127.0.0.1:6379>SETmsg"hellochrootliu"QUEUED127.0.0.1:6379>GETmsgQUEUED127.0.0.1:6379>EXEC1)OK1)hellochrootliuRedis提供了简单的事务,之所以说它简单,主要是因为它不支持事务中的回滚特性,同时无法实现命令之间的逻辑关系计算,主要有以下几点:不够满足原子性。一个事务执行过程中,其他事务或client是可以对相应的key进行修改的(并发情况下,例如电商常见的超卖问题),想要避免这样的并发性问题就需要使用WATCH命令,但是通常来说,必须经过仔细考虑才能决定究竟需要对哪些key进行WATCH加锁。然而,额外的WATCH会增加事务失败的可能,而缺少必要的WATCH又会让我们的程序产生竞争条件。后执行的命令无法依赖先执行命令的结果。由于事务中的所有命令都是互相独立的,在遇到exec命令之前并没有真正的执行,所以我们无法在事务中的命令中使用前面命令的查询结果。我们唯一可以做的就是通过watch保证在我们进行修改时,如果其它事务刚好进行了修改,则我们的修改停止,然后应用层做相应的处理。事务中的每条命令都会与Redis服务器进行网络交互。Redis事务开启之后,每执行一个操作返回的都是queued,这里就涉及到客户端与服务器端的多次交互,明明是需要一次批量执行的n条命令,还需要通过多次网络交互,显然非常浪费(这个就是为什么会有pipeline的原因,减少RTT的时间)。Redis事务缺陷的解决–LuaLua是一个小巧的脚本语言,用标准C编写,几乎在所有操作系统和平台上都可以编译运行。一个完整的Lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的,这一切都决定了Lua是作为嵌入式脚本的最佳选择。Redis2.6版本之后内嵌了一个Lua解释器,可以用于一些简单的事务与逻辑运算,也可帮助开发者定制自己的Redis命令(例如:一次性的执行复杂的操作,和带有逻辑判断的操作),在这之前,必须修改源码。在Redis中执行Lua脚本有两种方法:eval 和evalsha,这里以eval做为案例介绍:eval语法:evalscriptnumkeyskey[key...]arg[arg...]其中:script  一段Lua脚本或Lua脚本文件所在路径及文件名numkeys Lua脚本对应参数数量key[key…] Lua中通过全局变量KEYS数组存储的传入参数arg[arg…] Lua中通过全局变量ARGV数组存储的传入附加参数EVAL"return{KEYS[1],KEYS[2],ARGV[1],ARGV[2]}"2key1key2firstsecond1)"key1"2)"key2"3)"first"4)"second"Lua执行流程图:SCRIPTLOAD与EVALSHA命令对于不立即执行的Lua脚本,或需要重用的Lua脚本,可以通过SCRIPTLOAD提前载入Lua脚本,这个命令会立即返回对应的SHA1校验码当需要执行函数时,通过EVALSHA调用SCRIPTLOAD返回的SHA1即可SCRIPTLOAD"return{KEYS[1],KEYS[2],ARGV[1],ARGV[2]}""232fd51614574cf0867b83d384a5e898cfd24e5a"EVALSHA"232fd51614574cf0867b83d384a5e898cfd24e5a"2key1key2firstsecond1)"key1"2)"key2"3)"first"4)"second"通过Lua脚本执行Redis命令在Lua脚本中,只要使用 redis.call() 或 redis.pcall() 传入Redis命令就可以直接执行:eval"returnredis.call('set',KEYS[1],'bar')"1foo--等同于在服务端执行setfoobar案例,使用Lua脚本实现访问频率限制:----KEYS[1]要限制的ip--ARGV[1]限制的访问次数--ARGV[2]限制的时间--localkey="rate.limit:"..KEYS[1]locallimit=tonumber(ARGV[1])localexpire_time=ARGV[2]localis_exists=redis.call("EXISTS",key)ifis_exists==1thenifredis.call("INCR",key)>limitthenreturn0elsereturn1endelseredis.call("SET",key,1)redis.call("EXPIRE",key,expire_time)return1end使用方法,通过:eval(file_get_contents(storage_path("limit.lua")),3,"127.0.0.1","3","100");redis的事务与Lua,就先介绍到这里了,更多的用法大家请查看Lua官方文档Bitmaps许多开发语言都提供了操作位的功能,合理地使用位能够有效地提高内存使用率和开发效率。Redis提供了Bitmaps这个“数据结构”可以实现对位的操作。把数据结构加上引号主要因为:Bitmaps本身不是一种数据结构,实际上它就是字符串,但是它可以对字符串的位进行操作。Bitmaps单独提供了一套命令,所以在Redis中使用Bitmaps和使用字符串的方法不太相同。可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫做偏移量。在我们平时开发过程中,会有一些bool型数据需要存取,比如用户一年的签到记录,签了是1,没签是0,要记录365天。如果使用普通的key/value,每个用户要记录365个,当用户上亿的时候,需要的存储空间是惊人的。为了解决这个问题,Redis提供了位图数据结构,这样每天的签到记录只占据一个位,365天就是365个位,46个字节(一个稍长一点的字符串)就可以完全容纳下,这就大大节约了存储空间。语法:setbitkeyoffsetvalue#设置或者清空key的value(字符串)在offset处的bit值getbitkeyoffset#返回key对应的string在offset处的bit值bitcountkey[startend]#startend范围内被设置为1的数量,不传递startend默认全范围使用案例,统计用户登录(活跃)情况127.0.0.1:6379>setbituserLogin:2021-04-10666661#userId=66666的用户登录,这是今天登录的第一个用户。(integer)0127.0.0.1:6379>setbituserLogin:2021-04-109999991#userId=999999的用户登录,这是今天第二个登录、的用户。(integer)0127.0.0.1:6379>setbituserLogin:2021-04-1033331(integer)0127.0.0.1:6379>setbituserLogin:2021-04-1088881(integer)0127.0.0.1:6379>setbituserLogin:2021-04-101000001(integer)0127.0.0.1:6379>getbitactive:2021-04-1066666(integer)1127.0.0.1:6379>getbitactive:2021-04-1055555(integer)127.0.0.1:6379>bitcountactive:2021-04-10(integer)5由于bit数组的每个位置只能存储0或者1这两个状态;所以对于实际生活中,处理两个状态的业务场景就可以考虑使用bitmaps。如用户登录/未登录,签到/未签到,关注/未关注,打卡/未打卡等。同时bitmap还通过了相关的统计方法进行快速统计。HyperLogLogHyperLogLog并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。HyperLogLog提供了3个命令:pfadd、pfcount、pfmerge。#用于向HyperLogLog添加元素#如果HyperLogLog估计的近似基数在PFADD命令执行之后出现了变化,那么命令返回1,否则返回0#如果命令执行时给定的键不存在,那么程序将先创建一个空的HyperLogLog结构,然后再执行命令pfaddkeyvalue1[value2value3]#PFCOUNT命令会给出HyperLogLog包含的近似基数#在计算出基数后,PFCOUNT会将值存储在HyperLogLog中进行缓存,知道下次PFADD执行成功前,就都不需要再次进行基数的计算。pfcountkey#PFMERGE将多个HyperLogLog合并为一个HyperLogLog,合并后的HyperLogLog的基数接近于所有输入HyperLogLog的并集基数。pfmergedestkeykey1key2[...keyn]127.0.0.1:6379>pfaddtotaluvuser1(integer)1127.0.0.1:6379>pfcounttotaluv(integer)1127.0.0.1:6379>pfaddtotaluvuser2(integer)1127.0.0.1:6379>pfcounttotaluv(integer)2127.0.0.1:6379>pfaddtotaluvuser3(integer)1127.0.0.1:6379>pfcounttotaluv(integer)3127.0.0.1:6379>pfaddtotaluvuser4(integer)1127.0.0.1:6379>pfcounttotaluv(integer)4127.0.0.1:6379>pfaddtotaluvuser5(integer)1127.0.0.1:6379>pfcounttotaluv(integer)5127.0.0.1:6379>pfaddtotaluvuser6user7user8user9user10(integer)1127.0.0.1:6379>pfcounttotaluv(integer)10HyperLogLog内存占用量非常小,但是存在错误率,开发者在进行数据229结构选型时只需要确认如下两条即可:只为了计算独立总数,不需要获取单条数据。可以容忍一定误差率,毕竟HyperLogLog在内存的占用量上有很大的优势。例如:如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站每个网页每天的UV数据,然后让你来开发这个统计模块,你会如何实现?如果统计PV那非常好办,给每个网页一个独立的Redis计数器就可以了,这个计数器的key后缀加上当天的日期。这样来一个请求,incrby一次,最终就可以统计出所有的PV数据。但是UV不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的ID,无论是登录用户还是未登录用户都需要一个唯一ID来标识。你也许已经想到了一个简单的方案,那就是为每一个页面一个独立的set集合来存储所有当天访问过此页面的用户ID。当一个请求过来时,我们使用sadd将用户ID塞进去就可以了。通过scard可以取出这个集合的大小,这个数字就是这个页面的UV数据。没错,这是一个非常简单的方案。但是,如果你的页面访问量非常大,比如一个爆款页面几千万的UV,你需要一个很大的set集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实老板需要的数据又不需要太精确,105w和106w这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?Redis提供了HyperLogLog数据结构就是用来解决这种统计问题的。HyperLogLog提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差是0.81%,这样的精确度已经可以满足上面的UV统计需求了。对于上面的场景,同学们可能有疑问,我或许同样可以使用HashMap、BitMap和HyperLogLog来解决。对于这三种解决方案,这边做下对比:HashMap:算法简单,统计精度高,对于少量数据建议使用,但是对于大量的数据会占用很大内存空间;BitMap:位图算法,具体内容可以参考我的这篇文章,统计精度高,虽然内存占用要比HashMap少,但是对于大量数据还是会占用较大内存;HyperLogLog:存在一定误差,占用内存少,稳定占用12k左右内存,可以统计2^64个元素,对于上面举例的应用场景,建议使用。发布订阅Redis提供了基于“发布/订阅”模式的消息机制,此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(channel)发布消息,订阅该频道的每个客户端都可以收到该消息:主要对应的Redis命令为:subscribechannel[channel...]#订阅一个或多个频道unsubscribechannel#退订指定频道publishchannelmessage#发送消息psubscribepattern#订阅指定模式punsubscribepattern#退订指定模式使用案例:打开一个Redis客户端,如向TestChanne说一声hello:127.0.0.1:6379>publishTestChannehello(integer)1#返回的是接收这条消息的订阅者数量这样消息就发出去了。发出去的消息不会被持久化,也就是有客户端订阅TestChanne后只能接收到后续发布到该频道的消息,之前的就接收不到了。打开另一Redis个客户端,这里假设发送消息之前就打开并且订阅了TestChanne频道:127.0.0.1:6379>subscribeTestChanne#执行上面命令客户端会进入订阅状态Readingmessages...(pressCtrl-Ctoquit)1)"subscribe"//消息类型2)"TestChanne"//频道3)"hello"//消息内容我们可以利用Redis发布订阅功能,实现的简单MQ功能,实现上下游的解耦。不过需要注意了,由于Redis发布的消息不会被持久化,这就会导致新订阅的客户端将不会收到历史消息。所以,如果当前的业务场景不能容忍这些缺点,那还是用专业MQ吧。GEORedis3.2版本提供了GEO(地理信息定位)功能,支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能,对于需要实现这些功能的开发者来说是一大福音。GEO功能是Redis的另一位作者MattStancliff借鉴NoSQL数据库Ardb实现的,Ardb的作者来自中国,它提供了优秀的GEO功能。RedisGEO相关的命令如下:#添加一个空间元素,longitude、latitude、member分别是该地理位置的经度、纬度、成员#这里的成员就是指代具体的业务数据,比如说用户的ID等#需要注意的是Redis的纬度有效范围不是[-90,90]而是[-85,85]#如果在添加一个空间元素时,这个元素中的menber已经存在key中,那么GEOADD命令会返回0,相当于更新了这个menber的位置信息GEOADDkeylongitudelatitudemember[longitudelatitudemember]#用于添加城市的坐标信息geoaddcities:locations117.1239.08tianjin114.2938.02shijiazhuang118.0139.38tangshan115.2938.51baoding#获取地理位置信息geoposkeymember[member...]#获取天津的坐标geoposcities:locationstianjin#获取两个坐标之间的距离#unit代表单位,有4个单位值-m(meter)代表米-km(kilometer)代表千米-mi(miles)代表英里-ft(ft)代表尺geodistkeymember1member2[unit]#获取天津和保定之间的距离GEODISTcities:locationstianjinbaodingkm#获取指定位置范围内的地理信息位置集合,此命令可以用于实现附近的人的功能#georadius和georadiusbymember两个命令的作用是一样的,都是以一个地理位置为中心算出指定半径内的其他地理信息位置,不同的是georadius命令的中心位置给出了具体的经纬度,georadiusbymember只需给出成员即可。其中radiusm|km|ft|mi是必需参数,指定了半径(带单位),这两个命令有很多可选参数,参数含义如下:#-withcoord:返回结果中包含经纬度。#-withdist:返回结果中包含离中心节点位置的距离。#-withhash:返回结果中包含geohash,有关geohash后面介绍。#-COUNTcount:指定返回结果的数量。#-asc|desc:返回结果按照离中心节点的距离做升序或者降序。#-storekey:将返回结果的地理位置信息保存到指定键。#-storedistkey:将返回结果离中心节点的距离保存到指定键。georadiuskeylongitudelatituderadiusm|km|ft|mi[withcoord][withdist][withhash][COUNTcount][asc|desc][storekey][storedistkey]georadiusbymemberkeymemberradiusm|km|ft|mi[withcoord][withdist][withhash][COUNTcount][asc|desc][storekey][storedistkey]#获取geohash#Redis使用geohash将二维经纬度转换为一维字符串,geohash有如下特点:#-GEO的数据类型为zset,Redis将所有地理位置信息的geohash存放在zset中。#-字符串越长,表示的位置更精确,表3-8给出了字符串长度对应的精度,例如geohash长度为9时,精度在2米左右。长度和精度的对应关系,请参考:https://easyreadfs.nosdn.127.net/9F42_CKRFsfc8SUALbHKog==/8796093023252281390#-两个字符串越相似,它们之间的距离越近,Redis利用字符串前缀匹配算法实现相关的命令。#-geohash编码和经纬度是可以相互转换的。#-Redis正是使用有序集合并结合geohash的特性实现了GEO的若干命令。geohashkeymember[member...]#删除操作,GEO没有提供删除成员的命令,但是因为GEO的底层实现是zset,所以可以借用zrem命令实现对地理位置信息的删除。zremkeymember使用案例,例如咋部门是做直播的,那直播业务一般会有一个“附近的直播”功能,这里就可以考虑用Redis的GEO技术来完成这个功能。数据操作主要有两个:一是主播开播的时候写入主播Id的经纬度,二是主播关播的时候删除主播Id元素。这样就维护了一个具有位置信息的在线主播集合提供给线上检索。大家具体使用的时候,可以去了解一下RedisGEO原理,主要用到了空间索引的算法GEOHASH的相关知识,针对索引我们日常所见都是一维的字符,那么如何对三维空间里面的坐标点建立索引呢,直接点就是三维变二维,二维变一维。这里就不再详细阐述了。Redis客户端主流编程语言都有对应的常用Redis客户端,例如:java->Jedispython->redis-pynode->ioredis具体使用语法,大家可以根据自己的需要查找对应的官方文档:Jedis文档:https://github.com/redis/jedisredis-py文档:https://github.com/redis/redis-pyioredis文档:https://github.com/luin/ioredis持久化、主从同步与缓存设计持久化Redis支持RDB和AOF两种持久化机制,持久化功能有效地避免因进程退出造成的数据丢失问题,当下次重启时利用之前持久化的文件即可实现数据恢复。RDB是一次全量备份,AOF日志是连续的增量备份,RDB是内存数据的二进制序列化形式,在存储上非常紧凑,而AOF日志记录的是内存数据修改的指令记录文本。AOF以独立日志的方式记录每次写命令,重启时再重新执行AOF文件中的命令达到恢复数据的目的。AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。AOF日志在长期的运行过程中会变的无比庞大,数据库重启时需要加载AOF日志进行指令重放,这个时间就会无比漫长。所以需要定期进行AOF重写,给AOF日志进行瘦身。RDB我们知道Redis是单线程程序,这个线程要同时负责多个客户端套接字的并发读写操作和内存数据结构的逻辑读写。在服务线上请求的同时,Redis还需要进行内存RDB,内存RDB要求Redis必须进行文件IO操作,可文件IO操作是不能使用多路复用API。这意味着单线程同时在服务线上的请求还要进行文件IO操作,文件IO操作会严重拖垮服务器请求的性能。还有个重要的问题是为了不阻塞线上的业务,就需要边持久化边响应客户端请求。持久化的同时,内存数据结构还在改变,比如一个大型的hash字典正在持久化,结果一个请求过来把它给删掉了,还没持久化完呢,这可怎么办?那该怎么办呢?Redis使用操作系统的多进程COW(CopyOnWrite)机制来实现RDB持久化,以下为RDB备份流程:执行bgsave命令,Redis父进程判断当前是否存在正在执行的子进程,如RDB/AOF子进程,如果存在bgsave命令直接返回。父进程执行fork操作创建子进程,fork操作过程中父进程会阻塞,通过infostats命令查看latest_fork_usec选项,可以获取最近一个fork操作的耗时,单位为微秒。父进程fork完成后,bgsave命令返回“Backgroundsavingstarted”信息并不再阻塞父进程,可以继续响应其他命令。子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后对原有文件进行原子替换。执行lastsave命令可以获取最后一次生成RDB的时间,对应info统计的rdb_last_save_time选项。进程发送信号给父进程表示完成,父进程更新统计信息,具体见infoPersistence下的rdb_*相关选项。AOFAOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录。假设AOF日志记录了自Redis实例创建以来所有的修改性指令序列,那么就可以通过对一个空的Redis实例顺序执行所有的指令,也就是「重放」,来恢复Redis当前实例的内存数据结构的状态。Redis会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到AOF日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到AOF日志的指令进行重放一下就可以恢复到宕机前的状态。通过appendfsync参数可以控制实时/秒级持久化。AOF流程:所有的写入命令会追加到aof_buf(缓冲区)中。AOF缓冲区根据对应的策略向硬盘做同步操作。随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。当Redis服务器重启时,可以加载AOF文件进行数据恢复。Redis在长期运行的过程中,AOF的日志会越变越长。如果实例宕机重启,重放整个AOF日志会非常耗时,导致长时间Redis无法对外提供服务。所以需要对AOF日志瘦身。Redis提供了bgrewriteaof指令用于对AOF日志进行瘦身。其原理就是开辟一个子进程对内存进行遍历转换成一系列Redis的操作指令,序列化到一个新的AOF日志文件中。序列化完毕后再将操作期间发生的增量AOF日志追加到这个新的AOF日志文件中,追加完毕后就立即替代旧的AOF日志文件了,瘦身工作就完成了。AOF瘦身重写流程:AOF重写可以通过auto-aof-rewrite-min-size和auto-aof-rewrite-percentage参数控制自动触发,也可以使用bgrewriteaof命令手动触发。子进程执行期间使用copy-on-write机制与父进程共享内存,避免内存消耗翻倍。AOF重写期间还需要维护重写缓冲区,保存新的写入命令避免数据丢失。单机下部署多个实例时,为了防止出现多个子进程执行重写操作,建议做隔离控制,避免CPU和IO资源竞争。Redis4.0混合持久化重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据。我们通常使用AOF日志重放,但是重放AOF日志性能相对rdb来说要慢很多,这样在Redis实 例很大的情况下,启动需要花费很长的时间。Redis4.0为了解决这个问题,带来了一个新的持久化选项——混合持久化。将RDB文件的内容和增量的AOF日志文件存在一起。这里的AOF日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量AOF日志,通常这部分AOF日志很小。于是在Redis重启的时候,可以先加载RDB的内容,然后再重放增量AOF日志就可以完全替代之前的AOF全量文件重放,重启效率因此大幅得到提升。主从同步—简单了解很多企业都没有使用到Redis的集群,但是至少都做了主从。有了主从,当master挂掉的时候,运维让从库过来接管,服务就可以继续,否则master需要经过数据恢复和重启的过程,这就可能会拖很长的时间,影响线上业务的持续服务。Redis通过主从同步功能实现主节点的多个副本。从节点可灵活地通过slaveof命令建立或断开同步流程。同步复制分为:全量复制和部分增量复制主从节点之间维护心跳和偏移量检查机制,保证主从节点通信正常和数据一致。Redis为了保证高性能复制过程是异步的,写命令处理完后直接返回给客户端,不等待从节点复制完成。因此从节点数据集会有延迟情况。即当使用从节点用于读写分离时会存在数据延迟、过期数据、从节点可用性等问题,需要根据自身业务提前作出规避。注意:在运维过程中,主节点存在多个从节点或者一台机器上部署大量主节点的情况下,会有复制风暴的风险。**RedisSentinel(哨兵)**主从复制是Redis分布式的基础,Redis的高可用离开了主从复制将无从进行。后面的我们会讲到Redis的集群模式,集群模式都依赖于本节所讲的主从复制。不过复制功能也不是必须的,如果你将Redis只用来做缓存,也就无需要从库做备份,挂掉了重新启动一下就行。但是只要你使用了Redis的持久化功能,就必须认真对待主从复制,它是系统数据安全的基础保障。举例:如果主节点凌晨3点突发宕机怎么办?就坐等运维从床上爬起来,然后手工进行从主切换,再通知所有的程序把地址统统改一遍重新上线么?毫无疑问,这样的人工运维效率太低,事故发生时估计得至少1个小时才能缓过来。Sentinel负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。客户端来连接集群时,会首先连接sentinel,通过sentinel来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向sentinel要地址,sentinel会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节点切换。如图:消息丢失Redis主从采用异步复制,意味着当主节点挂掉时,从节点可能没有收到全部的同步消息,这部分未同步的消息就丢失了。如果主从延迟特别大,那么丢失的数据就可能会特别多。Sentinel无法保证消息完全不丢失,但是也尽可能保证消息少丢失。它有两个选项可以限制主从延迟过大:min-slaves-to-write1min-slaves-max-lag10第一个参数表示主节点必须至少有一个从节点在进行正常复制,否则就停止对外写服务,丧失可用性。何为正常复制,何为异常复制?这个就是由第二个参数控制的,它的单位是秒,表示如果10s没有收到从节点的反馈,就意味着从节点同步不正常,要么网络断开了,要么一直没有给反馈。Redis最终一致Redis的主从数据是异步同步的,所以分布式的Redis系统并不满足「一致性」要求。当客户端在Redis的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改服务,所以Redis满足「可用性」。Redis保证「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上落后的数据,继续尽力保持和主节点一致。缓存缓存的收益与成本收益:加速读写:CPUL1/L2/L3Cache、浏览器缓存等。因为缓存通常都是全内存的(例如Redis、Memcache),而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。降低后端负载:帮助后端减少访问量和复杂计算,在很大程度降低了后端的负载。成本:数据不一致:缓存层和数据层有时间窗口不一致,和更新策略有关。代码维护成本:加入缓存后,需要同时处理缓存层和存储层的逻辑,增大了开发者维护代码的成本。运维成本:以RedisCluster为例,加入后无形中增加了运维成本。使用场景:降低后端负载:对高消耗的SQL:join结果集/分组统计结果缓存。加速请求响应:利用Redis/Memcache优化IO响应时间。大量写合并为批量写:比如计数器先Redis累加再批量写入DB。缓存更新策略—算法剔除LRU:LeastRecentlyUsed,最近最少使用。LFU:LeastFrequentlyUsed,最不经常使用。FIFO:FirstInFirstOut,先进先出。使用场景:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。一致性:要清理哪些数据是由具体算法决定,开发人员只能决定使用哪种算法,所以数据的一致性是最差的。维护成本:算法不需要开发人员自己来实现,通常只需要配置最大maxmemory和对应的策略即可。缓存更新策略—超时剔除使用场景:超时剔除通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。一致性:一段时间窗口内(取决于过期时间长短)存在一致性问题,即缓存数据和真实数据源的数据不一致。维护成本:维护成本不是很高,只需设置expire过期时间即可,当然前提是应用方允许这段时间可能发生的数据不一致。缓存更新策略—主动更新使用场景:应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。一致性:一致性最高,但如果主动更新发生了问题,那么这条数据很可能很长时间不会更新,所以建议结合超时剔除一起使用效果会更好。维护成本:维护成本会比较高,开发者需要自己来完成更新,并保证更新操作的正确性。缓存更新策略—总结低一致性业务:建议配置最大内存和淘汰策略的方式使用。高一致性业务:可以结合使用超时剔除和主动更新,这样即使主动更新出了问题,也能保证数据过期时间后删除脏数据。缓存可能会遇到的问题缓存穿透:指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。解决方法:布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。缓存雪崩:指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到DB,DB瞬时压力过重雪崩。解决方法:我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。缓存击穿:对于一些设置了过期时间的key,如果这些key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题,这个和缓存雪崩的区别在于这里针对某一key缓存,前者则是很多key。缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。解决方法:互斥锁、永远不过期设置、资源保护等等。缓存无底洞问题:Facebook的工作人员反应2010年已达到3000个memcached节点,储存数千G的缓存。他们发现一个问题–memcached的连接效率下降了,于是添加memcached节点,添加完之后,并没有好转。称为“无底洞”现象。原因:客户端一次批量操作会涉及多次网络操作,也就意味着批量操作会随着实例的增多,耗时会不断增大。服务端网络连接次数变多,对实例的性能也有一定影响。即:更多的机器不代表更多的性能,所谓“无底洞”就是说投入越多不一定产出越多。解决方案有:串行mget、串行IO、并行IO、Hashtag实现等,更多请看:缓存无底洞问题(http://ifeve.com/redis-multiget-hole/)知识拓展缓存与数据库同步策略(如何保证缓存(Redis)与数据库(MySQL)的一致性?)对于热点数据(经常被查询,但不经常被修改的数据),我们一般会将其放入Redis缓存中,以增加查询效率,但需要保证从Redis中读取的数据与数据库中存储的数据最终是一致的,这就是经典的缓存与数据库同步问题。那么,如何保证缓存(Redis)与数据库(MySQL)的一致性呢?根据缓存是删除还是更新,以及操作顺序大概是可以分为下面四种情况:先更新数据库,再更新缓存先更新缓存,再更新数据库先删除缓存,再更新数据库先更新数据库,再删除缓存删除缓存对比更新缓存删除缓存:数据只会写入数据库,不会写入缓存,只会删除缓存更新缓存:数据不但写入数据库,还会写入缓存删除缓存优点:操作简单,无论更新操作是否复杂,直接删除,并且能防止更新出现的线程安全问题缺点:删除后,下一次查询无法在cache中查到,会有一次CacheMiss,这时需要重新读取数据库,高并发下可能会出现上面说的缓存问题更新缓存优点:命中率高,直接更新缓存,不会有CacheMiss的情况缺点:更新缓存消耗较大,尤其在复杂的操作流程中那到底是选择更新缓存还是删除缓存呢,主要取决于更新缓存的复杂度更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率更新缓存的代价很大,此时我们应该更倾向于删除缓存例如:只是简单的更新一下用户积分,只操作一个字段,那就可以采用更新缓存,还有类似秒杀下商品库存数量这种并发下查询频繁的数据,也可以使用更新缓存,不过也要注意线程安全的问题,防止产生脏数据。但是当更新操作的逻辑较复杂时,需要涉及到其它数据,如用户购买商品付款时,需要考虑打折、优惠券、红包等多种因素,这样需要缓存与数据库进行多次交互,将打折等信息传入缓存,再与缓存中的其它值进行计算才能得到最终结果,此时更新缓存的消耗要大于直接淘汰缓存。所以还是要根据业务场景来进行选择,不过大部分场景下删除缓存操作简单,并且带来的副作用只是增加了一次CacheMiss,建议作为通用的处理方式。先更新数据库,再更新缓存这种方式就适合更新缓存的代价很小的数据,例如上面说的用户积分,库存数量这类数据,同样还是要注意线程安全的问题。线程安全角度同时有请求A和请求B进行更新操作,那么会出现线程A更新了数据库线程B更新了数据库线程B更新了缓存线程A更新了缓存这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存,这就导致了脏数据。业务场景角度有如下两种不适合场景:如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是也浪费性能的先更新缓存,再更新数据库这种情况应该是和第一种情况一样会存在线程安全问题的,但是这种情况是有人使用过的,根据书籍《淘宝技术这十年》里,多隆把商品详情页放入缓存,采取的正是先更新缓存,再将缓存中的数据异步更新到数据库这种方式,有兴趣了解的可以查看这篇博客: https://www.cnblogs.com/rjzheng/p/9240611.html还有现在互联网常见的点赞功能,也可以采用这种方式,有兴趣了解的可以查看这篇文章: https://juejin.im/post/5bdc257e6fb9a049ba410098先删除缓存,再更新数据库简单的想一下,好像这种方式不错,就算是第一步删除缓存成功,第二步写数据库失败,则只会引发一次CacheMiss,对数据没有影响,其实仔细一想并发下也很容易导致了脏数据,例如请求A进行写操作,删除缓存请求B查询发现缓存不存在请求B去数据库查询得到旧值请求B将旧值写入缓存请求A将新值写入数据库那怎么解决呢,先看第四种情况(先更新数据库,再删除缓存),后面再统一说第三种和第四种的解决方案。先更新数据库,再删除缓存先说一下,国外有人提出了一个缓存更新套路,名为Cache-AsidePattern:https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中命中:应用程序从cache中取数据,渠道后返回更新:先把数据存到数据库中,成功后再让缓存失效更新操作就是先更新数据库,再删除缓存;读取操作先从缓存取数据,没有,则从数据库中取数据,成功后,放到缓存中;这是标准的设计方案,包括Facebook的论文ScalingMemcacheatFacebook:chrome-extension://ikhdkkncnoglghljlkmcimlnlhkeamad/pdf-viewer/web/viewer.html?file=https%3A%2F%2Fwww.usenix.org%2Fsystem%2Ffiles%2Fconference%2Fnsdi13%2Fnsdi13-final170_update.pdf也使用了这个策略。为什么他们都用这种方式呢,这种情况不存在并发问题么?答案是也存在,但是出现概率比第三种低,例如:请求缓存刚好失效请求A查询数据库,得一个旧值请求B将新值写入数据库请求B删除缓存请求A将查到的旧值写入缓存这样就出现脏数据了,然而,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作删除缓存,所有的这些条件都具备的概率基本并不大,但是还是会有出现的概率。并且假如第一步写数据库成功,第二步删除缓存失败,这样也导致脏数据,请看解决方案。方案三四脏数据解决方案那怎么解决呢,可以采用延时双删策略(缓存双淘汰法),可以将前面所造成的缓存脏数据,再次删除先删除(淘汰)缓存再写数据库(这两步和原来一样)休眠1秒,再次删除(淘汰)缓存或者是先写数据库再删除(淘汰)缓存(这两步和原来一样)休眠1秒,再次删除(淘汰)缓存这个1秒应该看你的业务场景,应该自行评估自己的项目的读数据业务逻辑的耗时,然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百毫秒即可,这么做确保读请求结束,写请求可以删除读请求造成的缓存脏数据。如果你用了MySql的读写分离架构怎么办?,例如:请求A进行写操作,删除缓存请求A将数据写入数据库了,(或者是先更新数据库,后删除缓存)请求B查询缓存发现,缓存没有值请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值请求B将旧值写入缓存数据库完成主从同步,从库变为新值这种情景,就是数据不一致的原因,还是采用延时双删策略(缓存双淘汰法),只是,休眠时间修改为在主从同步的延时时间基础上,加几百毫秒并且为了性能更快,可以把第二次删除缓存可以做成异步的,这样不会阻塞请求了,如果再严谨点,防止第二次删除缓存失败,这个异步删除缓存可以加上重试机制,失败一直重试,直到成功。这里给出两种重试机制参考方案一更新数据库数据缓存因为种种问题删除失败将需要删除的key发送至消息队列自己消费消息,获得需要删除的key继续重试删除操作,直到成功然而,该方案有一个缺点,对业务线代码造成大量的侵入,于是有了方案二,启动一个订阅程序去订阅数据库的Binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作方案二更新数据库数据数据库会将操作信息写入binlog日志当中订阅程序提取出所需要的数据以及key另起一段非业务代码,获得该信息尝试删除缓存操作,发现删除失败将这些信息发送至消息队列重新从消息队列中获得该数据,重试操作上述的订阅Binlog程序在MySql中有现成的中间件叫Canal,可以完成订阅Binlog日志的功能,另外,重试机制,这里采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。**总结:**大部分应该使用的都是第三种或第四种方式,如果都是采用延时双删策略(缓存双淘汰法),可能区别不会很大,不过第四种方式出现脏数据概率是更小点,更多的话还是要结合自身业务场景使用,灵活变通。分布式锁例如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存里进行修改,改完了再存回去。如果这样的操作同时进行了,就会出现并发问题,因为读取和保存状态这两个操作不是原子的。(Wiki解释:所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何contextswitch线程切换。)如图: 这个时候就要使用到分布式锁来限制程序的并发执行。分布式锁本质上要实现的目标就是在Redis里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。占坑一般是使用setnx(setifnotexists)指令,只允许被一个客户端占坑。先来先占,用完了,再调用del指令释放茅坑。setnxlock:codeholetrueOK...dosomethingcritical...dellock:codehole(integer)1但是有个问题,如果逻辑执行到中间出现异常了,可能会导致del指令没有被调用,这样就会陷入死锁,锁永远得不到释放。于是我们在拿到锁之后,再给锁加上一个过期时间,比如5s,这样即使中间出现异常也可以保证5秒之后锁会自动释放。setnxlock:codeholetrueOK>expirelock:codehole5...dosomethingcritical...>dellock:codehole(integer)1如果在setnx和expire之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致expire得不到执行,也会造成死锁。这种问题的根源就在于setnx和expire是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。也许你会想到用Redis事务来解决。但是这里不行,因为expire是依赖于setnx的执行结果的,如果setnx没抢到锁,expire是不应该执行的。事务里没有ifelse分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行。Redis2.8版本中作者加入了set指令的扩展参数,使得setnx和expire指令可以一起执行:setlock:codeholetrueex5nxOK...dosomethingcritical...dellock:codehole上面这个指令就是setnx和expire组合在一起的原子指令,它就是分布式锁的奥义所在。分布式锁存在的问题超时问题:如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这时候锁过期了,第二个线程重新持有了这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了,第三个线程就会在第二个线程逻辑执行完之间拿到了锁。单节点的分布式锁问题:在单Matste的主从Matster-SlaveRedis系统中,正常情况下Client向Master获取锁之后同步给Slave,如果Client获取锁成功之后Master节点挂掉,并且未将该锁同步到Slave,之后在Sentinel的帮助下Slave升级为Master但是并没有之前未同步的锁的信息,此时如果有新的Client要在新Master获取锁,那么将可能出现两个Client持有同一把锁的问题,来看个图来想下这个过程:所以,为了保证自己的锁只能自己释放需要增加唯一性的校验,综上基于单Redis节点的获取锁和释放锁的简单过程如下://获取锁unique_value作为唯一性的校验SETresource_nameunique_valueNXPX30000//释放锁比较unique_value是否相等避免误释放ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end关于分布式锁的Redlock算法Redis性能好并且实现方便,但是单节点的分布式锁在故障迁移时产生安全问题,Redlock算法是Redis的作者Antirez提出的集群模式分布式锁,基于N个完全独立的Redis节点实现分布式锁的高可用。在Redis的分布式环境中,我们假设有N个完全互相独立的Redis节点,在N个Redis实例上使用与在Redis单实例下相同方法获取锁和释放锁。现在假设有5个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:获取当前Unix时间,以毫秒为单位依次尝试从5个实例,使用相同的key和具有唯一性的value获取锁当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,这个很重要如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题关于集群在大数据高并发场景下,单个Redis实例往往会显得捉襟见肘。首先体现在内存上,单个Redis的内存不宜过大,内存太大会导致rdb文件过大,进一步导致主从同步时全量同步时间过长,在实例重启恢复时也会消耗很长的数据加载时间,特别是在云环境下,单个实例内存往往都是受限的。其次体现在CPU的利用率上,单个Redis实例只能利用单个核心,这单个核心要完成海量数据的存取和管理工作压力会非常大。所以孕育而生了Redis集群,集群方案主要有以下几种:Sentinel:Sentinel(哨兵)模式,基于主从复制模式,只是引入了哨兵来监控与自动处理故障Codis:Codis是Redis集群方案之一,令我们感到骄傲的是,它是中国人开发并开源的,来自前豌豆荚中间件团队。Cluster:RedisCluster是Redis的亲儿子,它是Redis作者自己提供的Redis集群化方案。感谢阅读,部分来源于互联网,暂未备注来源~参考:Redis开发与运维:https://book.douban.com/subject/26971561事务和Lua‍脚本:https://whiteccinn.github.io/2020/06/02/Redis/redis%E4%BA%8B%E5%8A%A1%E5%92%8CluaRedisGEO功能使用场景:https://www.cnblogs.com/54chensongxia/p/13813533.htmlRedis与数据库一致性:https://note.dolyw.com/cache/00-DataBaseConsistency.html
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-27 00:59 , Processed in 1.140122 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表