5、value为null引发的Redisson异常之RedisResponseTimeoutException

项目场景:

项目中使用redis存储一些热点数据


问题描述

升级后次日早晨,生产监控群出现大量redis相关异常,异常堆栈信息如下:

org.springframework.dao.QueryTimeoutException: Redis server response timeout (3000 ms) occured after 0 retry attempts. 
Increase nettyThreads and/or timeout settings. Try to define pingConnectionInterval setting. Command: (INCRBY), 
params: [[-84, -19, 0, 5, 116, 0, 18, 101, 110, 113, ...], 9], 
channel: [id: 0xd***e, L:/17*.*.*.*:3***6 - R:r-2zeijkfagqgv2g42ye.redis.rds.aliyuncs.com/17*.*.*.*:6379]; 
nested exception is org.redisson.client.RedisResponseTimeoutException: 
Redis server response timeout (3000 ms) occured after 0 retry attempts. 
Increase nettyThreads and/or timeout settings. Try to define pingConnectionInterval setting. Command: (INCRBY),
params: [[-84, -19, 0, 5, 116, 0, 18, 101, 110, 113, ...], 9], 
channel: [id: 0xdcb***ce, L:/17*.*.*.*:3***6 - R:r-2zeijkfagqgv2g42ye.redis.rds.aliyuncs.com/10.1*****:6379]
	at org.redisson.spring.data.connection.RedissonExceptionConverter.convert(RedissonExceptionConverter.java:48)
	at org.redisson.spring.data.connection.RedissonExceptionConverter.convert(RedissonExceptionConverter.java:35)
	at org.springframework.data.redis.PassThroughExceptionTranslationStrategy.translate(PassThroughExceptionTranslationStrategy.java:44)
	at org.redisson.spring.data.connection.RedissonConnection.transform(RedissonConnection.java:197)
	at org.redisson.spring.data.connection.RedissonConnection.syncFuture(RedissonConnection.java:192)
	at org.redisson.spring.data.connection.RedissonConnection.sync(RedissonConnection.java:360)
	at org.redisson.spring.data.connection.RedissonConnection.write(RedissonConnection.java:726)
	at org.redisson.spring.data.connection.RedissonConnection.incrBy(RedissonConnection.java:574)
	at org.springframework.data.redis.core.DefaultValueOperations.lambda$increment$1(DefaultValueOperations.java:167)
	at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:223)
	at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:190)
	at org.springframework.data.redis.core.AbstractOperations.execute(AbstractOperations.java:97)
	at org.springframework.data.redis.core.DefaultValueOperations.increment(DefaultValueOperations.java:167)

原因分析:

首先排查堆栈,发现代码出现bug,导致出现存入redis中的数据,value为null,当时由于只有极少情况才会出现value为空,就准备晚上修复,开始排查为什么超时。
1.联系运维查看redis运行情况,检查Redis参数配置;(近期未修改,没有发现问题)
2.网络原因;(网络未抖动)
3.开始查看Redis源码,此时有同事反映发现redis存入value为null时,就会出现RedisResponseTimeoutException问题,结合源码和现象发现了问题。继续往下看分析。
4.从日志的表面看,的确就是超时了,第一句话“org.springframework.dao.QueryTimeoutException: Redis server response timeout (3000 ms) occured after 0 retry attempts. Increase nettyThreads and/or timeout settings. Try to define pingConnectionInterval setting. ”表示超过了 3000ms 还没有响应,3000ms 是 redisson 的响应超时时间,那么这个具体是从哪里来的呢?在源码中,(几个重要方法write,writeAsync,async,execute,checkWriteFuture,scheduleResponseTimeout

	<T> T write(byte[] key, Codec codec, RedisCommand<?> command, Object... params) {
        RFuture<T> f = executorService.writeAsync(key, codec, command, params);
        indexCommand(command);
        return sync(f);
    }
	
	public <T, R> RFuture<R> writeAsync(byte[] key, Codec codec, RedisCommand<T> command, Object... params) {
        NodeSource source = getNodeSource(key);
        return async(false, source, codec, command, params, false, false);
    }

	public <V, R> RFuture<R> async(boolean readOnlyMode, NodeSource source, Codec codec,
            RedisCommand<V> command, Object[] params, boolean ignoreRedirect, boolean noRetry) {
        CompletableFuture<R> mainPromise = createPromise();
        RedisExecutor<V, R> executor = new RedisExecutor<>(readOnlyMode, source, codec, command, params, mainPromise,
                                                    ignoreRedirect, connectionManager, objectBuilder, referenceType, noRetry);
        executor.execute();
        return new CompletableFutureWrapper<>(mainPromise);
    }

	public void execute() {
        if (mainPromise.isCancelled()) {
            free();
            return;
        }

        if (!connectionManager.getShutdownLatch().acquire()) {
            free();
            mainPromise.completeExceptionally(new RedissonShutdownException("Redisson is shutdown"));
            return;
        }

        codec = getCodec(codec);

        CompletableFuture<RedisConnection> connectionFuture = getConnection().toCompletableFuture();

        CompletableFuture<R> attemptPromise = new CompletableFuture<>();
        mainPromiseListener = (r, e) -> {
            if (mainPromise.isCancelled() && connectionFuture.cancel(false)) {
                log.debug("Connection obtaining canceled for {}", command);
                timeout.cancel();
                if (attemptPromise.cancel(false)) {
                    free();
                }
            }
        };

        if (attempt == 0) {
            mainPromise.whenComplete((r, e) -> {
                if (this.mainPromiseListener != null) {
                    this.mainPromiseListener.accept(r, e);
                }
            });
        }

        scheduleRetryTimeout(connectionFuture, attemptPromise);

        connectionFuture.whenComplete((connection, e) -> {
            if (connectionFuture.isCancelled()) {
                connectionManager.getShutdownLatch().release();
                return;
            }

            if (connectionFuture.isDone() && connectionFuture.isCompletedExceptionally()) {
                connectionManager.getShutdownLatch().release();
                exception = convertException(connectionFuture);
                return;
            }

            sendCommand(attemptPromise, connection);

            writeFuture.addListener(new ChannelFutureListener() {
                @Override
                public void operationComplete(ChannelFuture future) throws Exception {
                    checkWriteFuture(writeFuture, attemptPromise, connection);
                }
            });
        });

        attemptPromise.whenComplete((r, e) -> {
            releaseConnection(attemptPromise, connectionFuture);

            checkAttemptPromise(attemptPromise, connectionFuture);
        });
    }

	private void checkWriteFuture(ChannelFuture future, CompletableFuture<R> attemptPromise, RedisConnection connection) {
        if (future.isCancelled() || attemptPromise.isDone()) {
            return;
        }

        if (!future.isSuccess()) {
            exception = new WriteRedisConnectionException(
                    "Unable to write command into connection! Increase connection pool size. Node source: " + source + ", connection: " + connection +
                    ", command: " + LogHelper.toString(command, params)
                    + " after " + attempt + " retry attempts", future.cause());
            if (attempt == attempts) {
                attemptPromise.completeExceptionally(exception);
            }
            return;
        }

        timeout.cancel();

        scheduleResponseTimeout(attemptPromise, connection);
    }

	

redisson 的写入操作是一个异步转同步的过程,使用了 sync 方法阻塞等待 future 的结果。既然是异步的过程,它是如何控制超时时间的,答案是org.redisson.command.RedisExecutor#scheduleResponseTimeout方法,这个方法会起一个定时任务,任务的在 3000ms(可配置)之后执行,任务的内容是将操作结果置为超时,也就是说,如果 3000ms 内该任务没有被 cancel 掉,就会抛出超时的异常。核心逻辑是这一段代码:

private void scheduleResponseTimeout(CompletableFuture<R> attemptPromise, RedisConnection connection) {
        long timeoutTime = responseTimeout;
        if (command != null && command.isBlockingCommand()) {
            Long popTimeout = null;
            if (RedisCommands.BLOCKING_COMMANDS.contains(command)) {
                for (int i = 0; i < params.length-1; i++) {
                    if ("BLOCK".equals(params[i])) {
                        popTimeout = Long.valueOf(params[i+1].toString()) / 1000;
                        break;
                    }
                }
            } else {
                popTimeout = Long.valueOf(params[params.length - 1].toString());
            }

            handleBlockingOperations(attemptPromise, connection, popTimeout);
            if (popTimeout == 0) {
                return;
            }
            timeoutTime += popTimeout * 1000;
            // add 1 second due to issue https://github.com/antirez/redis/issues/874
            timeoutTime += 1000;
        }

        long timeoutAmount = timeoutTime;
        TimerTask timeoutResponseTask = timeout -> {
            if (isResendAllowed(attempt, attempts)) {
                if (!attemptPromise.cancel(false)) {
                    return;
                }

                connectionManager.newTimeout(t -> {
                    attempt++;
                    if (log.isDebugEnabled()) {
                        log.debug("attempt {} for command {} and params {}",
                                attempt, command, LogHelper.toString(params));
                    }

                    mainPromiseListener = null;
                    execute();
                }, retryInterval, TimeUnit.MILLISECONDS);
                return;
            }

            attemptPromise.completeExceptionally(
                    new RedisResponseTimeoutException("Redis server response timeout (" + timeoutAmount + " ms) occured"
                            + " after " + attempt + " retry attempts. Increase nettyThreads and/or timeout settings. Try to define pingConnectionInterval setting. Command: "
                            + LogHelper.toString(command, params) + ", channel: " + connection.getChannel()));
        };

        timeout = connectionManager.newTimeout(timeoutResponseTask, timeoutTime, TimeUnit.MILLISECONDS);
    }

上述代码表明writeFuture有一个 Listener,在 write 操作完成的时候进行检查checkWriteFuture。也就是说,在scheduleResponseTimeout之前,实际请求已经发出去了,那么 redis 服务端有没有收到呢?使用 redis-cli 的 monitor 功能查看服务端接收到的请求,的确能看到写入的请求,模拟发现 redis 的服务端看,对 SET 操作的处理也非常快。那问题是不是在响应处理这一端出现的呢?接下来我们来看一看 redisson 在处理请求/响应时到底干了什么,具体可以参考org.redisson.client.handler.RedisChannelInitializer#initChannel方法,代码如下:

@Override
protected void initChannel(Channel ch) throws Exception {
    initSsl(config, ch);
    
    if (type == Type.PLAIN) {
        ch.pipeline().addLast(new RedisConnectionHandler(redisClient));
    } else {
        ch.pipeline().addLast(new RedisPubSubConnectionHandler(redisClient));
    }

    ch.pipeline().addLast(
        connectionWatchdog,
        CommandEncoder.INSTANCE,
        CommandBatchEncoder.INSTANCE);

    if (type == Type.PLAIN) {
        ch.pipeline().addLast(new CommandsQueue());
    } else {
        ch.pipeline().addLast(new CommandsQueuePubSub());
    }

    if (pingConnectionHandler != null) {
        ch.pipeline().addLast(pingConnectionHandler);
    }
    
    if (type == Type.PLAIN) {
        ch.pipeline().addLast(new CommandDecoder(config.getAddress().getScheme()));
    } else {
        ch.pipeline().addLast(new CommandPubSubDecoder(config));
    }

    ch.pipeline().addLast(new ErrorsLoggingHandler());

    config.getNettyHook().afterChannelInitialization(ch);
}

代码中可以看到很多熟悉的身影,比如pingConnectionHandler、CommandEncoder、connectionWatchdog。从以上的代码中,我们可以简单画一下输入和输出的 pipeline,输出(请求 redis)的 pipieline 如下:

ErrorsLoggingHandler -> CommandsQueue -> CommandBatchEncoder -> CommandEncoder

输入(响应 redis,还包括建立连接等)的 pipeline 如下:

RedisConnectionHandler -> ConnectionWatchdog -> PingConnectionHandler -> CommandDecoder -> ErrorsLoggingHandler

在输出即请求链路上,按我们的分析,set null 时在CommandEncoder抛出了异常,中断了请求,在 set 正常值时链路时正常的。在输入即响应链路上,最主要的是CommandDecoder,其他要么是处理日志,要么是处理连接事件,这里我们着重分析一下CommandDecoder,即解码器。解码的第一步很重要,就是取出对应请求时的操作命令,redisson 是这么做的:

protected QueueCommand getCommand(ChannelHandlerContext ctx) {
    Queue<QueueCommandHolder> queue = ctx.channel().attr(CommandsQueue.COMMANDS_QUEUE).get();
    QueueCommandHolder holder = queue.peek();
    if (holder != null) {
        return holder.getCommand();
    }
    return null;
}

它从 channel 中取出了一个 queue,然后从 queue 中 peek 出了QueueCommandHolder作为当前的要处理的命令响应。那既然可以从 queue 中取,那又是什么时候塞入 queue 的呢,我们可以看到在请求的 pipiline 中,有一个CommandsQueue,它是在这里把命令塞到 queue 里面的。

@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
    if (msg instanceof QueueCommand) {
        QueueCommand data = (QueueCommand) msg;
        QueueCommandHolder holder = new QueueCommandHolder(data, promise);

        Queue<QueueCommandHolder> queue = ctx.channel().attr(COMMANDS_QUEUE).get();

        while (true) {
            if (lock.compareAndSet(false, true)) {
                try {
                    queue.add(holder);
                    ctx.writeAndFlush(data, holder.getChannelPromise());
                } finally {
                    lock.set(false);
                }
                break;
            }
        }
    } else {
        super.write(ctx, msg, promise);
    }
}

此时,我们已经大致知道 queue 写入和取出的对应关系,因为 peek 不会对 queue 中的数据做 remove 操作,所以必然有个地方把 queue 中的数据推出,redisson 的做法是在解码之后推出,decode 之后会调用如下的方法:

protected void sendNext(Channel channel) {
    Queue<QueueCommandHolder> queue = channel.attr(CommandsQueue.COMMANDS_QUEUE).get();
    queue.poll();
    state(null);
}

分析到这里,我们的问题其实基本已经有解了,在案例中 stringRedisTemplate set null 之后,由于在CommandEncode阶段抛出了空指针异常,导致请求没发出去,那么肯定不会有CommandDecoder阶段了,根据 pipeline 的顺序,CommandsQueue是在CommandEncoder之前执行,这也意味着 queue 中塞入了 set null(指指令 1)请求指令,而没有解码阶段将其从 queue 中推出,这导致在下一个 set 正常值(指令 2)的命令时,请求发到了 redis,redis 也进行了响应,但是在解码阶段,取出的操作指令是上一个请求(指令 1)的,从而解码异常,而且,由于此时取出的是第一次操作的指令(指令 1),第二次的指令(指令 2)还在等待下一次从 queue 中取出并处理(promise.tryFailure),进而导致正常 set(指令 2)的超时。自此,后续所有的请求都会出现错误,因为解码阶段取出的QueueCommand永远是上一次请求的,直到PinConnectionHandler出错,并断开连接,在ConnectionWatchDog的协同处理下重置整个 channel 的数据。redisson 作为一个成熟的开源框架,有这个 bug 实际是非常不应该的,解决的方案也就清晰了:
前置增加value判断


解决方案:

数据放入Redis前,增加key、value非空判断。

感悟:

在分析 redisson 为什么会异常蔓延的过程中,实际经历的过程远比文中复杂。
一个是 redisson 出错时的日志误导性比较强,看到错误日志的第一反应,要么是网络出错,要么是 redis 服务器出错,
在分析了很久之后才排除了网络和 redis 的原因,
从而转到分析 redisson 自身的实现机制。
另外,在分析 redisson 的过程中,最开始的设想其实是在请求阶段关闭了连接,但是没有释放句柄或者引用,这也耗费了比较长的时间。
在分析过程中,也打印了不少的 TRACE 日志,这也帮助排除了不少的错误方向。
总而言之,分析问题是个比较耗时的过程,也是个不断学习进步的过程,在这个过程中,熟悉了 redisson,也为今后自己的应用代码提供了很好的借鉴。

### RedissonValue 的使用方法 Redisson 是一个用于 Java 应用程序的 Redis 客户端库,它提供了许多高级功能来简化开发过程。其中 `Value` 的概念主要体现在如何通过 Redisson 来操作键值对数据。 #### 1. 基于 RMap 存储和获取 Value Redisson 提供了一个名为 `RMap` 的接口,类似于 Java 的 `HashMap`,可以用来存储和检索 Key-Value 数据。以下是具体的代码示例: ```java import org.redisson.api.RMap; import org.redisson.api.RedissonClient; public class RedissonExample { public static void main(String[] args) { // 初始化 Redisson Client 实例 RedissonClient redisson = ...; // 获取 RMap 对象 RMap<String, String> map = redisson.getMap("myMap"); // 设置键值对 map.put("key", "value"); // 将字符串作为 value [^1] // 获取对应的值 String value = map.get("key"); System.out.println(value); // 输出: value } } ``` 上述代码展示了如何利用 `RMap` 进行简单的键值对存取操作。这里需要注意的是,`put()` 方法会覆盖已存在的键值对,而如果需要原子化更新,则可以通过 Lua 脚本来完成[^3]。 #### 2. 使用 RBucket 存储单个 Value 除了 `RMap` 外,还可以使用 `RBucket` 来保存单一的对象实例或者基本类型的数据。 ```java import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; public class BucketExample { public static void main(String[] args) { // 初始化 Redisson Client 实例 RedissonClient redisson = ...; // 创建一个新的桶 (bucket),并设置其名称为 myKey RBucket<String> bucket = redisson.getBucket("myKey"); // 向桶中放入一个值 bucket.set("Hello, Redisson!"); // 放入字符串类型的值 // 取回该值 String content = bucket.get(); System.out.println(content); // 输出: Hello, Redisson! } } ``` 此部分说明了如何借助 `RBucket` 类型的操作符来进行单独对象级别的管理。对于复杂业务场景下可能涉及多个字段的情况,推荐采用序列化的形式将整个实体类写入到 Redis 当中[^2]。 #### 3. 高级特性——支持泛型与自定义序列化器 当处理更复杂的结构时(比如集合、列表或者其他非基础数据类型),Redisson 允许我们指定相应的容器以及定制专属的序列化机制。 ```java // 自定义 Holder 类 class MyDataHolder<T> implements Serializable { private T data; public MyDataHolder(T data){ this.data=data; } public T getData(){ return data; } } ... // 利用 RBucket 和自定义序列化方式存储对象 MyDataHolder<Integer> holder=new MyDataHolder<>(42); RBucket<MyDataHolder<Integer>> customBucket=redisson.getBucket("custom_bucket",new JacksonJsonSerializer()); customBucket.set(holder); System.out.println(customBucket.get().getData()); // 打印结果应为整数 '42' ``` 在这个例子当中引入了一个新的组件叫做 `JacksonJsonSerializer` ,它可以自动把任意 POJO 映射成 JSON 字符串从而方便持久化至 Redis 。当然也可以替换其他第三方框架如 Kryo 或者 Protobuf 等实现更高效率传输需求下的优化方案。 --- ### 总结 综上所述,在实际项目应用过程中可以根据具体的需求选择合适的 API 接口去适配不同的逻辑单元;无论是简单的小规模缓存还是大规模分布式环境下的协调控制都可以依赖 Redisson 提供的强大工具集达成目标!
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小白de成长之路

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值