前言
最近做 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.XXXkeyGenerator = "keyGenerator"
在查询接口上加缓存 @Cacheable
,一旦有增删改操作,利用 @CacheEvict(allEntries = true)
注解使缓存失效,重新查库。
1、在实际 API
压测时,时不时程序执行上抛出类型转换异常:
java.lang.ClassCastException: java.util.ArrayList cannot be cast to java.lang.Longat 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<Object> exec() { ... int retry = 0; List<Object> 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
的不同数据类型(如 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<UserEntity> list=new ArrayList<>(); if (StringUtils.isEmpty(jsonValue)) { Map<String, Object> map = new HashMap<String, Object>(); 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<List<UserEntity>>() { }.getType()); } return R.ok().data(list);}
在并发很高的业务场景,可以使用 Redis
原生的 set\get
写入或读取缓存数据。
使用 SpringCache
的注解时,适合查询的数据尽量小并且数据值变化不大应用场景。