|
作者:kevine前言在Redis的实际使用过程中,我们经常会面对以下的场景:在Redis上执行同样的命令,为什么有时响应很快,有时却很慢;为什么Redis执行GET、SET、DEL命令耗时也很久;为什么我的Redis突然慢了一波,之后又恢复正常了;为什么我的Redis稳定运行了很久,突然从某个时间点开始变慢了。这时我们还是需要一个全面的排障流程,不能无厘头地进行优化;全面的排障流程可以帮助我们找到真正的根因和性能瓶颈,以及实施正确高效的优化方案。这篇文章我们就从可能导致Redis延迟的方方面面开始,逐步深入排障深水区,以提供一个「全面」的Redis延迟问题排查思路。需要了解的词CopyOnWriteCOW是一种建立在虚拟内存重映射技术之上的技术,因此它需要MMU的硬件支持,MMU会记录当前哪些内存页被标记成只读,当有进程尝试往这些内存页中写数据的时候,MMU就会抛一个异常给操作系统内核,内核处理该异常时为该进程分配一份物理内存并复制数据到此内存地址,重新向MMU发出执行该进程的写操作。内存碎片操作系统负责为每个进程分配物理内存,而操作系统中的虚拟内存管理器保管着由内存分配器分配的实际内存映射如果我们的应用程序需求1GB大小的内存,内存分配器将首先尝试找到一个连续的内存段来存储数据;如果找不到连续的段,则分配器必须将进程的数据分成多个段,从而导致内存开销增加。SWAP顾名思义,当某进程向OS请求内存发现不足时,OS会把内存中暂时不用的数据交换出去,放在SWAP分区中,这个过程称为SWAPOUT。当某进程又需要这些数据且OS发现还有空闲物理内存时,又会把SWAP分区中的数据交换回物理内存中,这个过程称为SWAPIN,详情可参考这篇文章。redis监控指标合理完善的监控指标无疑能大大助力我们的排障,本篇文章中提到了很多的redis监控指标,详情可以参考这篇文章:redis监控指标排除无关原因当我们发现从我们的业务服务发起请求到接收到Redis的回包这条链路慢时,我们需要先排除其它的一些无关Redis自身的原因,如:业务自身准备请求耗时过长;业务服务器到Redis服务器之间的网络存在问题,例如网络线路质量不佳,网络数据包在传输时存在延迟、丢包等情况;网络和通信导致的固有延迟:客户端使用TCP/IP连接或Unix域连接连接到Redis,在1Gbit/s网络下的延迟约为200us,而Unix域Socket的延迟甚至可低至30us,这实际上取决于网络和系统硬件;在网络通信的基础之上,操作系统还会增加了一些额外的延迟(如线程调度、CPU缓存、NUMA等);并且在虚拟环境中,系统引起的延迟比在物理机上也要高得多的结果就是,即使Redis在亚微秒的时间级别上能处理大多数命令,网络和系统相关的延迟仍然是不可避免的。Redis实例所在的机器带宽不足/docker网桥性能问题等。排障事大,但咱也不能冤枉了Redis;首先我们还是应该把其它因素都排除完了,再把焦点关注在业务服务到Redis这条链路上。如以下的火焰图就可以很肯定的说问题出现在Redis上了:在排除无关因素后,如何确认Redis是否真的变慢了?测试流程排除无关因素后,我们可以按照以下基本步骤来判断某一Redis实例是否变慢了:监控并记录一个相对正常的Redis实例(相对低负载、key存储结构简单合理、连接数未满)的相关指标;找到认为表现不符合预期的Redis实例(如使用该实例后业务接口明显变慢),在相同配置的服务器上监控并记录这个实例的相关指标;若表现不符合预期的Redis实例的相关指标明显达不到正常Redis实例的标准(延迟两倍以上、OPS仅为正常实例的1/3、内存碎片率较高等),即可认为这个Redis实例的指标未达到预期。确认是Redis实例的某些指标未达到预期后,我们就可以开始逐步分析拆解可能导致Redis表现不佳的因素,并确认优化方案了。快速清单I'velittletime,givemethechecklist在线上发生故障时,我们都没有那么多时间去深究原因,所以在深入到排障的深水区前,我们可以先从最影响最大的一些问题开始检查,这里是一份「会对redis基本运行造成严重影响的问题」的checklist:确保没有运行阻塞服务器的缓慢命令;使用Redis的耗时命令记录功能来检查这一点;对于EC2用户,请确保使用基于HVM的现代EC2实例,如m3.dium等,否则,fork()系统调用带来的延迟太大了;禁用透明内存大页。使用echonever>/sys/kernel/mm/transparent_hugepage/enabled来禁用它们,然后重新启动Redis进程;如果使用的是虚拟机,则可能存在与Redis本身无关的固有延迟;使用redis-cli--intrinsic-latency100检查延迟,确认该延迟是否符合预期(注意:您需要在服务器上而不是在客户机上运行此命令);启用并使用Redis的延迟监控功能,更好的监控Redis实例中的延迟事件和原因。导致RedisLatency的具体原因如果使用我们的快速清单并不能解决实际的延迟问题,我们就得深入redis性能排障的深水区,多方面逐步深究其中的具体原因了。使用复杂度过高的命令/「大型」命令要找到这样的命令执行记录,需要使用Redis提供的耗时命令统计的功能,查看Redis耗时命令之前,我们需要先在redis.conf中设置耗时命令的阈值;如:设置耗时命令的阈值为5ms,保留近500条耗时命令记录:# The following time is expressed in microseconds, so 1000000 is equivalent# to one second. Note that a negative number disables the slow log, while# a value of zero forces the logging of every command.slowlog-log-slower-than 10000# There is no limit to this length. Just be aware that it will consume memory.# You can reclaim memory used by the slow log with SLOWLOG RESET.slowlog-max-len 128或是直接在redis-cli中使用CONFIG命令配置:# 命令执行耗时超过 5 毫秒,记录耗时命令CONFIG SET slowlog-log-slower-than 5000# 只保留最近 500 条耗时命令CONFIG SET slowlog-max-len 500通过查看耗时命令记录,我们就可以知道在什么时间点,执行了哪些比较耗时的命令。如果应用程序执行的Redis命令有以下特点,那么有可能会导致操作延迟变大:经常使用O(N)以上复杂度的命令,例如SORT,SUNION,ZUNIONSTORE等聚合类命令使用O(N)复杂度的命令,但N的值非常大第一种情况导致变慢的原因是Redis在操作内存数据时,时间复杂度过高,要花费更多的CPU资源。第二种情况导致变慢的原因是处理「大型」redis命令(大请求包体/大返回包体的redis请求),对于这样的命令来说,虽然其只有两次内核态与用户态的上下文切换,但由于redis是单线程处理回调事件的,所以后续请求很有可能被这一个大型请求阻塞,这时可能需要考虑业务请求拆解尽量分批执行,以保证redis服务的稳定性。Bigkeybigkey一般指包含大量数据或大量成员和列表的key,如下所示就是一些典型的bigkey(根据Redis的实际用例和业务场景,bigkey的定义可能会有所不同):value大小为5MB(数据太大)的String包含20000个元素的List(列表中的元素数量过多)有10000个成员的ZSET密钥(成员数量过多)一个大小为100MB的Hashkey,即便只包含1000个成员(key太大)在上一节的耗时命令查询中,如果我们发现榜首并不是复杂度过高的命令,而是SET/DEL等简单命令,这很有可能就是redis实例中存在bigkey导致的。bigkey会导致包括但不限于以下的问题:Redis的内存使用量不断增长,最终导致实例OOM,或者因为达到最大内存限制而导致写入被阻塞和重要key被驱逐;访问偏差导致的资源倾斜,bigkey的存在可能会导致某个Redis实例达到性能瓶颈,从而导致整个集群也达到性能瓶颈;在这种情况下,Redis集群中一个节点的内存使用量通常会因为对bigkey的访问需求而远远超过其他节点,而Redis集群中数据迁移时有一个最小粒度,这意味着该节点上的bigkey占用的内存无法进行balance;由于将bigkey请求从socket读取到Redis占用了几乎所有带宽,Redis的其它请求都会受到影响;删除BigKey时,由于主库长时间阻塞(释放bigkey占用的内存)导致同步中断或主从切换。如何定位bigkey使用redis-cli提供的—-bigkeys参数redis-cli提供了扫描bigkey的option—-bigkeys,执行以下命令就可以扫描redis实例中bigkey的分布情况,以key类型维度输出结果:$ redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01[00.00%] Biggest string found so far...[98.23%] Biggest string found so far-------- summary -------Sampled 829675 keys in the keyspace!Total key length in bytes is 10059825 (avg len 12.13)Biggest string found 'key:291880' has 10 bytesBiggest list found 'mylist:004' has 40 itemsBiggest set found 'myset:2386' has 38 membersBiggest hash found 'myhash:3574' has 37 fieldsBiggest zset found 'myzset:2704' has 42 members36313 strings with 363130 bytes (04.38% of keys, avg size 10.00)787393 lists with 896540 items (94.90% of keys, avg size 1.14)1994 sets with 40052 members (00.24% of keys, avg size 20.09)1990 hashs with 39632 fields (00.24% of keys, avg size 19.92)1985 zsets with 39750 members (00.24% of keys, avg size 20.03)从输出结果我们可以很清晰地看到,每种数据类型所占用的最大内存/拥有最多元素的key是哪一个,以及每种数据类型在整个实例中的占比和平均大小/元素数量。bigkey扫描实际上是Redis执行了SCAN命令,遍历整个实例中所有的key,然后针对key的类型,分别执行STRLEN,LLEN,HLEN,SCARD和ZCARD命令,来获取String类型的长度,容器类型(List,Hash,Set,ZSet)的元素个数。⚠️NOTICE:当执行bigkey扫描时,要注意2个问题:以下是bigkey扫描实际用到的命令的时间复杂度:对线上实例进行bigkey扫描时,Redis的OPS会突增,为了降低扫描过程中对Redis的影响,最好控制一下扫描的频率,指定-i参数即可,它表示扫描过程中每次扫描后休息的时间间隔(秒);扫描结果中,对于容器类型(List,Hash,Set,ZSet)的key,只能扫描出元素最多的key;但一个key的元素多,不一定表示内存占用也多,我们还需要根据业务情况,进一步评估内存占用情况。使用开源的redis-rdb-tools通过redis-rdb-Tools,我们可以根据自己的标准准确分析Redis实例中所有密钥的实际内存使用情况,同时它还可以避免中断在线服务,分析完成后,您可以获得简洁、易于理解的报告。redis-rdb-Tools对rdb文件的分析是离线的,对在线的redis服务没有影响;这无疑是它对比第一种方案最大的优势,但也正是因为是离线分析,其分析结果的实时性可能达不到某些场景下的标准,对大型rdb文件的分析可能需要较长的时间。针对bigkey问题的优化措施:上游业务应避免在不合适的场景写入bigkey(夸张一点:用String存储大型binaryfile),如必须使用,可以考虑进行大key拆分,如:对于string类型的Bigkey,可以考虑拆分成多个key-value;对于hash或者list类型,可以考虑拆分成多个hash或者list。定期清理HASHkey中的无效数据(使用HSCAN和HDEL),避免HASHkey中的成员持续增加带来的bigkey问题。Redis≥4.0中,用UNLINK命令替代DEL,此命令可以把释放key内存的操作,放到后台线程中去执行,从而降低对Redis的影响。Redis≥6.0中,可以开启lazy-free机制(lazyfree-lazy-user-del=yes),在执行DEL命令时,释放内存也会放到后台线程中执行。针对消息队列/生产消费场景的List,Set等,设置过期时间或实现定期清理任务,并配置相关监控以及时处理突发情况(如线上流量暴增,下有服务无法消费等产生的消费积压)。即便我们有一系列的解决方案,我们也要尽量避免在实例中存入bigkey。这是因为bigkey在很多场景下,依旧会产生性能问题;例如,bigkey在分片集群模式下,对于数据的迁移也会有性能影响;以及资源倾斜、数据过期、数据淘汰、透明大页等,都会受到bigkey的影响。Hotkey在讨论bigkey时,我们也经常谈到hotkey,当访问某个密钥的工作量明显高于其他密钥时,我们可以称之为hotkey;以下就是一些hotkey的例子:在一个QPS10w的Redis实例中,只有一个key的QPS达到了7000次;拥有数千个成员、总大小为1MB的哈希键每秒会收到大量的HGETALL请求(在这种情况下,我们将其称为热键,因为访问一个键比访问其他键消耗的带宽要大得多);拥有数万个member的ZSET每秒处理大量的ZRANGE请求(cpu时间明显高于用于其他key请求的cpu时间。同样,我们可以说这种消耗大量CPU的Key就是HotKey)。hotkey通常会带来以下的问题:hotkey会导致较高的CPU负载,并影响其它请求的处理;资源倾斜,对hotkey的请求会集中在个别Redis节点/机器上,而不是shard到不同的Redis节点上,导致内存/CPU负载集中在这个别的节点上,Redis集群利用率不能达到预期;hotkey上的流量可能在流量高峰时突然飙升,导致redisCPU满载甚至缓存服务崩溃,在缓存场景下导致缓存雪崩,大量的请求会直接命中其它较慢的数据源,最终导致业务不可用等不可接受的后果。如何定位hotkey:使用redis-cli提供的—hotkeys参数Redis从4.0版本开始在redis-cli中提供hotkey参数,以方便实例粒度的hotkey分析;它可以返回所有key被访问的次数,但需要先将maxmemorypolicy设置为allkey-LFU。# Scanning the entire keyspace to find hot keys as well as# average sizes per key type. You can use -i 0.1 to sleep 0.1 sec# per 100 SCAN commands (not usually needed).Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. lease note that when switching between policies at runtime LRU and LFU data will take some time to adjust.使用monitor命令Redis的monitor命令可以实时输出Redis接收到的所有请求,包括访问时间、客户端IP、命令和key;我们可以短时间执行monitor命令,并将输出重定向到文件;结束后,可以通过对文件中的请求进行分类和分析来找到这段时间的hotkey。monitor命令会消耗大量CPU、内存和网络资源;因此,对于本身就负载较大的Redis实例来说,monitor命令可能会让性能问题进一步恶化;同时,这种异步采集分析方案的时效性较差,分析的准确性依赖于monitor命令的执行时长;因此,在大多数无法长时间执行该命令的在线场景中,结果的准确性并不好。上游服务针对redis请求进行监控所有的redis请求都来自于上游服务,上游服务可以在上报时进行相关的指标监控、汇总及分析,以定位hotkey;但这样的方式需要上游服务支持,并不独立。针对hotkey问题的优化方案:1.使用pipeline在一些非实时的bigkey请求场景下,我们可以使用pipeline来大幅度降低Redis实例的CPU负载。首先我们要知道,Redis核心的工作负荷是一个单线程在处理,这里指的是——网络IO和命令执行是由一个线程来完成的;而Redis6.0中引入了多线程,在Redis6.0之前,从网络IO处理到实际的读写命令处理都是由单个线程完成的,但随着网络硬件的性能提升,Redis的性能瓶颈有可能会出现在网络IO的处理上,也就是说单个主线程处理网络请求的速度跟不上底层网络硬件的速度。针对此问题,Redis采用多个IO线程来处理网络请求,提高网络请求处理的并行度,但多IO线程只用于处理网络请求,对于命令处理,Redis仍然使用单线程处理。而Redis6.0以前的单线程网络IO模型的处理具体的负载在哪里呢?虽然Redis利用epoll机制实现IO多路复用(即使用epoll监听各类事件,通过事件回调函数进行事件处理),但I/O这一步骤是无法避免且始终由单线程串行处理的,且涉及用户态/内核态的切换,即:从socket中读取请求数据,会从内核态将数据拷贝到用户态(read调用)将数据回写到socket,会将数据从用户态拷贝到内核态(write调用)高频简单命令请求下,用户态/内核态的切换带来的开销被更加放大,最终会导致redis-servercpu满载→redis-serverOPS不及预期→上游服务海量请求超时→最终造成类似缓存穿透的结果,这时我们就可以使用pipeline来处理这样的场景了:redispipeline。众所周知,redispipeline可以让redis-server一次接收一组指令(在内核态中存入输入缓冲区,收到客户端的Exec指令再调用read()syscall)后再执行,减少I/O(即accept->read->write)次数,在高频可聚合命令的场景下使用pipeline可以大大减少socketI/O带来的内核态与用户态之间的上下文切换开销。下面我们进行跑一组基于golangredis客户端的简单高频命令的Benchmark测试(不使用pipeline和使用pipeline对比),同时使用perf对Redis4实例监控上下文切换次数:SetwithoutPipeline(redis4.0.14)perf stat -p 15537 -e context-switches -a sleep 10 erformance counter stats for process id '15537': 96,301 context-switches 10.001575750 seconds time elapsedSetusingPipeline(redis4.0.14)perf stat -p 15537 -e context-switches -a sleep 10 erformance counter stats for process id '15537': 17 context-switches 10.001722488 seconds time elapsed可以看到在不使用pipeline执行高频简单命令时产生了大量的上下文切换,这无疑会占用大量的cpu时间。另一方面,pipeline虽然好用,但是每次pipeline组装的命令个数不能没有节制,否则一次组装pipeline数据量过大,一方面会增加客户端的等待时间,另一方面会造成一定的网络阻塞,可以将一次包含大量命令的pipeline拆分成多次较小的pipeline来完成,比如可以将pipeline的总发送大小控制在内核输入输出缓冲区大小之内(内核的输入输出缓冲区大小一般是4K-8K,在不同操作系统中有所差异,可配置修改),同时控制在单个TCP报文最大值1460字节之内。最大传输单元(MTU—MaximumTransmissionUnit)在以太网中的最大值是1500字节,扣减20个字节的IP头和20个字节的TCP头,即1460字节2.MemCache当hotkey本身可预估,且总大小可控时,我们可以考虑使用MemCache直接存储:省去了Redis接入直接的内存读取,保证高性能摆脱带宽限制但同时它也带来了新的问题:在像k8s这样的高可用多实例架构下,多pod间的同步以及和原始数据库的同步是一个大问题,很有可能导致脏读同样是在多实例的情况下,会带来很多的内存浪费。同时MemCache相比于Redis也少了很多feature,可能不能满足业务需求FeatureRedisMemCache原生支持不同的数据结构✅❌原生支持持久化✅❌横向扩展(replication)✅❌聚合操作✅❌支持高并发✅✅3.Redis读写分离当对hotkey的请求仅仅集中在读上时,我们可以考虑读写分离的Redis集群方案(很多公有云厂商都有提供),针对hotkey的读请求,新增read-onlyreplica来承担读流量,原replica作为热备不提供服务,如下图所示(链式复制架构)。这里我们不展开讲读写分离的其它优势,仅针对读多写少的业务场景来说,使用读写分离的Redis提供了更多的选择,业务可以根据场景选择最适合的规格,充分利用每一个read-onlyreplica的资源,且读写分离架构还有比较好的横向扩容能力、客户端友好等优势。规格QPS带宽1master8-10万读写10-48MB1master+1read-onlyreplica10万写+10万读20-64MB1master+3read-onlyreplica10万写+30万读40-128MBn_master+m_read-onlyreplican_100,000write+m_100,000read10(m+n)MB-32(m+n)MB当然我们也不能忽略读写分离架构的缺点,在有大量写请求的场景中,读写分离架构将不可避免地产生延迟,这很有可能造成脏读,所以读写分离架构不适用于读写负载都较高以及实时性要求较高的场景。Key集中过期当Redis实例表现出的现象是:周期性地在一个小的时间段出现一波延迟高峰时,我们就需要check一下是否有大批量的key集中过期;那么为什么key集中过期会导致Redis延迟变大呢?我们首先来了解一下Redis的过期策略是怎样的,Redis处理过期key的方式有两种——被动方式和主动方式。被动方式key过期的时候不删除,每次从Redis获取key时检查是否过期,若过期,则删除,返回null。优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的。缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,此时的无效缓存是永久暂用在内存中的,那么可能发生内存泄露(无效key占用了大量的内存)。主动方式Redis每100ms执行以下步骤:抽样检查附加了TTL的20个随机key(环境变量ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP,默认为20);删除抽样中所有过期的key;如果超过25%的key过期,重复步骤1。优点:通过限制删除操作的时长和频率,来限制删除操作对CPU时间的占用;同时解决被动方式中无效key存留的问题。缺点:仍然可能有最高达到25%的无效key存留;在CPU时间友好方面,不如被动方式,主动方式会block住主线程。难点:需要合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除,这要根据服务器运行情况和实际需求来决定)。如果Redis实例配置为上面的主动方式的,当Redis中的key集中过期时,Redis需要处理大量的过期key;这无疑会增加Redis的CPU负载和内存使用,可能会使Redis变慢,特别当Redis实例中存在bigkey时,这个耗时会更久;而且这个耗时不会被记录在slowlog中:解决方案:为了避免这种情况,可以考虑以下几种方法:尽量避免key集中过期,如果需要批量插入key(如批量插入一批设置了同样ExpireAt的key),可以通过额外的小量随机过期时间来打散key的过期时间;在Redis4.0以上的版本中提供了lazy-free选项,当删除过期key时,把释放内存的操作放到后台线程中执行,避免阻塞主线程。lazyfree-lazy-expire yes从监控的角度出发,我们还需要建立对expired_keys的实时监控和突增告警,以及时发出告警帮助我们定位到业务中的相关问题。触及maxmemory当我们的Redis实例达到了设置的内存上限时,我们也会很明显地感知到Redis延迟增大。究其原因,当Redis达到maxmemory后,如果继续往Redis中写入数据,Redis将会触发内存淘汰策略来清理一些数据以腾出内存空间,这个过程需要耗费一定的CPU和内存资源,如果淘汰过程中产生了大量的Swap交换或者内存回收,将会导致Redis变慢,甚至可能导致Redis崩溃。常见的驱逐策略有以下几种:noeviction:不删除策略,达到最大内存限制时,如果需要更多内存,直接返回错误信息;大多数写命令都会导致占用更多的内存(有极少数会例外,如DEL);allkeys-lru:所有key通用;优先删除最长时间未被使用(lessrecentlyused,LRU)的key;volatile-lru:只限于设置了expire的部分;优先删除最长时间未被使用(lessrecentlyused,LRU)的key;allkeys-random:所有key通用;随机删除一部分key;volatile-random:只限于设置了expire的部分;随机删除一部分key;volatile-ttl:只限于设置了expire的部分;优先删除剩余时间(timetolive,TTL)短的key;volatile-lfu:addedinRedis4,从设置了expire的key中删除使用频率最低的key;allkeys-lfu:addedinRedis4,从所有key中删除使用频率最低的key。最常用的驱逐策略是allkeys-lru/volatile-lru。⚠️需要注意的是:Redis的淘汰数据的逻辑与删除过期key的一样,也是在命令真正执行之前执行的,也就是说它也会增加我们操作Redis的延迟,并且写OPS越高,延迟也会越明显。另外,如果Redis实例中还存储了bigkey,那么在淘汰删除bigkey释放内存时,也会耗时比较久。解决方案:为了避免Redis达到maxmemory后变慢,可以考虑以下几种解决方案:设置合理的maxmemory,可以根据实际情况设置Redis的maxmemory,避免Redis在运行过程中出现内存不足的情况(大白话就是加钱加内存);开启Redis的持久化功能,以将Redis中的数据持久化到磁盘中,避免数据丢失,并且在Redis重启后可以快速地恢复加载数据;使用Redis的分区功能,将Redis中的数据分散到多个Redis实例中,以减轻单个Redis实例内存淘汰的负载压力;与删除过期key一样,针对淘汰key也可以开启layz-free,把淘汰key释放内存的操作放到后台线程中执行。lazyfree-lazy-eviction yes持久化耗时为了保证Redis数据的安全性,我们可能会开启后台定时RDB和AOFrewrite功能:而为了在后台生成RDB文件,或者在启用AOF持久化的情况下追加写只读AOF文件,Redis都需要fork一个子进程,fork操作(在主线程中运行)本身可能会导致延迟。下图分别是AOF持久化和RDB持久化的流程图:在大多数类Unix系统上,fork的成本都很高,因为它涉及复制与进程相关联的许多对象,尤其是与虚拟内存机制相关联的页表。例如,在Linux/AMD64系统上,内存被划分为4kB的页(如不开启内存大页);而为了将虚拟地址转换为物理地址,每个进程存储了一个页表,该页表包含该进程的地址空间每一页的至少一个指针;一个大小为24GB的Redis实例就会需要一个24GB/4kB*8=48MB的页表。在执行后台持久化时,就需要fork此实例,也就需要为页表分配和复制48MB的内存;这无疑会耗费大量CPU时间,特别是在部分虚拟机上,分配和初始化大内存块本身成本就更高。可以看到在Xen上运行的某些VM的fork耗时比在物理机上要高一个数量级到两个数量级。如何查看fork耗时:我们可以在redis-cli上执行INFO命令,查看latest_fork_usec项:INFO latest_fork_usec# 上一次 fork 耗时,单位为微秒latest_fork_usec:59477这个时间就是主进程在fork子进程期间,整个实例阻塞无法处理客户端请求的时间;这个时间对于大多数业务来说无疑是不能过高的(如达到秒级)。除了定时的数据持久化会生成RDB之外,当主从节点第一次建立数据同步时,主节点也会创建子进程生成RDB,然后发给从节点进行一次全量同步,所以,这个过程也会对Redis产生性能影响。解决方案:更改持久化模式如果Redis的持久化模式为RDB,我们可以尝试使用AOF模式来减少持久化的耗时的突增(AOFrewrite可以是多次的追加写)。优化写入磁盘的速度如果Redis所在的磁盘写入速度较慢,我们可以尝试将Redis迁移到写入速度更快的磁盘上。控制Redis实例的内存用作缓存的Redis实例尽量在10G以下,执行fork的耗时与实例大小有关,实例越大,耗时越久。避免虚拟化部署Redis实例不要部署在虚拟机上,fork的耗时也与系统也有关,虚拟机比物理机耗时更久。合理配置数据持久化策略于低峰期在slave节点执行RDB备份;而对于丢失数据不敏感的业务(例如把Redis当做纯缓存使用),可以关闭AOF和AOFrewrite。降低主从库全量同步的概率适当调大repl-backlog-size参数,避免主从全量同步。开启内存大页在上面提到的定时RDB和AOFrewrite持久化功能中,除了fork本身带来的页表复制的耗时外,还会有内存大页带来的延迟。内存页是用户应用程序向操作系统申请内存的单位,常规的内存页大小是4KB,而Linux内核从2.6.38开始,支持了内存大页机制,该机制允许应用程序以2MB大小为单位,向操作系统申请内存。在开启内存大页的机器上调用bgsave或者bgrewriteaoffork出子进程后,此时主进程依旧是可以接收写请求的,而此时处理写请求,会采用CopyOnWrite(写时复制)的方式操作内存数据(两个进程共享内存大页,仅需复制一份页表)。在写负载较高的Redis实例中,不断处理写命令将导致命令针对几千个内存大页(哪怕只涉及一个内存大页上的一小部分数据更改),导致几乎整个进程内存的COW,这将造成这些写命令巨大的延迟,以及巨大的额外峰值内存。同样的,如果这个写请求操作的是一个bigkey,那主进程在拷贝这个bigkey内存块时,涉及到的内存大页会更多,时间也会更久,十恶不赦的bigkey在这里又一次影响到了性能。无疑在开启AOF/RDB时,我们需要关闭内存大页。我们可以使用以下命令查看是否开启了内存大页:$ cat /sys/kernel/mm/transparent_hugepage/enabled[always] madvise never如果该文件的输出为[always]或[madvise],则透明大页是启用的;如果输出为[never],则透明大页是禁用的在Linux系统中,可以使用以下命令来关闭透明大页:typescriptCopy codeecho never > /sys/kernel/mm/transparent_hugepage/enabledecho never > /sys/kernel/mm/transparent_hugepage/defrag第一行命令将透明大页的使用模式设置为never,第二行命令将透明大页的碎片整理模式设置为never;这样就可以关闭透明大页了。AOF和磁盘I/O造成的延迟针对AOF(AppendOnlyFile)持久化策略来说,除了前面提到的fork子进程追加写文件会带来性能损耗造成延迟。首先我们来详细看一下AOF的实现原理,AOF基本上依赖两个系统调用来完成其工作;一个是WRITE(2),用于将数据写入AppendOnly文件,另一个是fDataync(2),用于刷新磁盘上的内核文件缓冲区,以确保用户指定的持久性级别,而WRITE(2)和fDatync(2)调用都可能是延迟的来源。对WRITE(2)来说,当系统范围的磁盘缓冲区同步正在进行时,或者当输出缓冲区已满并且内核需要刷新磁盘以接受新的写入时,WRITE(2)都会因此阻塞。对fDataync(2)来说情况更糟,因为使用了许多内核和文件系统的组合,我们可能需要几毫秒到几秒的时间才能完成fDataync(2),特别是在某些其它进程正在执行I/O的情况下;因此,Redis2.4之后版本会尽可能在另一个线程执行fDataync(2)调用。解决方案:最直接的解决方案当然是从redis配置出发,那么有哪些配置会影响到这两个系统调用的执行策略呢。我们可以使用appendfsync配置,该配置项提供了三种磁盘缓冲区刷新策略no当appendfsync被设置为no时,redis不会再执行fsync,在这种情况下唯一的延迟来源就只有WRITE(2)了,但这种情况很少见,除非磁盘无法处理Redis接收数据的速度(不太可能),或是磁盘被其他I/O密集型进程严重减慢。这种方案对Redis影响最小,但当Redis所在的服务器宕机时,会丢失一部分数据,为了数据的安全性,一般我们也不采取这种配置。everysec当appendfsync被设置为everysec时,redis每秒执行一次fsync,这项工作在非主线程中完成⚠️需要注意的是:对于用于追加写入AOF文件的WRITE(2)系统调用,如果执行时fsync仍在进行中,Redis将使用一个缓冲区将WRITE(2)调用延迟两秒(因为在Linux上,如果正在对同一文件进行fsync,WRITE就会阻塞);但如果fsync花费的时间太长,即使fsync仍在进行中,Redis最终也会执行WRITE(2)调用,造成延迟。针对这种情况,Redis提供了一个配置项,当子进程在追加写入AOF文件期间,可以让后台子线程不执行刷盘(不触发fsync系统调用)操作,也就是相当于在追加写AOF期间,临时把appendfsync设置为了no,配置如下:# AOF rewrite 期间,AOF 后台子线程不进行刷盘操作# 相当于在这期间,临时把 appendfsync 设置为了 noneno-appendfsync-on-rewrite yes当然,开启这个配置项,在追加写AOF期间,如果实例发生宕机,就会丢失更多的数据。always当appendfsync被设置为always时,每次写入操作时都执行fsync,完成后才会发送response回客户端(实际上,Redis会尝试将同时执行的多个命令聚集到单个fsync中)。在这种模式下,性能通常非常差,如果一定要达到这个持久化的要求并使用这个模式,就需要使用能够在短时间内执行fsync的高速磁盘以及文件系统实现。大多数Redis用户使用no或everysec并且为了最小化AOF带来的延迟,最好也要避免其他进程在同一系统中执行I/O;当然,使用SSD磁盘也会有所帮助(加💰),但通常情况下,即使是非SSD磁盘,如果磁盘没有被其它进程占用,Redis也能在写入AppendOnlyFile时保持良好的性能,因为Redis在写入AppendOnlyFile时不需要任何seek操作。我们可以使用strace命令查看AOF带来的延迟:sudo strace -p $(pidof redis-server) -T -e trace=fdatasync上面的命令将展示Redis在主线程中执行的所有fdatync(2)系统调用,但当appendfsync配置选项设置为everysec时,我们监控不到后台线程执行的fdatync(2);为此我们需将-foption加到上述命令中,这样就可以看到子线程执行的fdatync(2)了。如果需要的话,我们还可以将write添加到trace项中以监控WRITE(2)系统调用:sudo strace -p $(pidof redis-server) -T -e trace=fdatasync,write但是,由于WRITE(2)也用于将数据写入客户端socket以回复客户端请求,该命令也会显示许多与磁盘I/O无关的内容;为了解决这个问题我们可以使用以下命令:sudo strace -f -p $(pidof redis-server) -T -e trace=fdatasync,write 2>&1 | grep -v '0.0' | grep -v unfinishedSWAP导致的延迟Linux(以及许多其它现代操作系统)能够将内存页面从内存重新定位到磁盘,反之亦然,以便有效地使用系统内存。如果内核将Redis内存页从内存移动到SWAP分区,则当存储在该内存页中的数据被Redis使用时(例如,访问存储在该内存页中的key),内核将停止Redis进程,以便将该内存页移回内存;这是一个涉及随机I/O的缓慢磁盘操作(与访问已在内存中的内存页相比慢一到两个数量级),并将导致Redis客户端的异常延迟。Linux内核执行SWAP主要有以下三个原因:系统已使用内存达到内存上限,有可能是Redis使用的内存超过了系统可用内存,也可能是其它进程导致的;Redis实例的数据集或数据集的一部分几乎是完全空闲的(客户端从未访问过),因此内核可以交换内存中的空闲内存页到磁盘;这种问题非常少见,因为即使是中等速度的实例也会经常接触所有内存页,迫使内核将所有内存页保留在内存中;一些进程在系统上产生大量读写I/O。因为文件通常是缓存的,所以它往往会给内核带来增加文件系统缓存的压力,从而产生SWAP;当然,这里说的进程也包括可能产生大文件的RedisRDB和AOF后台线程。我们可以通过以下命令查看Redis的SWAP情况:首先我们获取到redis-server的pid redis-cli info | grep process_idprocess_id:9接下来查看RedisSwap的使用情况:# $pid改为刚刚获取到的redis-server的pidcat /proc/$pid/smaps | egrep '^(Swap|Size)'产生类似下面的输出:Size: 316 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 8 kBSwap: 0 kBSize: 40 kBSwap: 0 kBSize: 132 kBSwap: 0 kBSize: 720896 kBSwap: 12 kBSize: 4096 kBSwap: 156 kBSize: 4096 kBSwap: 8 kBSize: 4096 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 1272 kBSwap: 0 kBSize: 8 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 16 kBSwap: 0 kBSize: 84 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 8 kBSwap: 4 kBSize: 8 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 4 kBSwap: 4 kBSize: 144 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 4 kBSwap: 4 kBSize: 12 kBSwap: 4 kBSize: 108 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 4 kBSwap: 0 kBSize: 272 kBSwap: 0 kBSize: 4 kBSwap: 0 kB每一行Size表示Redis所用的一块内存大小,Size下面的Swap就表示这块Size大小的内存有多少数据已经被换到磁盘上了。从上面的输出中可以看到,有一个720896kB的映射仅交换了12kB,另一个映射中交换了156kB,这些Swap占对应Size的比例很小,所以基本不会产生任何问题。但如果存在SWAP比例较大的输出,那么Redis的延迟很大可能就是SWAP导致的。我们可以使用vmstat命令进一步验证:$ vmstat 1procs -----------memory---------- ---swap-- -----io---- -system-- ----cpu---- r b swpd free buff cache si so bi bo in cs us sy id wa 0 0 3980 697932 147180 1406456 0 0 2 2 2 0 4 4 91 0 0 0 3980 697428 147180 1406580 0 0 0 0 19088 16104 9 6 84 0 0 0 3980 697296 147180 1406616 0 0 0 28 18936 16193 7 6 87 0 0 0 3980 697048 147180 1406640 0 0 0 0 18613 15987 6 6 88 0 2 0 3980 696924 147180 1406656 0 0 0 0 18744 16299 6 5 88 0 0 0 3980 697048 147180 1406688 0 0 0 4 18520 15974 6 6 88 0我们看到si和so这两列,它们分别是内存中SWAP到文件的Size以及从文件中SWAP到内存的Size;如果这两列中存在非零值,则表示系统中存在SWAP活动。最后我们还可以使用iostat命令查看系统的全局I/O活动:$ iostat -xk 1avg-cpu: %user %nice %system %iowait %steal %idle 13.55 0.04 2.92 0.53 0.00 82.95Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await svctm %utilsda 0.77 0.00 0.01 0.00 0.40 0.00 73.65 0.00 3.62 2.58 0.00sdb 1.27 4.75 0.82 3.54 38.00 32.32 32.19 0.11 24.80 4.24 1.85解决方案:这种情况下基本没有什么可以多说的解决方案,无非就两方面:加内存(加💰),没有什么是怼资源无法解决的;减少业务侧对Redis的使用量,包括调整过期时间、优化数据结构、调整缓存策略等等;另一方面自然是做好Redis机器的内存监控以及SWAP事件监控,在内存不足及SWAP事件激增时及时告警。内存碎片Redis内存碎片率(used_memory_rss/used_memory)大于1表示正在发生碎片,内存碎片率超过1.5表示碎片过多,Redis实例消耗了其实际申请的物理内存的150%的内存;另一方面,如果内存碎片率低于1,则表示Redis需要的内存多于系统上的可用内存,这会导致SWAP操作,其中内存交换到磁盘的CPU时间成本将导致Redis延迟显著增加。为什么会产生内存碎片:主要有两大原因:redis自己实现的内存分配器:在redis中新建key-value值时,redis需要向操作系统申请内存,一般的进程在不需要使用申请的内存后,会直接释放掉、归还内存;但redis不一样,redis在使用完内存后并不会直接归还内存,而是放在redis自己实现的内存分配器中管理,这样就不需要每次都向操作系统申请内存了,实现了高性能;但另一方面,未归还的内存自然也就造成了内存碎片。value的更新:redis的每个key-value对初始化的内存大小是最适合的,当这个value改变的并且原来内存块不适用的时候,就需要重新分配内存了;而重新分配之后,就会有一部分内存redis无法正常回收,造成了内存碎片。我们可以通过执行INFO命令快速查询到一个Redis实例的内存碎片率(mem_fragmentation_ratio):[db0] > INFO memory# Memoryused_memory:215489640used_memory_human:205.51M...mem_fragmentation_ratio:1.13mem_fragmentation_bytes:27071448...理想情况下,操作系统将在物理内存中分配一个连续的段,Redis的内存碎片率等于1或略大于1;碎片率过大会导致内存无法有效利用,进而导致redis频繁进行内存分配和回收,从而导致用户请求延迟,并且这个延迟是不会计入slowlog的。如何清理内存碎片:若在Redis config set activedefrag yesOKredis.conf中相关的配置项:# Enabled active defragmentation# 碎片整理总开关# activedefrag yes# Minimum amount of fragmentation waste to start active defrag# 内存碎片达到多少的时候开启整理active-defrag-ignore-bytes 100mb# Minimum percentage of fragmentation to start active defrag# 碎片率达到百分之多少开启整理active-defrag-threshold-lower 10# Maximum percentage of fragmentation at which we use maximum effort# 碎片率小余多少百分比开启整理active-defrag-threshold-upper 100当然,在面对一些复杂的场景时我们希望能根据自己设计的策略来进行内存碎片清理,redis也提供了手动内存碎片清理的命令:127.0.0.1:6379> memory purgeOK绑定CPU单核很多时候,我们在部署服务时,为了提高服务性能,降低应用程序在多个CPU核心之间的上下文切换带来的性能损耗,通常采用的方案是进程绑定CPU的方式提高性能。但VanillaRedis并不适合绑定到单个CPU核心上。一般现代的服务器会有多个CPU,而每个CPU又包含多个物理核心,每个物理核心又分为多个逻辑核心,每个物理核下的逻辑核共用L1/L2Cache。而Redis会fork出非常消耗CPU的后台任务,如BGSAVE或BGREWRITEAOF、异步释放fd、异步AOF刷盘、异步lazy-free等等。如果把Redis进程只绑定了一个CPU逻辑核心上,那么当Redis在进行数据持久化时,fork出的子进程会继承父进程的CPU使用偏好此时子进程就要占用大量的CPU时间,与主进程发生CPU争抢,进而影响到主进程服务客户端请求,访问延迟变大。解决方案:绑定多个逻辑核心如果你确实想要绑定CPU,可以优化的方案是,不要让Redis进程只绑定在一个CPU逻辑核上,而是绑定在多个逻辑核心上,而且,绑定的多个逻辑核心最好是同一个物理核心,这样它们还可以共用L1/L2Cache。当然,即便我们把Redis绑定在多个逻辑核心上,也只能在一定程度上缓解主线程、子进程、后台线程在CPU资源上的竞争,因为这些子进程、子线程还是会在这多个逻辑核心上进行切换,依旧存在性能损耗。针对各个场景绑定固定的CPU逻辑核心Redis6.0以上的版本中,我们可以通过以下配置,对主线程、后台线程、后台RDB进程、AOFrewrite进程,绑定固定的CPU逻辑核心:# Redis Server 和 IO 线程绑定到 CPU核心 0,2,4,6server_cpulist 0-7:2# 后台子线程绑定到 CPU核心 1,3bio_cpulist 1,3# 后台 AOF rewrite 进程绑定到 CPU 核心 8,9,10,11aof_rewrite_cpulist 8-11# 后台 RDB 进程绑定到 CPU 核心 1,10,11# bgsave_cpulist 1,10-1如果使用的正好是Redis6.0以上的版本,就可以通过以上配置,来进一步提高Redis性能;但一般来说,Redis的性能已经足够优秀,除非对Redis的性能有更加严苛的要求,否则不建议绑定CPU。总结Redis排障是一个循序渐进的复杂流程,涉及到Redis运行原理,设计架构以及操作系统,网络等等。作为业务方的Redis使用者,我们需要了解Redis的基本原理,如各个命令的时间复杂度、数据过期策略、数据淘汰策略以及读写分离架构等,从而更合理地使用Redis命令,并结合业务场景进行相关的性能优化。Redis在性能优秀的同时,又是脆弱的;作为Redis的运维者,我们需要在部署Redis时,需要结合实际业务进行容量规划,预留足够的机器资源,配置良好的网络支持,还要对Redis机器和实例做好完善的监控,以保障Redis实例的稳定运行。
|
|