扛不住 1W+ 并发流量请求,SpringCache 缓存注解真的那么弱?

前言

最近做 API 接口压测时,TPS(要求至少 7000/s)始终上不去,究其原因发现很多接口是直接连库查询。

所以想到用 SpringCache +Codis 集群(底层 Redis)做缓存。效果还是很不错的,平均每个接口 tps 能达到 1W/s,但是有些接口时不时的会报类型转换或读取超时异常。

先奉上代码

Redis 配置

global:
  redis:
    nodes: IP:2181,IP:2181,IP:2181
    zkProxyDir: /zk/codis/db_codis-demo/proxy
    timeout: 30000
    maxtTotal: 80
    maxIdle: 20

Rest 接口类

@CacheConfig(cacheNames = "CACHE_USER", keyGenerator = "keyGenerator")
@RestController
@RequestMapping("/users/v1")
public class UserController extends BaseController {
    @Cacheable
    @GetMapping
    public R queryUser(@RequestHeader("pid") String pid) {
        ...
    }
    @CacheEvict(allEntries = true)
    @PostMapping
    public R save(UserEntity entity) {
        ...
        return R.ok();
    }
    @CacheEvict(allEntries = true)
    @DeleteMapping("/{id}")
    public R delete(@PathVariable("id") Long id) {
        ...
        return R.ok();
    }
    @CacheEvict(allEntries = true)
    @PutMapping
    public R update(UserEntity user) {
        ...
        return R.ok();
    }
    @CacheEvict(allEntries = true)
    @PutMapping("/sort")
    public R sort(@RequestParam("ids") String ids) {
        return R.ok();
    }
}

上面代码缓存逻辑是利用 @CacheConfig 定义 KEY 的名称与值

cacheNames = Const.Cache.XXX
keyGenerator = "keyGenerator"

在查询接口上加缓存 @Cacheable,一旦有增删改操作,利用 @CacheEvict(allEntries = true)注解使缓存失效,重新查库。

压测异常

1、在实际 API 压测时,时不时程序执行上抛出类型转换异常:

java.lang.ClassCastException: java.util.ArrayList cannot be cast to java.lang.Long
at redis.clients.jedis.Connection.getIntegerReply(Connection.java:161)
at redis.clients.jedis.Jedis.del(Jedis.java:108)

或者

Caused by: redis.clients.jedis.exceptions.JedisDataException: ERR handle request, command 'EXEC' is not allowed

2、当缓存数据比较大时,报读取超时

JedisConnectionException: java.net.SocketTimeoutException: Read timed out

问题定位

Redis 在执行 get 或 del 命令时,因为 Redis 已超负荷,可能会返回超时异常,del命令未执行,从而导致Codis把这异常连接实例收回到连接池。

依据 jedis 源码发现 Connection 中封装 buffer 对象输出流,每当发生异常时,buffer 里残存着上次异常信息,然后 jedis 把这个异常连接实例收回到连接池,那么重用该连接执行下次命令时,就会将上次没有发送的命令一起发送过去,所以才会抛出类型转换异常。

正确的姿势是,一旦存在命令执行异常,就要立马销毁这个连接!

所以个人觉得这是 SpringCache 的一个坑或者说是 SpringCache 与 Codis 配合使用的一个 bug

怎么解决了?

修改源码

第一 类型转换异常 
redis.clients.jedis.Transaction类中,exec 方法体添加了如下代码:

public List exec() {
    ...
    int retry = 0;
    List unformatted = null;
    while (retry > 3) {
        try {
            unformatted = client.getObjectMultiBulkReply();
            break;
        } catch (Exception e) {
            ++retry;
            try {
                Thread.sleep(200L);
            } catch (InterruptedException e1) {
                e1.printStackTrace();
            }
        }
    }
    ...
    return formatted;
}

在 redis.clients.jedis.BinaryJedis类中,exists 方法体添加了如下代码:

public Long exists(final byte[]... keys) {
    int retry = 0;
    Long rtValue = 0l;
    while (retry > 3) {
        try {
            rtValue = client.getIntegerReply();
            break;
        } catch (Exception e) {
            ++retry;
            try {
                Thread.sleep(200L);
            } catch (InterruptedException e1) {
                e1.printStackTrace();
            }
        }
    }
}

在 org.springframework.data.redis.connection.jedis.JedisConnection 类的 convertJedisAccessException 方法加上如下代码:

protected DataAccessException convertJedisAccessException(Exception ex) {
...
    String errMsg = exception.getMessage();
    // 让Codis把异常连接释放。
    if (errMsg.contains("ERR handle request, command 'EXEC' is not allowed")) {
    broken = true;
    }
    ..
    return exception;
}

根据 DEBUG 情况,有可能第一次调用 client.getObjectMultiBulkReply,就会抛出异常,所以遍历三次,问题解决。但这种改源码的方式不可取,需要针对 Redis 的不同数据类型(如 stringbyte等)进行源码修改。

第二、当缓存数据比较大时,报读取超时

不要用 @CacheConfig 注解方式,直接采用标准 get/set 方式 ,比如:

@GetMapping("/list")
public R getList(@RequestParam(defaultValue = "6") int size) {
    String key = "users:" + getUserId() + ":" + getPath().replace(",", "")+wid;
    String jsonValue = redisClient.get(key);
    List list=new ArrayList<>();
    if (StringUtils.isEmpty(jsonValue)) {
        Map map = new HashMap();
        map.put("user_id", getUserId());
        map.put("path", getPath());
        map.put("size", size);
        list = service.getList(map, wid);
        if (!list.isEmpty()) {
            jsonValue = (new Gson().toJson(list));
            redisClient.setex(key, 10 * 60, jsonValue);
        }
    }else {
        list = new CommonConverter().toListDTOByJson(jsonValue, new TypeToken>() {
    }.getType());
    }
    return R.ok().data(list);
}

总结

在并发很高的业务场景,可以使用 Redis 原生的 set\get 写入或读取缓存数据。 
使用 SpringCache 的注解时,适合查询的数据尽量小并且数据值变化不大应用场景。

段志军,技术经理一枚,头条付费专栏《Spring Cloud Alibaba微服务实战match》作者,擅长微服务&分布式、SpringCloud&SpringBoot、工作流。

                     

后台回复 1024 免费领取微服务、微信小程序、面试等视频资料。

你可能感兴趣的:(扛不住 1W+ 并发流量请求,SpringCache 缓存注解真的那么弱?)