前言
最近做 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
在 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
的不同数据类型(如 string
、byte
等)进行源码修改。
第二、当缓存数据比较大时,报读取超时
不要用 @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 免费领取微服务、微信小程序、面试等视频资料。