Redisson @user_script:1: user_script报错详解

1,378 阅读5分钟

问题说明

线上报错@user_script:1: user_script:1: bad argument #2 to 'unpack' (data string too short)

org.redisson.client.RedisException: ERR Error running script (call to f_5ba8bfbad2a802bbb16bd42936dccf1ba5701d4a): @user_script:1: user_script:1: bad argument #2 to 'unpack' (data string too short). channel: [id: 0xc76f8970, L:/172.20.0.34:59666 - R:r-bp1p81fmrf8i661s51.redis.rds.aliyuncs.com/172.16.122.5:6379] command: (EVAL), promise: java.util.concurrent.CompletableFuture@8dbd602d[Not completed, 1 dependents], params: [local value = redis.call('hget', KEYS[1], ARGV[2]); if value == false then return nil; end; local t, val = struct.unpack('dLc0', value); local expireDate = 92233720368547758; local expireDateScore = redis.call('zscore', KEYS[2], ARGV[2]); if expireDateScore ~= false then expireDate = tonumber(expireDateScore) end; if t ~= 0 then local expireIdle = redis.call('zscore', KEYS[3], ARGV[2]); if expireIdle ~= false then if tonumber(expireIdle) > tonumber(ARGV[1]) then redis.call('zadd', KEYS[3], t + tonumber(ARGV[1]), ARGV[2]); end; expireDate = math.min(expireDate, tonumber(expireIdle)) end; end; if expireDate <= tonumber(ARGV[1]) then return nil; end; local maxSize = tonumber(redis.call('hget', KEYS[5], 'max-size')); if maxSize ~= nil and maxSize ~= 0 then local mode = redis.call('hget', KEYS[5], 'mode'); if mode == false or mode == 'LRU' then redis.call('zadd', KEYS[4], tonumber(ARGV[1]), ARGV[2]); else redis.call('zincrby', KEYS[4], 1, ARGV[2]); end; end; return val; , 5, digit:serve:source, redisson__timeout__set:{digit:serve:source}, redisson__idle__set:{digit:serve:source}, redisson__map_cache__last_access__set:{digit:serve:source}, {digit:serve:source}:redisson_options, 1720429243795, PooledUnsafeDirectByteBuf(ridx: 0, widx: 34, cap: 256)]
	at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:370)
	at org.redisson.client.handler.CommandDecoder.decodeCommand(CommandDecoder.java:198)
	at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:137)
	at org.redisson.client.handler.CommandDecoder.decode(CommandDecoder.java:113)
	at io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:501)
	at io.netty.handler.codec.ReplayingDecoder.callDecode(ReplayingDecoder.java:366)
	at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:276)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
	at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
	at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
	at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
	at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
	at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:163)
	at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:714)
	at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:650)
	at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:576)
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:839)

对应的伪代码是

@Component
public class CacheableTest {

    @Cacheable(cacheNames = "test")
    public String getName(String key) {
        return key;
    }

}

这里使用了SpringCache,集成Redisson

问题解析

@user_script:1: user_script:1: bad argument #2 to 'unpack' (data string too short)是Redis命令行的报错,这里使用了Redisson作为缓存的中间件,所以从RedissonCache入手

先看下集成RedissonCache的代码

implementation("org.redisson:redisson-spring-boot-starter")
@EnableCaching
public class CacheAutoConfiguration {

    @Resource
    private RedissonClient redissonClient;

    private static final String CONFIG_PATH = "classpath*:cache-config.yaml";

    @Override
    public CacheManager cacheManager() {
        var patternResolver = new PathMatchingResourcePatternResolver();
        try {
            var resources = patternResolver.getResources(CONFIG_PATH);
            var config = new HashMap<String, CacheConfig>();
            // 先读取jar里面的配置,再读取项目配置,以便可以覆盖jar的配置
            for (var i = resources.length - 1; i >= 0; i--) {
                var resource = resources[i];
                config.putAll(CacheConfig.fromYAML(resource.getInputStream()));
            }
            return new RedissonSpringCacheManager(redissonClient, config);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

具体可以参考官方文档:github.com/redisson/re…

关键点是RedissonSpringCacheManager#getCache

@Override
public Cache getCache(String name) {
    Cache cache = instanceMap.get(name);
    if (cache != null) {
        return cache;
    }
    if (!dynamic) {
        return cache;
    }
    
    CacheConfig config = configMap.get(name);
    if (config == null) {
        config = createDefaultConfig();
        configMap.put(name, config);
    }
    
    if (config.getMaxIdleTime() == 0 && config.getTTL() == 0 && config.getMaxSize() == 0) {
        return createMap(name, config);
    }
    
    return createMapCache(name, config);
}

private Cache createMap(String name, CacheConfig config) {
    RMap<Object, Object> map = getMap(name, config);
    
    Cache cache = new RedissonCache(map, allowNullValues);
    Cache oldCache = instanceMap.putIfAbsent(name, cache);
    if (oldCache != null) {
        cache = oldCache;
    }
    return cache;
}

protected RMap<Object, Object> getMap(String name, CacheConfig config) {
    if (codec != null) {
        return redisson.getMap(name, codec);
    }
    return redisson.getMap(name);
}

private Cache createMapCache(String name, CacheConfig config) {
    RMapCache<Object, Object> map = getMapCache(name, config);
    
    Cache cache = new RedissonCache(map, config, allowNullValues);
    Cache oldCache = instanceMap.putIfAbsent(name, cache);
    if (oldCache != null) {
        cache = oldCache;
    } else {
        map.setMaxSize(config.getMaxSize());
    }
    return cache;
}

protected RMapCache<Object, Object> getMapCache(String name, CacheConfig config) {
    if (codec != null) {
        return redisson.getMapCache(name, codec);
    }
    return redisson.getMapCache(name);
}

关键代码解析:

  1. maxIdleTime、ttl和maxSize都为0时,创建无时限的缓存对象,使用的是RMap
  2. 其他情况创建对应时限的缓存对象,使用的是RMapCache

按照Redisson官方的说法,同一个key不可以使用不同的Redisson对象,所以最简化的报错复现如下

@Test
void testCache2() throws IOException {
    var cache = createMap("test", new CacheConfig(0, 0)); // 无时限的缓存
    cache.put("222333", new Person("张三", 12));
    cache = createMapCache("test", new CacheConfig(1, 1)); // 有时限的缓存
    cache.put("222333", new Person("张三", 12));
}

private Cache createMap(String name, CacheConfig config) {
    RMap<Object, Object> map = getMap(name, config);
    return new RedissonCache(map, false);
}

protected RMap<Object, Object> getMap(String name, CacheConfig config) {
    return redissonClient.getMap(name);
}

private Cache createMapCache(String name, CacheConfig config) {
    RMapCache<Object, Object> map = getMapCache(name, config);

    Cache cache = new RedissonCache(map, config, false);
    map.setMaxSize(config.getMaxSize());
    return cache;
}

protected RMapCache<Object, Object> getMapCache(String name, CacheConfig config) {
    return redissonClient.getMapCache(name);
}

先创建RMap,然后使用相同的key再创建RMapCache,就会报这个错误

问题解决

虽然知道了问题原因,但是解决方案也想了一下午才想出来

这个问题归根结底是第一次使用的时候没指定缓存失效时间,后来加上了缓存失效的时间,导致此问题。虽然我感觉是Redisson的问题,不过可能是由于Redisson只能用RMap来时限无时效限制的缓存,所以也没法从配置入手。

方案一

最简单的方案就是删除对应的key,然后就会正常,不过此方案比较不靠谱,会导致线上报错。

方案二

由于使用的是Cacheable注解,所以可以考虑实现一个错误处理器,来规避此问题

/**
 * @author <a href="mailto:gcwm99@gmail.com">gcdd1993</a>
 * Created by gcdd1993 on 2024/7/8
 */
@Slf4j
@RequiredArgsConstructor
public class RedissonCacheErrorHandler extends SimpleCacheErrorHandler {

    private final RedissonClient redissonClient;

    @Override
    public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
        handleError(ex, cache, key);
    }

    @Override
    public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
        handleError(ex, cache, key);
    }

    @Override
    public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
        handleError(ex, cache, key);
    }

    private void handleError(RuntimeException ex, Cache cache, Object key) {
        LOGGER.error("handle redisson cache error {} {}", cache.getName(), key, ex);
        if (ex instanceof RedisException) {
            if (ex.getMessage().contains("'unpack' (data string too short)")) {
                try {
                    var map = redissonClient.getMap(cache.getName());
                    if (map != null) {
                        map.deleteAsync();
                    }
                } catch (Exception ignore) {
                    try {
                        var mapCache = redissonClient.getMapCache(cache.getName()); // 删除错误的key
                        if (mapCache != null) {
                            mapCache.deleteAsync();
                        }
                    } catch (Exception ignore1) {
                    }
                }
            }
        }
    }

}

原理是出现上述异常的时候,主动删除对应的cache对象,比如一开始是RMap,出现错误时,就将RMap从Redis移除,此次请求正常返回结果(不使用缓存);下次再命中缓存时就正常了

/**
 * @author <a href="mailto:gcwm99@gmail.com">gcdd1993</a>
 * Created by gcdd1993 on 2024/7/8
 */
@EnableCaching
@ConditionalOnClass(KeyGenerator.class)
public class CacheAutoConfiguration extends CachingConfigurerSupport {

    @Resource
    private RedissonClient redissonClient;

    @Bean
    KeyGenerator hashKeyGenerator() {
        return new HashKeyGenerator();
    }

    private static final String CONFIG_PATH = "classpath*:cache-config.yaml";

    @Override
    public CacheManager cacheManager() {
        var patternResolver = new PathMatchingResourcePatternResolver();
        try {
            var resources = patternResolver.getResources(CONFIG_PATH);
            var config = new HashMap<String, CacheConfig>();
            // 先读取jar里面的配置,再读取项目配置,以便可以覆盖jar的配置
            for (var i = resources.length - 1; i >= 0; i--) {
                var resource = resources[i];
                config.putAll(CacheConfig.fromYAML(resource.getInputStream()));
            }
            return new RedissonSpringCacheManager(redissonClient, config);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public CacheErrorHandler errorHandler() {
        return new RedissonCacheErrorHandler(redissonClient);
    }
}

不要忘记注册一下CacheErrorHandler