性能优化

Redis Optimization | Redis 性能优化实践

大键删除

在 Redis 集群中,应用程序尽量避免使用大键;直接影响容易导致集群的容量和请求出现”倾斜问题“,但在实际生产过程中,总会有业务使用不合理,出现这类大键;因为 Redis 是单线程处理。单个耗时过大命令,导致阻塞其他命令,容易引起应用程序雪崩或 Redis 集群发生故障切换,所以避免在生产环境中使用耗时过大命令。

从 3.4 版本开始,Redis 会支持 lazy delete free 的方式,删除大键的过程不会阻塞正常请求。不过如果使用的是旧版本,那么建议尽量使用批处理的方式对于大键进行删除。

(1)使用 hscan 加上 hdel 删除 Hash 大键 通过 hscan 命令,每次获取 500 个字段,再用 hdel 命令,每次删除 1 个字段。

# Rename the key
newkey = "gc:hashes:" + redis.INCR( "gc:index" )
redis.RENAME("my.hash.key", newkey)

# Delete fields from the hash in batche of 100s
cursor = 0
loop
  cursor, hash_keys = redis.HSCAN(newkey, cursor, "COUNT", 100)
  if hash_keys count > 0
    redis.HDEL(newkey, hash_keys)
  end
  if cursor == 0
    break
  end
end
def del_large_hash():
  r = redis.StrictRedis(host='redis-host1', port=6379)
    large_hash_key ="xxx" #要删除的大hash键名
    cursor = '0'
    while cursor != 0:
        cursor, data = r.hscan(large_hash_key, cursor=cursor, count=500)
        for item in data.items():
                r.hdel(large_hash_key, item[0])

(2)使用 ltrim 删除 List 大键 删除大的 List 键,未使用 scan 命令;通过 ltrim 命令每次删除少量元素。

def del_large_list():
  r = redis.StrictRedis(host='redis-host1', port=6379)
  large_list_key = 'xxx'  #要删除的大list的键名
  while r.llen(large_list_key)>0:
      r.ltrim(large_list_key, 0, -101) #每次只删除最右100个元素

(3)使用 sscan+srem 删除 Set 大键 删除大 set 键,使用 sscan 命令,每次扫描集合中 500 个元素,再用 srem 命令每次删除一个键

def del_large_set():
  r = redis.StrictRedis(host='redis-host1', port=6379)
  large_set_key = 'xxx'   # 要删除的大set的键名
  cursor = '0'
  while cursor != 0:
    cursor, data = r.sscan(large_set_key, cursor=cursor, count=500)
    for item in data:
      r.srem(large_size_key, item)

(4)使用 zremrangebyrank 命令删除 Sorted Key 中大键 删除大的有序集合键,和 List 类似,使用 sortedset 自带的 zremrangebyrank 命令,每次删除 top 100 个元素。

def del_large_sortedset():
  r = redis.StrictRedis(host='large_sortedset_key', port=6379)
  large_sortedset_key='xxx'
  while r.zcard(large_sortedset_key)>0:
    r.zremrangebyrank(large_sortedset_key,0,99)#时间复杂度更低, 每次删除O(log(N)+100)

Redis 阻塞时延分析与讨论

Redis 时延问题分析及应对

Redis 的事件循环在一个线程中处理,作为一个单线程程序,重要的是要保证事件处理的时延短,这样,事件循环中的后续任务才不会阻塞; 当 redis 的数据量达到一定级别后(比如 20G),阻塞操作对性能的影响尤为严重; 下面我们总结下在 redis 中有哪些耗时的场景及应对方法;

耗时长的命令造成阻塞

keys、sort 等命令

keys 命令用于查找所有符合给定模式 pattern 的 key,时间复杂度为 O(N),N 为数据库中 key 的数量。当数据库中的个数达到千万时,这个命令会造成读写线程阻塞数秒; 类似的命令有 sunion sort 等操作; 如果业务需求中一定要使用 keys、sort 等操作怎么办?

解决方案:

image

在架构设计中,有“分流”一招,说的是将处理快的请求和处理慢的请求分离来开,否则,慢的影响到了快的,让快的也快不起来;这在 redis 的设计中 体现的非常明显,redis 的纯内存操作,epoll 非阻塞 IO 事件处理,这些快的放在一个线程中搞定,而持久化,AOF 重写、Master-slave 同步数据这些耗时的操作就单开一个进程来处理,不要慢的影响到快的; 同样,既然需要使用 keys 这些耗时的操作,那么我们就将它们剥离出去,比如单开一个 redis slave 结点,专门用于 keys、sort 等耗时的操作,这些查询一般不会是线上的实时业务,查询慢点就慢点,主要是能完成任务,而对于线上的耗时快的任务没有影响;

smembers 命令

smembers 命令用于获取集合全集,时间复杂度为 O(N),N 为集合中的数量; 如果一个集合中保存了千万量级的数据,一次取回也会造成事件处理线程的长时间阻塞;

解决方案: 和 sort,keys 等命令不一样,smembers 可能是线上实时应用场景中使用频率非常高的一个命令,这里分流一招并不适合,我们更多的需要从设计层面来考虑; 在设计时,我们可以控制集合的数量,将集合数一般保持在 500 个以内; 比如原来使用一个键来存储一年的记录,数据量大,我们可以使用 12 个键来分别保存 12 个月的记录,或者 365 个键来保存每一天的记录,将集合的规模控制在可接受的范围;

如果不容易将集合划分为多个子集合,而坚持用一个大集合来存储,那么在取集合的时候可以考虑使用 SRANDMEMBER key [count];随机返回集合中的指定数量,当然,如果要遍历集合中的所有元素,这个命令就不适合了;

save 命令

save 命令使用事件处理线程进行数据的持久化;当数据量大的时候,会造成线程长时间阻塞(我们的生产上,reids 内存中 1 个 G 保存需要 12s 左右),整个 redis 被 block; save 阻塞了事件处理的线程,我们甚至无法使用 redis-cli 查看当前的系统状态,造成“何时保存结束,目前保存了多少”这样的信息都无从得知;

解决方案: 我没有想到需要用到 save 命令的场景,任何时候需要持久化的时候使用 bgsave 都是合理的选择(当然,这个命令也会带来问题,后面聊到);

fork 产生的阻塞

在 redis 需要执行耗时的操作时,会新建一个进程来做,比如数据持久化 bgsave: 开启 RDB 持久化后,当达到持久化的阈值,redis 会 fork 一个新的进程来做持久化,采用了操作系统的 copy-on-wirte 写时复制策略,子进 程与父进程共享 Page。如果父进程的 Page(每页 4K)有修改,父进程自己创建那个 Page 的副本,不会影响到子进程; fork 新进程时,虽然可共享的数据内容不需要复制,但会复制之前进程空间的内存页表,如果内存空间有 40G(考虑每个页表条目消耗 8 个字节),那么页表大小就有 80M,这个复制是需要时间的,如果使用虚拟机,特别是 Xen 虚拟服务器,耗时会更长; 在我们有的服务器结点上测试,35G 的数据 bgsave 瞬间会阻塞 200ms 以上;

类似的,以下这些操作都有进程 fork;

  • Master 向 slave 首次同步数据:当 master 结点收到 slave 结点来的 syn 同步请求,会生成一个新的进程,将内存数据 dump 到文件上,然后再同步到 slave 结点中;
  • AOF 日志重写:使用 AOF 持久化方式,做 AOF 文件重写操作会创建新的进程做重写;(重写并不会去读已有的文件,而是直接使用内存中的数据写成归档日志);

解决方案: 为了应对大内存页表复制时带来的影响,有些可用的措施:

  1. 控制每个 redis 实例的最大内存量; 不让 fork 带来的限制太多,可以从内存量上控制 fork 的时延; 一般建议不超过 20G,可根据自己服务器的性能来确定(内存越大,持久化的时间越长,复制页表的时间越长,对事件循环的阻塞就延长) 新浪微博给的建议是不超过 20G,而我们虚机上的测试,要想保证应用毛刺不明显,可能得在 10G 以下;
  2. 使用大内存页,默认内存页使用 4KB,这样,当使用 40G 的内存时,页表就有 80M;而将每个内存页扩大到 4M,页表就只有 80K;这样复制页表几乎没有阻塞,同时也会提高快速页表缓冲 TLB(translation lookaside buffer)的命中率;但大内存页也有问题,在写时复制时,只要一个页快中任何一个元素被修改,这个页块都需要复制一份(COW 机制的粒度是页面),这样在写时复制期间,会耗用更多的内存空间;
  3. 使用物理机; 如果有的选,物理机当然是最佳方案,比上面都要省事; 当然,虚拟化实现也有多种,除了 Xen 系统外,现代的硬件大部分都可以快速的复制页表; 但公司的虚拟化一般是成套上线的,不会因为我们个别服务器的原因而变更,如果面对的只有 Xen,只能想想如何用好它;
  4. 杜绝新进程的产生,不使用持久化,不在主结点上提供查询;实现起来有以下方案: 1) 只用单机,不开持久化,不挂 slave 结点。这样最简单,不会有新进程的产生;但这样的方案只适合缓存; 如何来做这个方案的高可用? 要做高可用,可以在写 redis 的前端挂上一个消息队列,在消息队列中使用 pub-sub 来做分发,保证每个写操作至少落到 2 个结点上;因为所有结点的数据相同,只需要用一个结点做持久化,这个结点对外不提供查询;
    image
    2) master-slave:在主结点上开持久化,主结点不对外提供查询,查询由 slave 结点提供,从结点不提供持久化;这样,所有的 fork 耗时的操作都在主结点上,而查询请求由 slave 结点提供; 这个方案的问题是主结点坏了之后如何处理? 简单的实现方案是主不具有可替代性,坏了之后,redis 集群对外就只能提供读,而无法更新;待主结点启动后,再继续更新操作;对于之前的更新操作,可以用 MQ 缓存起来,等主结点起来之后消化掉故障期间的写请求;
    image
    如果使用官方的 Sentinel 将从升级为主,整体实现就相对复杂了;需要更改可用从的 ip 配置,将其从可查询结点中剔除,让前端的查询负载不再落在新主上;然后,才能放开 sentinel 的切换操作,这个前后关系需要保证;

持久化造成的阻塞

执行持久化(AOF / RDB snapshot)对系统性能有较大影响,特别是服务器结点上还有其它读写磁盘的操作时(比如,应用服务和 redis 服务部署在相同结点上,应用服务实时记录进出报日志);应尽可能避免在 IO 已经繁重的结点上开 Redis 持久化;

子进程持久化时,子进程的 write 和主进程的 fsync 冲突造成阻塞

在开启了 AOF 持久化的结点上,当子进程执行 AOF 重写或者 RDB 持久化时,出现了 Redis 查询卡顿甚至长时间阻塞的问题, 此时, Redis 无法提供任何读写操作;

原因分析: Redis 服务设置了 appendfsync everysec, 主进程每秒钟便会调用 fsync(), 要求内核将数据”确实”写到存储硬件里. 但由于服务器正在进行大量 IO 操作, 导致主进程 fsync()/操作被阻塞, 最终导致 Redis 主进程阻塞.

redis.conf 中是这么说的: When the AOF fsync policy is set to always or everysec, and a background saving process (a background save or AOF log background rewriting) is performing a lot of IO against the disk, in some Linux configurations Redis may block too long on the fsync() call. Note that there is no fix for this currently, as even performing fsync in a different thread will block our synchronous write(2) call. 当执行 AOF 重写时会有大量 IO,这在某些 Linux 配置下会造成主进程 fsync 阻塞;

解决方案: 设置 no-appendfsync-on-rewrite yes, 在子进程执行 AOF 重写时, 主进程不调用 fsync()操作;注意, 即使进程不调用 fsync(), 系统内核也会根据自己的算法在适当的时机将数据写到硬盘(Linux 默认最长不超过 30 秒). 这个设置带来的问题是当出现故障时,最长可能丢失超过 30 秒的数据,而不再是 1 秒;

子进程 AOF 重写时,系统的 sync 造成主进程的 write 阻塞

我们来梳理下:

  1. 起因:有大量 IO 操作 write(2) 但未主动调用同步操作
  2. 造成 kernel buffer 中有大量脏数据
  3. 系统同步时,sync 的同步时间过长
  4. 造成 redis 的写 aof 日志 write(2)操作阻塞;
  5. 造成单线程的 redis 的下一个事件无法处理,整个 redis 阻塞(redis 的事件处理是在一个线程中进行,其中写 aof 日志的 write(2)是同步阻塞模式调用,与网络的非阻塞 write(2)要区分开来)

产生 1)的原因:这是 redis2.6.12 之前的问题,AOF rewrite 时一直埋头的调用 write(2),由系统自己去触发 sync。 另外的原因:系统 IO 繁忙,比如有别的应用在写盘;

解决方案: 控制系统 sync 调用的时间;需要同步的数据多时,耗时就长;缩小这个耗时,控制每次同步的数据量;通过配置按比例(vm.dirty_background_ratio)或按值(vm.dirty_bytes)设置 sync 的调用阈值;(一般设置为 32M 同步一次) 2.6.12 以后,AOF rewrite 32M 时会主动调用 fdatasync;

另外,Redis 当发现当前正在写的文件有在执行 fdatasync(2)时,就先不调用 write(2),只存在 cache 里,免得被 block。但如果已经超过两秒都还是这个样子,则会强行执行 write(2),即使 redis 会被 block 住。

AOF 重写完成后合并数据时造成的阻塞

在 bgrewriteaof 过程中,所有新来的写入请求依然会被写入旧的 AOF 文件,同时放到 AOF buffer 中,当 rewrite 完成后,会在主线程把这部分内容合并到临时文件中之后才 rename 成新的 AOF 文件,所以 rewrite 过程中会不断打印”Background AOF buffer size: 80 MB,Background AOF buffer size: 180 MB”,要监控这部分的日志。这个合并的过程是阻塞的,如果产生了 280MB 的 buffer,在 100MB/s 的传统硬盘上,Redis 就要阻塞 2.8 秒;

解决方案: 将硬盘设置的足够大,将 AOF 重写的阈值调高,保证高峰期间不会触发重写操作;在闲时使用 crontab 调用 AOF 重写命令;

Redis 数据丢失

笔者在面试阿里的时候就被问到一个问题,是否遇到过 Redis 数据丢失的情况,因为 Redis 是常用作缓存,如果少量数据丢失,相当于请求”缓冲未命中“;一般对业务的影响是无感知的。因此笔者第一反应是没有丢失,不过挂了电话之后特意去查了些资料,发现 Redis 确实存在数据可能丢失的情况。最常见的丢失情况可能有如下几种:

  • 因为程序错误或者认为失误操作导致数据丢失,譬如误操作执行 flushall/flushdb 这类命令。对于这种潜在的危险情况,可以通过键数监控或者对各类删除命令的执行数监控:cmdtats_flushall, cmdstats_flushdb,cmdstat_del 对应时间范围,确认具体是什么操作。
  • 主库故障后自动重启,可能导致数据全部丢失。这个笔者还真碰到过,时间点 T1,主库故障关闭了,因设置有自动重启的守护程序,时间点 T2 主库被重新拉起,因(T2-T1)时间间隔过小,未达到 Redis 集群或哨兵的主从切换判断时长;这样从库发现主库 runid 变了或断开过,会全量同步主库 rdb 清理,并清理自己的数据。而为保障性能,Redis 主库往往不做数据持久化设置,那么时间点 T2 启动的主库,很有可能是个空实例(或很久前的 rdb 文件)。
  • 主从复制数据不一致,发生故障切换后,出现数据丢失。
  • 网络分区的问题,可能导致短时间的写入数据丢失。这种问题出现丢失数据都很少,网络分区时,Redis 集群或哨兵在判断故障切换的时间窗口,这段时间写入到原主库的数据,5 秒~15 秒的写入量。
  • 客户端缓冲区内存使用过大,导致大量键被 LRU 淘汰

每个 Client 都有一个 query buffer(查询缓存区或输入缓存区), 它用于保存客户端的发送命令,redis server 从 query buffer 获取命令并执行。每个客户端 query buffer 自动动态调整使用内存大小的,范围在 0~1GB 之间;当某个客户端的 query buffer 使用超过 1GB, server 会立即关闭它,为避免过度使用内存,触发 oom killer。query buffer 的大小限制是硬编码的 1GB,没法控制配置参数修改。

server.h#163
/* Protocol and IO related defines */
#define PROTO_MAX_QUERYBUF_LEN  (1024*1024*1024) /* 1GB max query buffer. */

模拟 100 个客户端,连续写入大小为 500MB(生产建议小于 1KB)的 Key; redis server 设置 maxmemory 为 4gb,但 redis 实际已用内存 43gb(见 used_memory)。结论是 query buffer 使用内存不受 maxmemory 的限制,这 BUG 已经提给官方, 如不能限制 redis 使用的内存量,很易导致 redis 过度使用内存,无法控制出现 oom。

127.0.0.1:6390> info memory
# Memory
used_memory:46979129016
used_memory_human:43.75G
used_memory_rss:49898303488
used_memory_rss_human:46.47G
used_memory_peak:54796105584
used_memory_peak_human:51.03G
total_system_memory:134911881216
total_system_memory_human:125.65G
maxmemory:4294967296
maxmemory_human:4.00G
maxmemory_policy:allkeys-random
mem_fragmentation_ratio:1.06
mem_allocator:jemalloc-4.0.3
## 当client断开后,rss会马上释放内存给OS

query buffer 占用内存,会计入 maxmemory, 如果达到 maxmemory 限制,会触发 KEY 的 LRU 淘汰或无法写入新数据。

127.0.0.1:6390> set a b
(error) OOM command not allowed when used memory > 'maxmemory'.
上一页
下一页