秒杀项目实践(三)——分布式扩展

PunkLu 2019年12月22日 61次浏览

分布式扩展

搭建两台应用服务器

因为单机容量有瓶颈,所以必须进行水平扩展,水平扩展则需要使用nginx反向代理实现。并可通过nginx的负载均衡配置使用轮询等策略。

将当前的这台的服务器单纯当作MySQL数据库服务器,新增以下三台机器:

IP作用
192.168.1.111nginx服务器
192.168.1.112应用服务器1
192.168.1.113应用服务器2

使用以下命令将之前的192.168.1.107上的项目jar包和配置文件传输到两台新的应用服务器上:

scp seckill-1.0-SNAPSHOT.jar deploy.sh application.properties root@192.168.1.112:/usr/local

scp seckill-1.0-SNAPSHOT.jar deploy.sh application.properties root@192.168.1.113:/usr/local

连接上两台新的应用服务器,赋予deploy.sh执行权限:

chmod -R 777 deploy.sh

修改application.properties配置文件,增加数据库链接配置,指向之前的192.168.1.107,需要保证192.168.1.107上的3306端口已经开启对外访问:

spring.datasource.url=jdbc:mysql://192.168.1.107:3306/seckill?serverTimezone=GMT%2B8

启动应用服务器上的项目:

nohup ./deploy.sh &

开启两台应用服务器上的80对外端口后,可以正常访问项目。

使用Nginx路由前端页面

nginx作用:

  1. 作为前端web服务器

  2. 作为动静分离服务器

    静态资源依然和作为web服务器一样放在nginx上供外部访问,动态请求使用反向代理分发到具体的应用服务器上

  3. 作为反向代理服务器

修改前端绑定的IP地址

之前的前端代码中,后台服务器的ip地址和端口是写死的,不利于后续的调试,在前端的htmlStable文件夹下新增gethost.js:

var g_host = "localhost:8090";

并在各个html文件中将localhost:port替换为g_host。

安装OpenResty

切换到ip为192.168.1.111的nginx服务器上,下载OpenResty:

wget https://openresty.org/download/openresty-1.13.6.2.tar.gz

授予权限:

chmod -R 777 openresty-1.13.6.2.tar.gz

解压:

tar -xvzf openresty-1.13.6.2.tar.gz

先安装需要的perl等环境:

yum install perl-devel
yum install readline-devel pcre-devel openssl-devel gcc

安装

cd /openresty-1.13.6.2
./configure
make
make install

openresty已经被安装在了/usr/local/openresty下。

启动nginx:

cd /usr/local/openresty/nginx
sbin/nginx -c conf/nginx.conf

此时,项目已启动在80端口。访问192.168.1.111,可以看到欢迎信息。

上传前端静态资源
cd /htmlStable
scp -r * root@192.168.1.111:/usr/local/openresty/nginx/html/

修改nginx配置文件
cd /usr/local/openresty/nginx/conf
vi nginx.conf

修改以下内容:

location / {
    root   html;
    index  index.html index.htm;
}

为:

location /resources/ {
            alias   /usr/local/openresty/nginx/html/resources/;
            index  index.html index.htm;
        }

代表当请求规则是resouces/开头时,将去/usr/local/openresty/nginx/html/resources/目录下寻找对应的资源。

移动静态文件
cd /usr/local/openresty/nginx/html/
mkdir resources
mv *.html resources/
mv gethost.js resources/
mv static/ resources/

nginx本身提供了修改配置文件后无缝重启的功能:

cd /usr/local/openresty/nginx
sbin/nginx -s reload

重启后,再次使用192.168.1.111/resources/getotp.html访问,即可访问到对应的前端页面。说明之前的配置已经生效,nginx已经实现了前端资源路由的功能。

使用Nginx做反向代理

  1. 设置upstream server
  2. 设置动态请求location为proxy pass路径
  3. 开启tomcat access log验证

修改nginx配置文件:

cd /usr/local/openresty/nginx
vi conf/nginx.conf

在http{}节点内(与server{}节点同级)增加以下配置设置反向代理:

upstream backend_server{
   server 192.168.1.112 weight=1;
   server 192.168.1.113 weight=1;
   keepalive 30;
}

其中weight代表了权重。

keepalive 30;是指keepalive长连接超时时间为30秒。超过30s客户端未发送新的请求才会断开。

再在server{}节点中增加与之前静态资源代理不同的动态资源请求处理:

location / {
	proxy_pass http://backend_server;
	proxy_set_header Host $http_host:$proxy_port;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
	proxy_http_version 1.1;
	proxy_set_header Connection "";
}

其中proxy_pass是指当请求是以/开头时将请求转发到上面配置的backend_server的upstrea上。

proxy_set_header Host $http_host:$proxy_port;项是指将nginx所在的服务器的host和端口号转发到backend_server中定义的服务器。

proxy_set_header X-Real-IP $remote_addr;是指将此nginx服务器接收到的客户端的真实IP转发到具体的应用服务器上。

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;代表nginx作为代理服务器转发了对应的请求。

proxy_http_version 1.1;和proxy_set_header Connection "";是启用HTTP1.1版本并启用长连接避免性能损耗,因为nginx默认的HTTP1.0不支持长连接。

无缝重新加载配置文件:

cd /usr/local/openresty/nginx
sbin/nginx -s reload

使用代理服务器的IP 192.168.1.111访问具体的AJAX请求地址:

http://192.168.1.111/item/get?id=6

可以看到页面上正确返回了对应的商品信息,说明nginx反向代理配置成功。

开启Tomcat的Access Log

切换到两台应用服务器上并新建tomcat文件夹:

cd /usr/local
mkdir tomcat
chmor -R 777 tomcat/

修改application.properties,增加:

server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=/usr/loca/tomcat
server.tomcat.accesslog.pattern=%h %l %u %t %r %s %b %D

%h:客户端IP地址

%l:-

%u:客户端的User

%t:处理时长

%r:对应的Http请求的第一行

%s:HTTP返回状态码

%b:response大小

%D:处理请求的时长

分布式扩展后的性能压测

分别使用JMeter对单台应用服务器和经过nginx反向代理都压测一遍,可以明显发现性能指标提升了将近一倍。

查看当前服务器与另一台服务器的连接数:

netstat -an | grep 192.168.1.112 | wc -l

分布式会话

问题

从以下代码可以看出,当前项目的会话是放在session中的,但是因为后台应用服务器已经变成了两台,所以会出现第一台服务器上有session、第二台没有所以直接返回请求失败的问题,这时就需要引入分布式会话。

 this.request.getSession().setAttribute("IS_LOGIN",true);
        this.request.getSession().setAttribute("LOGIN_USER",userModel);

分布式会话又分基于cookie和基于token两种。基于cookie传输sessionid是将tomcat容器中的session迁移到redis。基于token传输是直接通过java代码将session实现迁移到redis。为了更多的兼容诸如前端、移动端、客户端,要使用基于token的方式来实现。

基于Cookie的分布式会话

新增Maven Redis依赖
<!-- redis依赖 -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Spring 自己实现的将session保存到redis中的实现 -->
    <dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
    </dependency>

引入这两个依赖后,Spring Boot默认的实现就会将对应的session放入到相应的容器内,但是为了自定义,还是需要做个性化的实现。

新增Redis的配置

新增RedisConfig类:

package tech.punklu.seckill.config;

import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.stereotype.Component;

/**
 * Redis的配置
 */
@Component
// 将Redis Session默认的过期时间从1800改成3600
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
}

修改application.properties,增加以下配置:

# 配置Spring Boot对Redis的依赖
spring.redis.host=192.168.1.107
spring.redis.port=6379
spring.redis.database=10
#spring.redis.password=

# 设置jedis连接池
# 最大连接数量
spring.redis.jedis.pool.max-active=50
# 最小连接
spring.redis.jedis.pool.max-idle=20

因为Jedis默认的序列化方式是JDK自带的序列化方式,所以,为了将session中的用户信息放入Redis,需要让UserModel实现序列化接口Serializable。

运行项目登录之后查看redis缓存,可以看到在index为10的缓存下标上增加了三个对应的session缓存K-V值。

重新打包部署

重新打包上传到192.168.1.112和192.168.1.113上,重新启动应用,访问登录页面并登录,可以看到后续的操作都没有问题,不会再报未登陆的提示了,说明分布式会话已经实现了。

基于token的分布式会话

将UserController中原来的login方法中的以下部分:

// 将登陆凭证加入到用户登录成功的session内
this.request.getSession().setAttribute("IS_LOGIN",true);
this.request.getSession().setAttribute("LOGIN_USER",userModel);
return CommonReturnType.create(null);

修改为:

@Autowired
privat RedisTemplate redisTemplate;

String uuidToken = UUID.randomUUID().toString();
uuidToken.replace("-","");

// 建立token和用户登录态之间的联系        redisTemplate.opsForValue().set(uuidToken,userModel);
// 设置超时时间为1小时
redisTemplate.expire(uuidToken,1, TimeUnit.HOURS);
// 将token下发给前端
return CommonReturnType.create(uuidToken);

并将前端登录页面login.html登录成功后的处理方法从:

if(data.status == "success"){
		alert("登陆成功");
		window.location.href="listitem.html";
}

修改为:

if(data.status == "success"){
		alert("登陆成功");
		var token = data.data;
		window.localStorage["token"] = token;
		window.location.href="listitem.html";
}

并修改getitem.html中的下单处理方法从:

$("#createorder").on("click",function(){
			$.ajax({
				type:"POST",
				contentType:"application/x-www-form-urlencoded",
				url:"http://"+g_host+"/order/createorder",
				data:{
					"itemId":g_itemVO.id,
					"amount":1,
					"promoId":g_itemVO.promoId
				},
				xhrFields:{withCredentials:true},
				success:function(data){
					if(data.status == "success"){
						alert("下单成功");
						window.location.reload();
					}else{
						alert("下单失败,原因为"+data.data.errMsg);
						if(data.data.errCode == 20003){
							window.location.href="login.html";
						}
					}
				},
				error:function(data){
					alert("下单失败,原因为"+data.responseText);
				}
			});

		});

修改为:

$("#createorder").on("click",function(){
			var token = window.localStorage["token"];
			if(token == null){
				alert("没有登录,不能下单");
				window.location.href="login.html";
				return false;
			}

			$.ajax({
				type:"POST",
				contentType:"application/x-www-form-urlencoded",
				url:"http://"+g_host+"/order/createorder?token="+token,
				data:{
					"itemId":g_itemVO.id,
					"amount":1,
					"promoId":g_itemVO.promoId
				},
				xhrFields:{withCredentials:true},
				success:function(data){
					if(data.status == "success"){
						alert("下单成功");
						window.location.reload();
					}else{
						alert("下单失败,原因为"+data.data.errMsg);
						if(data.data.errCode == 20003){
							window.location.href="login.html";
						}
					}
				},
				error:function(data){
					alert("下单失败,原因为"+data.responseText);
				}
			});

		});

增加了发送ajax请求前的获取token并校验的逻辑以及将token发送到服务器的逻辑。

修改OrderController中的下单方法中的校验token的逻辑,从

Boolean isLogin =  (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
       if (isLogin == null || !isLogin.booleanValue()){
            throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
       }
       // 获取用户的登录信息
        UserModel userModel = (UserModel)httpServletRequest.getSession().getAttribute("LOGIN_USER");

修改为:

@Autowired
private RedisTemplate redisTemplate;

String token = httpServletRequest.getParameterMap().get("token")[0];
       if (StringUtils.isEmpty(token)){
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
       }
       UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
       if (userModel == null){
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户未登录");
       }

启动项目,重新登录、下单依然可以正常走完流程。

最后按照之前的逻辑重新打包部署到云服务器上。

查询性能优化

之前已经通过分布式扩展大幅提高了应用的性能,但是在对数据库的操作上依然存在性能瓶颈,可以通过使用缓存来优化。

多级缓存:

  1. redis

    redis有集中式管理缓存的特点,是最常见的NO-SQL组件

  2. 热点内部本地缓存

    redis有网络开销,而且redis有集中式负载均衡的性能瓶颈,可以将热点数据放入JVM的本地缓存中

  3. nginx proxy cache缓存

    不管是热点缓存还是redis缓存都要转发到后端的tomcat服务器上,可以在nginx反向代理上使用nginx proxy cache做对应的nginx的缓存

  4. nginx lua缓存

    nginx proxy cache缓存本质上也是nginx在高速的文件系统磁盘上做的缓存策略,也可以使用nginx定制的lua脚本来做nginx上的内存缓存

Redis集中式缓存商品详情页接入

将ItemController中原始的getItem方法从:

@RequestMapping(value = "/list",method = RequestMethod.GET)
    @ResponseBody
    public CommonReturnType listItem(){
        List<ItemModel> itemModelList = itemService.listItem();
        List<ItemVo> itemVoList = itemModelList.stream().map(itemModel -> {
           ItemVo itemVo = this.convertVOFromModel(itemModel);
           return itemVo;
        }).collect(Collectors.toList());
        return CommonReturnType.create(itemVoList);
    }

改成:

@Autowired
private RedisTemplate redisTemplate;

@RequestMapping(value = "/get",method = RequestMethod.GET)
    @ResponseBody
    public CommonReturnType getItem(@RequestParam(name = "id")Integer id){
        // 根据商品的id到redis内获取
        ItemModel itemModel = (ItemModel)redisTemplate.opsForValue().get("item_" + id);
        // 若redis内不存在对应的itemModel,则访问下游service
        if (itemModel == null){
            itemModel = itemService.getItemById(id);
            // 将itemModel保存到redis中
            redisTemplate.opsForValue().set("item_" + id,itemModel);
            // 设置10分钟的失效时间
            redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
        }
        ItemVo itemVo = convertVOFromModel(itemModel);
        return CommonReturnType.create(itemVo);
    }

同时,为了保证数据在redis中的良好可阅读性,修改RedisConfig文件,自定义RedisTemplate:

/**
     * 自定义bean,取代spring boot 对redisTemplate默认的配置
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        // 解决Redis key的序列化方式
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringRedisSerializer);

        // 解决value的序列化方式(JSON)
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        // 对JodaTime格式的个性化
        ObjectMapper objectMapper = new ObjectMapper();
        SimpleModule simpleModule = new SimpleModule();
        simpleModule.addSerializer(DateTime.class,new JodaDateTimeJsonSerializer());
        simpleModule.addDeserializer(DateTime.class, new JodaDateTimeJsonDeserializer());
        
        // 让Jackson在序列化时把解析对象的类型以及值的类型添加上去,方便后续的反序列化
        					  objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        
        objectMapper.registerModule(simpleModule);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        return redisTemplate;
    }

新建serializer包,在其中新建JodaDateTimeJsonSerializer和JodaDateTimeJsonDeserializer处理Jodatime的序列化格式:

package tech.punklu.seckill.serializer;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.joda.time.DateTime;

import java.io.IOException;

/**
 * 个性化Jodatime序列化
 */
public class JodaDateTimeJsonSerializer extends JsonSerializer<DateTime> {
    @Override
    public void serialize(DateTime dateTime, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeString(dateTime.toString("yyyy-MM-dd HH:mm:ss"));
    }
}


package tech.punklu.seckill.serializer;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;

import java.io.IOException;

/**
 * Jodatime反序列化方式
 */
public class JodaDateTimeJsonDeserializer extends JsonDeserializer<DateTime> {
    @Override
    public DateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JsonProcessingException {
        String dateString =jsonParser.readValueAs(String.class);
        DateTimeFormatter formatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss");

        return DateTime.parse(dateString,formatter);
    }
}


再次性能压测

将项目重新打包并部署到192.168.1.112和192.168.1.113上,重新使用JMeter压测/item/get?id=6接口。可以看到压测结果指标再次获得了一次巨大提高。

本地热点缓存

Redis有网络开销等方面的损耗,因此还可以引入本地热点缓存,用于存放热点的、基本不变的、内存可控的数据。考虑到读写的性能与需要淘汰机制以及自动失效功能,这里使用Guava Cache来实现。优点:

  1. 可控制的大小和超时时间

  2. 可配置的lru策略

    最近最少访问的key优先被淘汰

  3. 线程安全

实现

添加Maven依赖

<!-- guava cache -->
    <dependency>
      <groupId>com.google.guava</groupId>
      <artifactId>guava</artifactId>
      <version>18.0</version>
    </dependency>

创建缓存接口类:

package tech.punklu.seckill.service;

/**
 * 封装本地缓存操作类
 */
public interface CacheService {

    /**
     * 存方法
     * @param key
     * @param value
     */
    void setCommonCache(String key,Object value);

    /**
     * 取方法
     * @param key
     * @return
     */
    Object getFromCommonCache(String key);
}

创建其实现类:

package tech.punklu.seckill.service.impl;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import org.springframework.stereotype.Service;
import tech.punklu.seckill.service.CacheService;

import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;

/**
 * 本地缓存操作类的实现
 */
@Service
public class CacheServiceImpl implements CacheService {

    private Cache<String,Object> commonCache = null;

    /**
     * PostConstruct表示Spring 在加载此bean的时候会优先加载此方法
     */
    @PostConstruct
    public void init(){
        commonCache = CacheBuilder.newBuilder()
                // 设置缓存容器的初始容量为10
                .initialCapacity(10)
                // 设置缓存中最大可以存储100个key,超过100个之后会按照LRU的策略移除缓存项
                .maximumSize(100)
                // 设置写缓存后多少秒过期
                .expireAfterWrite(60, TimeUnit.SECONDS).build();
    }

    @Override
    public void setCommonCache(String key, Object value) {
        commonCache.put(key,value);
    }

    @Override
    public Object getFromCommonCache(String key) {
        return commonCache.getIfPresent(key);
    }
}

然后在ItemController中引入此缓存工具类并修改原有获取商品详情的代码逻辑如下:

@Autowired
private CacheService cacheService;

@RequestMapping(value = "/get",method = RequestMethod.GET)
    @ResponseBody
    public CommonReturnType getItem(@RequestParam(name = "id")Integer id){

        ItemModel itemModel =null;
        // 先取本地缓存
        itemModel = (ItemModel) cacheService.getFromCommonCache("item_" + id);
        // 若本地缓存不存在,则尝试从redis或数据库中获取,若获取到,则将其塞入本地缓存中
        if (itemModel == null){
            // 根据商品的id到redis内获取
            itemModel = (ItemModel)redisTemplate.opsForValue().get("item_" + id);
            // 若redis内不存在对应的itemModel,则访问下游service
            if (itemModel == null){
                itemModel = itemService.getItemById(id);
                // 将itemModel保存到redis中
                redisTemplate.opsForValue().set("item_" + id,itemModel);
                // 设置10分钟的失效时间
                redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
            }
            // 填充本地缓存
            cacheService.setCommonCache("item_" + id,itemModel);
        }


        ItemVo itemVo = convertVOFromModel(itemModel);
        return CommonReturnType.create(itemVo);
    }

debug启动项目,断点调试可发现,此时本地缓存已生效,从数据库或Redis中查询出数据之后会将对应的数据保存到本地缓存中。

重新打包压测

重新将项目打包上传至服务器开始压测。可以看到此时的QPS已经到了3000左右,平均值、中位数、90%百分比的值都已经降低到了100毫秒即0.1s左右。

nginx proxy cache缓存实现

使用nginx的缓存来使请求在到达nginx时就直接返回缓存的数据,不用再经过应用服务器,大大提高性能。nginx proxy cache缓存依靠文件系统存索引级的文件,依靠内存缓存文件地址。

连接到nginx的服务器上,修改/usr/local/openresty/nginx/conf配置文件。

在http{}节点中新增以下配置:

# 声明一个cache缓存节点的内容
proxy_cache_path /usr/local/openresty/nginx/tmp_caches levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;

其中,proxy_cache_path代表要保存缓存的文件所在的位置,levels=1:2代表

把之前配置的http{}节点中的:

location / {
                proxy_pass http://backend_server;
                proxy_set_header Host $http_host:$proxy_port;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_http_version 1.1;
                proxy_set_header Connection "";
        }

改成:

location / {
                proxy_pass http://backend_server;
                proxy_cache tmp_cache;
                proxy_cache_key $uri;
                proxy_cache_valid 200 206 304 302 7d;
                proxy_set_header Host $http_host:$proxy_port;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_http_version 1.1;
                proxy_set_header Connection "";
        }

其中,proxy_cache_key即缓存的key就指定为请求的uri,proxy_cache_valid设置为当请求的响应状态码为200 206 304 302 时对结果进行保存,并设置有效期。

重启nginx:

sbin/nginx -s reload

重新在浏览器中访问查询商品信息的接口,并查看两台服务器的access log,可以看到重复访问并不会重复调用应用接口。

重新压测

重新使用JMeter压测,可以看到性能再一次显著提高,并且查看tmp_caches目录下,可以看到对应的缓存文件。但是因为我本地是SSD硬盘,nginx读盘读写速度很快,如果放在普通磁盘上速度是很慢的。

使用lua 脚本

​ 修改nginx配置文件,在server{}节点中新增:

location /staticitem/get{
	default_type "text/html";
	content_by_lua_file ../lua/staticitem.lua;
}

若不指定default_type,使用浏览器访问时将会以文件的形式输出。

cd /usr/local/openresty
mkdir lua
cd lua
vi staticitem.lua

新增:

ngx.say("hello static item lua");

刷新nginx:

sbin/nginx -s reload

使用浏览器访问/staticitem/get,可以看到浏览器显示为:

hello static item lua
shared dic

之前使用nginx的文件系统缓存的方式对于非固态硬盘来说性能比较差,可以使用shared dic替代,shared dic是共享内存字典,所有worker进程可见,可以使用lru淘汰策略。

修改nginx配置文件,在http{}节点中、与server{}节点平级地新增:

lua_shared_dict my_cache 128m;

在lua目录下新建itemshareddic.lua脚本文件:

function get_from_cache(key)
	local cache_ngx = ngx.shared.my_cache
	local value = cache_ngx:get(key)
	return value
end

function set_to_cache(key,value,exptime)
	if not exptime then
		exptime = 0
	end
	local cache_ngx = ngx.shared.my_cache
	local succ,err,forcible = cache_ngx:set(key,value,exptime)
	return succ
end

local args = ngx.req.get_uri_args()
local id = args["id"]
local item_model = get_from_cache("item_"..id)
if item_model == nil then 
	local resp = ngx.location.capture("/item/get?id="..id)
	item_model = resp.body
	set_to_cache("item_"..id,item_model,1*60)
end
ngx.say(item_model)

功能分别就是从ngx.shared.my_cache即nginx地共享内容中取值、向共享内容中放入值以及处理前端传输过来的查询请求(先从nginx缓存中查,查不到就向后端应用查,查到的数据放入nginx共享缓存中),最后把item_model返回给客户端。

修改nginx配置文件,在server{}节点中新增:

location /luaitem/get{
	default_type "application/json";
	content_by_lua_file ../lua/itemshareddic.lua;
}

重启nginx,使用浏览器访问/luaitem/get?id=6,可以看到浏览器正常输出了json数据。

再次压测

将JMeter中的请求路径改为/luaitem/get?id=6,压测可以发现性能有了一个巨大的提升,95%的请求响应时间都落在了0.022秒以内,99%的请求响应时间都在1.2秒内,平均值只有0.044秒。

OpenResty Redis

Shared dic的优势在于它是基于内存的缓存,并且是离用户最近的节点,支持LRU规则淘汰,但是更新机制并不是太好。在这种情况下,可以通过OpenResty Redis直接访问Redis,但是只读不写,若Redis内没有数据,则调用底层应用服务器查询,应用服务器也判断下Redis内有没有数据,没有的话回MySQL读取,读取完放到Redis中,下次请求在Nginx上访问Redis缓存时就可以获取到数据。这样做的好处在于Nginx层不用管更新的操作,更新的操作交由下游去做,下游服务器修改完Redis的数据,OpenResty Redis获取到的数据就是最新的。脏读影响的面很小。而且可以配置Redis主从配置,OpenResty Redis上只从Redis Slave节点上获取数据,做到读写分离,减小Redis Master节点的压力。

实现

进入OpenResty下的lualib文件夹下的resty目录:

cd /usr/local/openresty/lualib/redis

可以看到其中的redis.lua 文件,其中封装了很多操作Redis的方法。

进入之前创建的lua目录:

cd /usr/local/openresty/lua

新建itemredis.lua:

local args = ngx.req.get_uri_args()
local id = args["id"]
local redis = require "resty.redis"
local cache = redis:new()
local ok,err = cache:connect("192.168.1.107",6379)
local item_model = cache:get("item_"..id)
if item_model == ngx.null or item_model == nil then
	local resp = ngx.location.capture("/item/get?id="..id)
	item_model = resp.body
end

ngx.say(item_model)

修改nginx配置文件:

cd /usr/local/openresty
vi nginx/conf/nginx.conf

将之前写过的shared dic方式的:

location /luaitem/get{
	default_type "application/json";
	content_by_lua_file ../lua/itemshareddic.lua;
}

改成:

location /luaitem/get{
	default_type "application/json";
	content_by_lua_file ../lua/itemredis.lua;
}

重启Nginx:

cd /usr/local/openresty/nginx
sbin/nginx -s reload

使用浏览器重新访问:/luaitem/get?id=6,可以看到可以正常查询到商品信息。

当数据并不是特别热点的数据,但是经常更新时,可以使用nginx访问redis或应用服务器访问redis的方式来提高吞吐量。如果是特别热点的数据,还是使用内存缓存性能更高。