《Redis开发与运维》读书笔记(七)阻塞

第七章 阻塞

Redis产生阻塞的原因:

  • 内在:不合理的使用API或数据结构、CPU饱和、持久化阻塞。
  • 外在:CPU竞争、内存交换、网络问题。

7.1 发现阻塞

发生阻塞时,应用会收到大量Redis超时异常(如JedisConnectionException)。通常会在应用层加入异常统计并通过邮件/短信/微信报警,以便及时发现通知问题。

何时触发报警?一般根据应用的并发量决定,如1分钟内超过10个异常。因为Redis调用API会分散在项目的多处,若每个地方都监听异常并加入监控代码必然难以维护。此时可以借助于日志系统,通过logback或log4j,异常会记录到Appender,默认一般为日志文件,开发可以自定义一个Appender专门统计异常和触发报警逻辑。项目要统一异常处理,所有异常使用error打印。

如logback代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class RedisAppender extends AppenderBase<ILoggingEvent> {
// 使用guava的AtomicLongMap,用于并发计数
public static final AtomicLongMap<String> ATOMIC_LONG_MAP = AtomicLongMap.create();
static {
// 自定义Appender加入到logback的rootLogger中
LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory();
Logger rootLogger = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME);
ErrorStatisticsAppender errorStatisticsAppender = new ErrorStatisticsAppender();
errorStatisticsAppender.setContext(loggerContext);
errorStatisticsAppender.start();
rootLogger.addAppender(errorStatisticsAppender);
}
}

// 重写接收日志事件方法
protected void append(ILoggingEvent event) {
// 只监控error级别日志
if (event.getLevel() == Level.ERROR) {
IThrowableProxy throwableProxy = event.getThrowableProxy();
// 确认抛出异常
if (throwableProxy != null) {
// 以每分钟为key,记录每分钟异常数量
String key = DateUtil.formatDate(new Date(), "yyyyMMddHHmm");
long errorCount = ATOMIC_LONG_MAP.incrementAndGet(key);
if (errorCount > 10) {
// 超过10次触发报警代码
......
}
// 清理历史计数统计,防止极端情况下内存泄露
for (String oldKey : ATOMIC_LONG_MAP.asMap().keySet()) {
if (!StringUtils.equals(key, oldKey)) {
ATOMIC_LONG_MAP.remove(oldKey);
}
}
}
}
}

开发人员接到报警后,需要到线上查看错误日志,如果是Redis集群,该如何判断是哪个节点超时?大部分客户端不会在异常信息中打印ip和port。此时可以修改Redis客户端类库。如Jedis修改Connection下的connect、sendCommand、readProtocolWithCheckingBroken方法专门捕捉连接,发送命令,协议读取事件的异常。

Redis有许多开源的监控系统,会对关键指标全方位监控和异常识别。如命令耗时、慢查询、持久化阻塞、连接拒绝、CPU/内存/网络/磁盘使用过载等。


7.2 内在原因

定位到具体异常节点后,可以从以下几方面排查。

7.2.1 API或数据结构使用不合理

大部分Redis命令执行速度很快,但如包含上万元素的hash结构执行hgetall操作,算法复杂度为O(n),执行速度会很慢。

(1)如何发现慢查询

执行 slowlog get {n} 命令获得最近的n条慢查询,默认会将执行超过10毫秒的命令记录到定长队列中(默认为128),线上建议调整为1毫秒以方便及时发现毫秒级别以上的命令。若命令执行时间在毫秒级,则实例实际OPS只有1000左右。 不记录数据传输时间和命令排队时间,客户端发生阻塞时不一定是命令执行慢,而是在等待其他命令执行。要重点比对异常发生的时间点,确认是否有慢查询造成的命令阻塞排队。

(2)如何发现大对象

执行 redis-cli -h {ip} -p {port} –bigkeys 发现大对象。内部原理是采用分段进行scan操作,把历史扫描过的最大对象统计出来。

根据结果汇总信息方便获取到大对象的键,以及不同类型数据结构的使用情况。

7.2.2 CPU饱和

CPU饱和是指单核CPU使用率达到接近100%。使用top命令查看Redis进程的CPU使用率。当CPU饱和时,会严重影响吞吐量和应用方的稳定。当出现这种情况时,首先判断并发量是否达到极限。使用 redis-cli -h {ip} -p {port} –stat 获取当前使用情况。

如图,每秒平均处理6W+的请求。垂直层面的命令优化很难达到效果,需要做集群化水平扩展来分摊OPS压力。如果只有几百上千的OPS就达到DPU饱和是不合理的,可能使用了高算法复杂度的命令。

也有可能是过度的内存优化,使用info commandstats分析出命令不合理开销时间:

1
cmdstat_hset:calls=198757512,usec=27021957243,usec_per_call=135.95

hset命令算法复杂度为O(1),但平均耗时却达到了135微秒,正常应该为10微秒以下。因为Redis实例为了追求低内存使用量,过度放宽了ziplist的使用条件,修改了hash-max-ziplist-entries和hash-max-ziplist-value配置。hash对象平均存储着上万个元素,针对ziplist的操作复杂度在O(n)到O(n^2^)之间。

7.2.3 持久化阻塞

(1)fork阻塞

fork操作在RDB和AOF重写时,产生共享内存的子进程,fork操作本身耗时很长,必然会导致主线程阻塞。可以执行info stats命令获取lates_fork_usec指标,表示最近一次fork操作耗时,如果超过1秒则需要优化。如避免使用过大的内存实例,规避fork缓慢的操作系统等。

(2)AOF刷盘阻塞

使用AOF持久化时,文件刷盘的方式一般为1秒1次,后台线程每秒对AOF文件做fsync操作。硬盘压力过大时,fsync需要等待,直到写入完成。若主线程发现距离上次fsync成功超过2秒,为了数据安全会阻塞到后台线程执行fsync操作完成。

当发生这种阻塞行为时,打印日志:

1
Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.

也可以查看info persistence中的aof_delayed_fsync指标,每次发生fdatasync阻塞主线程会累加。可以使用iotop查看具体哪个进程导致硬盘过多消耗。

(3)HugePage写操作阻塞

子进程在执行重写期间利用Linux写时复制技术降低内存开销,只有写操作时Redis才复制要修改的内存页。开启Transparent HugePages的操作系统,每次写命令引起的复制内存页单位由4K变为2MB,会拖慢写操作的执行时间。


7.3 外在原因

7.3.1 CPU竞争

  • 进程竞争:Redis是CPU密集型应用,不建议和其他多核CPU密集型服务部署在一起。可以通过top、sar等命令定位CPU消耗的时间点合具体进程。
  • 绑定CPU:为了利用多核CPU,会把多个实例部署在同台机器。将Redis绑定到CPU来避免频繁的上下文切换。

当父进程创建子进程进行RDB/AOF重写时,若做了CPU绑定,会与父进程共享一个CPU。子进程重写时对单核CPU使用率会达到90%以上,导致父子进程激烈竞争CPU。不建议开启持久化或复制的主节点绑定CPU。

7.3.2 内存交换

Redis保证高性能的前提是所有数据在内存中。如果操作系统将部分内存换出到硬盘,会造成Redis性能下降几个数量级。

识别内存交换:

  1. 查询Redis进程号:

    1
    2
    $ redis-cli -p 6383 info server | grep process_id
    process_id:4476
  2. 根据进程号查询内存交换信息:

    1
    2
    3
    4
    5
    6
    7
    $ cat /proc/4476/smaps | grep Swap
    Swap: 0 kB
    Swap: 0 kB
    Swap: 4 kB
    Swap: 0 kB
    Swap: 0 kB
    ......

    如果交换量是个位KB是正常现象。

预防内存交换:

  • 保证机器充足的可用内存。
  • 确保所有Redis实例设置最大可用内存,防止极端情况下Redis内存不可控的增长。
  • 降低系统使用swap的优先级,如 echo 10 > /proc/sys/vm/swappiness

7.3.3 网络问题

(1)连接拒绝

  • 网络闪断:发生在网络割接或带宽耗尽的情况,可以通过 sar -n DEV 查看本机历史流量是否正常,或使用外部系统监控工具(Ganglia)进行识别。需要考虑架构优化,避免客户端与Redis异地跨机房调用。

  • Redis连接拒绝:Redis默认maxclients为10000,连接数超过时会拒绝新的连接进入。info stats的rejected_connections指标记录被拒绝连接的数量。

    1
    2
    $ redis-cli -p 6384 info Stats | grep rejected_connections
    rejected_connections:0

    客户端访问Redis时尽量采用NIO长连接或连接池的方式。默认不会关闭长时间闲置的TCP连接,建议设置top-keepalive和timeout参数主动检查和关闭无效连接。

  • 连接溢出:

    1. 进程限制:需要突破双重限制才能连上Redis,OS会限制进程的资源使用,如可打开最大文件数,通过 ulimit -n 查看。可以适当调大,防止 Too many open files 错误。

    2. backlog队列溢出:系统对于特定端口的TCP连接使用backlog队列保存,默认为511,通过tcp-backlog设置。如果Redis设置大于系统值,则以后者为准。系统使用 echo 511 > /proc/sys/net/core/somaxconn 进行修改。通过 netstat -s | grep overflowed 命令获取因backlog队列溢出造成的连接拒绝统计。

(2)网络延迟

网络延迟取决于物理拓扑和带宽占用情况。

  • 物理拓扑按网络延迟由快到慢:同物理机>同机架>跨机器>同机房>同城机房>异地机房。容灾性正相反。
  • 带宽瓶颈:重点监控机器流量,及时发现网卡打满产生的网络延迟或通信中断等情况、
    • 机器网卡带宽。
    • 机架交换机带宽。
    • 机房之间专线带宽。

测试机器之间网络延迟,使用 redis-cli -h {host} -p {port} 后加入参数:

  • –latency:持续进行延迟测试,分别统计:最小值,最大值,平均值,采样次数。
  • –latency–history:统计结果同 –latency,默认每15秒完成一行统计,可通过 -i 参数控制采样时间。
  • –latency-dist:使用统计图的形式展示延迟统计,每1秒采样一次。

(3)网络软中断

指单个网卡队列只能使用一个CPU,高并发下网卡数据交互都集中在同一个CPU会导致多核无法被利用。一般出现在网络高流量吞吐的场景,使用top+数字1可以看到CPU1的软中断指标过高:

Linux在内核2.6.35以后支持Receive Packet Steering(RPS),实现了在软件层面模拟硬件的多队列网卡功能、


参考:

🔗 《Redis开发与运维》