背景
这个问题其实我在2016年碰到过。APP或者是小程序都有一个版本检查以便于前端进行APP的强制更新。
也适合在后台和to c端前台匹配个人信息安全所需的用户协议、消息推送协议版本进行校验用。
它只是一个API,这个API会在APP或者是小程序的入口入先于首页和to c端进行交互。
很多人觉得这就是一个versionCheck的get请求,返回一个版本号,然后在手机端比对一下版本号,就这么简单,要什么设计呀(典型的产品经理思维)。
简单?
在大促时或者是抢券、领红包场景时,500并发一来,直接整个首页打开时就是白屏。然后用APM工具看了一下,原来 只是因为有一根请求抖动了一下,结果导致了雪崩。
于是,有所谓产品经理、架构师又来设计了。。。唉呀。。。这个API每次走DB,简单,我们现在改走Redis吧。
我还没有来得及说:别介。。。
结果他们做好了、上线了。
于是第二天又是抢券、领红包,这次改完后系统到时顶住了5分钟,第6分钟白屏又来了。然后我们打开APM一看。由于前端的吞吐量打开了,因此外部的请求进一步汹涌进来。最终把Redis连接打爆了,我们看了一下Redis的连接设了10万,好家伙。
于是,一群人傻了,就不知道该怎么办了。
提出解决方案
其实这个问题很简单,因为以上无论是原来的传统设计还是后来改成了走Redis的设计,它都无法响应万级、十万级乃至百万级别的并发。
这种问题就需要使用云原生法则去解决。听听好高大上哈?什么叫云原生?让我们接地气、说“人话”。
云原生有好几个点,其实有一个很重要的点,那就是处理上述这类问题:随着流量不断的增加,你的应用服务也是可以“弹性”扩容的,但是这由于上述设计弹性扩容面临着以下这么一个问题那就是你的云服务器使用K8S不断的弹幅本(集群个数)而你可以使用的“资源”连接却受制于DB、Redis的最大连接数,这是一个很经典的悖论。此时这个应用就不符合“云原生”了。
因此我们要彻底把“中间件”的资源连接限制问题去除掉,因此我们使用了“双缓存”设计机制,如下:
即最后一种设计,它就是在你的Redis层前再挡一层jvm本地内存。
它在取数据时其实就是下面这段逻辑:
- 请求进来先在本地内存里找是否有保存的值;
- 如果本地内存里找不到再找Redis;
- 如果在Redis里再找不到的话就去Db里找;
- 找到后把它放入本地缓存;
这种设计就可以做到随着外部请求不断的扩大、企业只需要单纯的扩大幅本数去顶住并发请求带来的连接压力即可。
它把原先外部请求的连接数对中件间、DB的连接冲击转换成了固定的应用服务器(app1-x)个连接数,成指数级别的削减了系统资源的开销的同时通过不断弹性扩容应用服务器来支持外部汹涌而来的http并发请求数。
核心代码
VersionController.java
为了做演示和比较我在controller里做了两个api:
- versionCheckDoubleBuffer
- versionCheckRedis
package org.mk.demo.doublebuffer.controller;
import javax.annotation.Resource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.mk.demo.doublebuffer.service.VersionCheckService;
import org.mk.demo.doublebuffer.vo.AppVersionCheckBean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import org.mk.demo.util.response.ResponseBean;
import org.mk.demo.util.response.ResponseCodeEnum;
@RestController
public class VersionController {
private Logger logger = LogManager.getLogger(this.getClass());
@Resource
private VersionCheckService versionCheckService;
@GetMapping("/versionCheckDoubleBuffer")
public Mono<ResponseBean> versionCheckDoubleBuffer() {
ResponseBean responseBean = new ResponseBean();
try {
String versionId = versionCheckService.getLatestVersionFromDoubleBuffer();
logger.info(">>>>>>versonId->{}", versionId);
responseBean = new ResponseBean(ResponseCodeEnum.SUCCESS.getCode(), "success", versionId);
} catch (Exception e) {
responseBean = new ResponseBean(ResponseCodeEnum.FAIL.getCode(), "system error");
logger.error(">>>>>>versionCheckFromDoubleBuffer error: {}", e.getMessage(), e);
}
return Mono.just(responseBean);
}
@GetMapping("/versionCheckRedis")
public Mono<ResponseBean> versionCheckRedis() {
ResponseBean responseBean = new ResponseBean();
try {
String versionId = versionCheckService.getLatestVersionFromRedis();
logger.info(">>>>>>versonId->{}", versionId);
responseBean = new ResponseBean(ResponseCodeEnum.SUCCESS.getCode(), "success", versionId);
} catch (Exception e) {
responseBean = new ResponseBean(ResponseCodeEnum.FAIL.getCode(), "system error");
logger.error(">>>>>>versionCheckFromRedis error: {}", e.getMessage(), e);
}
return Mono.just(responseBean);
}
}
VersionCheckService.java
package org.mk.demo.doublebuffer.service;
import java.util.concurrent.TimeUnit;
import javax.annotation.Resource;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.mk.demo.doublebuffer.cache.LocalCache;
import org.mk.demo.doublebuffer.dao.AppVersionCheckDao;
import org.mk.demo.doublebuffer.vo.AppVersionCheckBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.druid.util.StringUtils;
@Service
public class VersionCheckService {
protected Logger logger = LogManager.getLogger(this.getClass());
private final static String VERSION_ID_REDIS = "redis-versionId";
@Resource
private RedisTemplate redisTemplate;
@Resource
private AppVersionCheckDao appVersionCheckDao;
public String getLatestVersionFromRedis() throws Exception {
String versionId = "";
try {
versionId = (String) redisTemplate.opsForValue().get(VERSION_ID_REDIS);
if (StringUtils.isEmpty(versionId)) {// 如果redis里为空走db
logger.info(">>>>>>redis里也找不到开始走db查找");
versionId = appVersionCheckDao.getLatestVersionId();
redisTemplate.opsForValue().set(VERSION_ID_REDIS, versionId, 120, TimeUnit.SECONDS);// db拿出来后塞入Redis
}
} catch (Exception e) {
logger.error(">>>>>>getLatestVersionFromRedis error {}", e.getMessage(), e);
throw new Exception(">>>>>>getLatestVersionFromRedis error :" + e.getMessage(), e);
}
return versionId;
}
public String getLatestVersionFromDoubleBuffer() throws Exception {
String versionId = "";
try {
versionId = getVersion();
} catch (Exception e) {
logger.error(">>>>>>getLatestVersionFromDoubleBuffer error {}", e.getMessage(), e);
throw new Exception(">>>>>>getLatestVersionFromDoubleBuffer error :" + e.getMessage(), e);
}
return versionId;
}
private String getVersion() throws Exception {
String versionId = "";
try {
// 先从本地内存找
versionId = LocalCache.getVersionId("versionId");
if (StringUtils.isEmpty(versionId)) {// 如果本地缓存为空走Redis
logger.info(">>>>>>本地内存找不到开始走redis查找");
versionId = (String) redisTemplate.opsForValue().get(VERSION_ID_REDIS);
if (StringUtils.isEmpty(versionId)) {// 如果redis里为空走db
logger.info(">>>>>>redis里也找不到开始走db查找");
versionId = appVersionCheckDao.getLatestVersionId();
redisTemplate.opsForValue().set(VERSION_ID_REDIS, versionId, 120, TimeUnit.SECONDS);// db拿出来后塞入Redis
}
LocalCache.setVersionId("versionId",versionId);// 如果是从redis或者是从db拿,拿到后都要塞回本地内存中去
} else {
logger.info(">>>>>>李地内存找到了直接就返回了");
}
} catch (Exception e) {
logger.error(">>>>>>getVersion error {}", e.getMessage(), e);
throw new Exception(">>>>>>getVersion error :" + e.getMessage(), e);
}
return versionId;
}
}
AppVersionCheckDao.java
package org.mk.demo.doublebuffer.dao;
import org.mk.demo.doublebuffer.vo.AppVersionCheckBean;
import org.springframework.stereotype.Repository;
@Repository
public interface AppVersionCheckDao {
public String getLatestVersionId();
}
AppVersionCheckDao.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
namespace="org.mk.demo.doublebuffer.dao.AppVersionCheckDao">
<resultMap id="appVersionCheckResultMap"
type="org.mk.demo.doublebuffer.vo.AppVersionCheckBean">
<id column="version_id" property="versionId" javaType="String" />
</resultMap>
<select id="getLatestVersionId" resultType="String">
SELECT version_id
FROM app_version order by created_date desc limit 1
</select>
</mapper>
走双缓存和仅走Redis的比较
我使用了1,000个线程分别对:versionIdFromRedis和versionIdFromDoubleBuffer进行了压测,得到的结果如下:
其实走纯Redis这块压测当我把线程数加到了10,000万,我的Redis被打爆了。
而走双缓存这块即使我把压测的线程增加到了10万个并发(我动用了200个jmeter client),它的这个average response还是在30-40毫秒内。
截图中的error rate大家可以忽略,是因为一个jmeter client我用了500个线程,这对于jmeter所在的OS来说太高了,jmeter client端自身因为http进程堆积从jmeter client处打断了少许和应用服务器间的连接以释放操作系统的tcp连接,因此可以直接忽略。
这个实验足以说明了双缓存带来的好处。
总结
- 双缓存:代码上略显复杂,但是它是符合云原生的,它把原先to c端的请求受制于redis、db的最大连接数变成了可几乎无限、不受限制、只要你使用云和k8s理论上可以无限接受to c端的http并发请求数;
- 如果只使用redis来缓存、加速to c端请求最终会因为redis本身被无限的to c端进入的请求打爆;
- 如果使用传统设计,觉得这只是一个小玩意、只是一个版本check、应该很快的!什么叫应该的?在互联网领域一切拍脑袋最后都会被“自我打脸”;