五、页面优化技术
- 页面缓存+URL缓存+对象缓存
- 页面静态化(前后端分离)
除了这两个之外,常用的还有静态资源优化和CDN优化,这里暂且没做。
1、页面缓存+URL缓存+对象缓存
1.1)页面缓存
什么是页面缓存?首先,我们访问一个页面的时候,我们不是直接让我们的系统去给页面渲染,而是说:
- 先去缓存中取
- 取到则返回给客户端
- 取不到,手动渲染,把结果输出到客户端,同时缓存到我们的缓存服务器redis
下次就可以直接使用,其实就是三步:取缓存-->手动渲染模板-->结果输出。
下面,我们以商品列表为例,来学习页面缓存:
@GetMapping("/to_list")
public String list(Model model, SecKillUser user) {
model.addAttribute("user", user);
//查询商品列表
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
return "goods_list";
}
从上面代码,我们可以看出,它渲染的是goods_list.html这个模板:我们将goodsList这个对象放到model中,方便页面获取
这是springboot帮我们渲染的。把数据直接放到model里面,最终是放到页面上,那么我们如何做页面缓存呢?
我们是直接返回一个html的源代码:
第一步,我们是从缓存里面来取,看能不能取到,缓存中取什么东西呢?
第二步,取到时直接返回,取不到,我们需要手动进行渲染,那么我们如何进行手动渲染呢?
其实,如果你使用了thymeleaf,这个框架会自动注入一个thymekeafViewResolver
我们是通过这个Resolver来进行渲染的。那到底我们如何渲染我们的模板呢?使用它的一个模板引擎,看SpringWebContext:
* @param request the request object
* @param response the response object
* @param servletContext the servlet context
* @param locale the locale
* @param variables the variables to be included into the context
* @param appctx the Spring application context
*/
public SpringWebContext(final HttpServletRequest request,
final HttpServletResponse response,
final ServletContext servletContext ,
final Locale locale,
final Map<String, ?> variables,
final ApplicationContext appctx) {
super(request, response, servletContext, locale, addSpringSpecificVariables(variables, appctx));
this.applicationContext = appctx;
}
源码,填入对应参数.
SpringWebContext ctx = new SpringWebContext(request, response, request.getServletContext(),
request.getLocale(), model.asMap(), applicationContext);
第三步,手动渲染完毕,返回值不为空时,将返回的html写入到redis中
@GetMapping(value = "/to_list", produces = "text/html")
@ResponseBody
public String list(HttpServletRequest request,
HttpServletResponse response,
Model model, SecKillUser user) {
model.addAttribute("user", user);
//1.从缓存中取值,看能不能取到
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
//查询商品列表
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
//return "goods_list";
SpringWebContext ctx = new SpringWebContext(request, response, request.getServletContext(),
request.getLocale(), model.asMap(), applicationContext);
//手动渲染
html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
if(!StringUtils.isEmpty(html)) {
redisService.set(GoodsKey.getGoodsList, "", html);
}
return html;
}
注意,我们做页面缓存,一般来说,我们这个有效期会比较短,什么意思呢?我们之所以做页面缓存,是考虑,瞬间访问量过来之后导致服务器压力太大,所以我们就搞一个缓存。但是呢,如果缓存时间过长,数据的及时性就不是很高了,比如说,有些数据发生变化了,结果你这个列表还没有变,但是如果说你的缓存60秒,你这个页面只是有60秒的缓存时间,对于大部分人来说,是可以容忍的,比方说你打开一个网站,看到的是一分钟之前的网站,对你来说基本上是没有什么影响,所以说缓存就存在这么个问题,需要做一个折中,OK。这就是我们的商品列表,我们来测试:
好了,已经缓存。
1.2)URL缓存
URL缓存,其实这个叫法不准确,URL级缓存和页面缓存其实差别不是很大,我们看一下商品详情页面。我们还是仿照着商品列表页面来进行修改:
@GetMapping(value = "/to_detail/{goodsId}", produces="text/html")
@ResponseBody
public String detail(HttpServletRequest request,
HttpServletResponse response,
Model model,SecKillUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);
//1从缓存中取值,看能不能取到 这里需要将参数拼接上去,每个详情页是不一样的
String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();
int seckillStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
seckillStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
seckillStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
seckillStatus = 1;
remainSeconds = 0;
}
model.addAttribute("miaoshaStatus", seckillStatus);
model.addAttribute("remainSeconds", remainSeconds);
//return "goods_detail";
SpringWebContext ctx = new SpringWebContext(request, response, request.getServletContext(),
request.getLocale(), model.asMap(), applicationContext);
//手动渲染
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail", ctx);
if(!StringUtils.isEmpty(html)) {
redisService.set(GoodsKey.getGoodsDetail, "" + goodsId, html);
}
return html;
}
启动程序,进行测试:
完成,这就是URL级缓存,可以认为就是页面缓存多加个参数。
1.3)对象缓存
页面缓存和URL缓存,这两种缓存,一般来说,时间都比较短。他们适合那种变化不大的,像商品列表啊,实际项目中,我们的商品列表有可能是分页的,但是我们不可能 把所有页面都缓存,正常来说,我们就缓存前一两页,因为大部分用户也就点一两页就OK啦,但是他这个失效是让它自动失效,我们用户是不需要来进行手动干预的。接下来要说的是一个对象缓存。这个对象缓存跟这两个有很大区别。
之前,我们在做分布式session时,做了一个token,根据token来获取我们的对象,这种缓存就叫对象级缓存,他的粒度是最小的,拿出一个key,直接对应一个对象。
下面,我们来修改SecKillUserService中的代码:
public SecKillUser getById(long id) {
return secKillUserDao.getById(id);
}
public SecKillUser getById(long id) {
//return secKillUserDao.getById(id);
//取缓存
SecKillUser user = redisService.get(SecKillUserKey.getById, ""+id, SecKillUser.class);
if(user != null) {
return user;
}
//取数据库
user = secKillUserDao.getById(id);
if(user != null) {
redisService.set(SecKillUserKey.getById, ""+id, user);
}
return user;
}
getById的过期时间我们设置为永不过期,只要对象不发生变化,就不过期。
当然,还有一个要注意的地方,假如我们要修改用户密码
SecKillUserDao中添加:
@Update("update miaosha_user set password = #{password} where id = #{id}")
public void update(SecKillUser toBeUpdate);
这里我们只更新密码,这样产生的binLog最少,不更新的东西,不要往里面传,只传需要更新的字段,减轻数据库压力。
SecKillUserService中添加:
public boolean updatePassword(String token, long id, String formPass) {
//取user
SecKillUser user = getById(id);
if(user == null) {
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
//更新数据库
SecKillUser toBeUpdate = new SecKillUser();
toBeUpdate.setId(id);
toBeUpdate.setPassword(MD5Util.formPassToDBPass(formPass, user.getSalt()));
secKillUserDao.update(toBeUpdate);
//处理缓存
redisService.delete(SecKillUserKey.getById, ""+id);
user.setPassword(toBeUpdate.getPassword());
redisService.set(SecKillUserKey.token, token, user);
return true;
}
首先是取user对象,如果user==null,则抛异常。如果存在,就是要更新该对象的密码,新建一个SecKillUser对象,设置id和新的password。该处为什么要新建一个对象去更新,而不是用原来的user对象呢?其实直接更新也是可以的,但是,更新的东西越多,产生的binlog越多,所以说,一般我们为了提高效率,修改哪个字段就更新哪个字段,其他的不管,这是一个小技巧。提高SQL性能的小技巧。如果更新数据库成功了,我们还要清理缓存,这里一定要注意,这里是要修改缓存的。那我们处理哪个缓存呢?
实际上是跟这个id对应的这个对象的所有缓存,都需要改掉。这里就涉及两个,一个是token,一个是getById,对于getById,这里直接删掉就行,对于token,直接删掉合适吗?并不合适,因为删掉token之后就没法登陆了。所以这里不能删掉,而是重新设置set,更新一下。
在更新密码时,这里是先更新数据库,然后更新缓存,那么,这个更新顺序可以反过来吗?
当然是不可以的,考虑这样一种场景:假如做更新时,先删掉缓存,这时候做了一个读操作,老数据就加载到缓存中来了,然后又做了一次更新,数据库中的数据是新的,缓存里面的数据是旧的,此时数据不一致。
之前我们还说每一个Service引用别人的时候,一定要去引用别人的Service,不要引用别人的Dao,为什么呀?如果说你在别的Service里面引用我的Dao,实际你是把缓存的东西给绕过了,这样是不对的,应该调用我的Service,Serivce里面有可能会去调缓存。我们这种搭建的系统,Service间相互调用,Service只能调用别人的Service,不可调用别人的Dao。自己可以调用自己的Dao,因为别人的Service里面可能是有缓存的。
这里我就不贴自己的压测结果了,可能是内存小,用上了缓存之后,Error更大了,而且吞吐量跟之前差不多......
2、页面静态化(前后端分离)
就是把页面直接缓存到用户的浏览器上,这样有什么好处呢?当用户访问页面的时候,直接就不需要跟我们的服务器进行交互了,直接从本地缓存中拿到这个页面,极大节省网络的流量,提高响应速度。
此处我们是用Jquery来模拟
这次选择商品详情页进行处理:
为什么要选择商品详情页呢?因为这个页面相对来说比较复杂。可以体现出静态化的意义。
动态数据通过接口进行获取,静态数据做静态化处理。
GoodsController中的detail方法,只保留业务逻辑,删除缓存等,新建一个GoodsDetailVo对象,往页面上传值,修改之后:
@GetMapping(value = "/to_detail/{goodsId}")
@ResponseBody
public Result<GoodsDetailVo> detail(HttpServletRequest request,
HttpServletResponse response,
Model model, SecKillUser user,
@PathVariable("goodsId")long goodsId) {
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
GoodsDetailVo vo = new GoodsDetailVo();
vo.setGoods(goods);
vo.setUser(user);
vo.setRemainSeconds(remainSeconds);
vo.setMiaoshaStatus(miaoshaStatus);
return Result.success(vo);
}
现在来改造页面:
之前,我们是通过商品列表来跳转到商品详情,这个是经过了controller的,现在呢,我们不需要通过服务端进行跳转,直接就把它搞到客户端来,我们将这个页面放到哪里呢,静态页面我们会放到static下面。记得把后缀名改了,因为我们的配置文件中有一条配置:
# 前缀:服务端返回模板后,先拼上前缀,再拼上后缀,最后得出实际页面路径
spring.thymeleaf.prefix=classpath:/templates/
# 后缀
spring.thymeleaf.suffix=.html
原来的跳转是
<td><a th:href="'/goods/to_detail/'+${goods.id}">详情</a></td>
静态化后,将商品详情放入static目录下,修改后缀为htm,而将商品目录下的跳转修改为:
<td><a th:href="'/goods_detail.htm?goodsId='+${goods.id}">详情</a></td>
再将商品详情页面,去掉所有thymeleaf的东西,因为这是完全的html了。这里就不列页面的代码了,以后会给出源码。
如何证明商品详情页面被缓存到本地了?
再次刷新时,我们发现这个请求的状态码是304,就是说,我在向服务端请求这个页面时,给服务端传递了一个参数If-Modified-Since: XXX,服务端收到这个参数之后,会检查请求的页面有没有发生变化,检查后发现没有任何变化,直接给客户端返回一个304,客户端可以直接使用本地缓存里面的内容,这样,页面的数据就不需要从服务端下载了。
但是呢,304不是我们最终想要的效果,即使是304,客户端和服务端还是发生了一次交互,那么,如何让客户端从浏览器直接取数据,不需要询问我们的服务端呢?这里啊,实际上需要一个配置:
#static
spring.resources.add-mappings=true
spring.resources.cache-period= 3600
spring.resources.chain.cache=true
spring.resources.chain.enabled=true
spring.resources.chain.gzipped=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/
加上这些配置之后,我们看一下有什么不同?先说一下控制浏览器缓存的几个参数
Pragma:http1.0用
Expire:http1.0,1.1 带时区的时间,格林尼治时间,这个是以服务端时间输出这个字段,但是客户端和服务端的时间有可能不一致,为了避免这种情况,新添加Cache-control
Cache-control:http1.0,1.1 单位是秒,指定缓存多少秒,这样就跟服务端的时间没有关系了
由于google浏览器在发送请求时,头部会带上Cache-Control: max-age=0
导致服务端不会输出我们想要看到的,所以换一个浏览器,火狐就可以看到了,感兴趣的可以试试。
对秒杀页面做静态化处理,先改controller的方法
@PostMapping("/do_miaosha")
public Result<OrderInfo> seckill(Model model, SecKillUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//判断库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {
model.addAttribute("errmsg", CodeMsg.SECKILL_OVER.getMsg());
return Result.error(CodeMsg.SECKILL_OVER);
}
//判断是否已经秒杀到了
SecKillOrder order = orderService.getSecKillOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_SECKILL);
}
//减库存 下订单 写入秒杀订单
OrderInfo orderInfo = secKillService.miaosha(user, goods);
return Result.success(orderInfo);
}
再改页面,这个不多说了。
订单详情静态化,解决超卖:
跟之前的套路一样,在加载页面时,根据参数来获取服务端数据,将页面渲染出来
新建OrderController,来响应客户端请求
@RequestMapping("/detail")
@ResponseBody
public Result<OrderDetailVo> info(Model model, SecKillUser user,
@RequestParam("orderId") long orderId) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
OrderInfo order = orderService.getOrderById(orderId);
if(order == null) {
return Result.error(CodeMsg.ORDER_NOT_EXIST);
}
long goodsId = order.getGoodsId();
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
OrderDetailVo vo = new OrderDetailVo();
vo.setOrder(order);
vo.setGoods(goods);
return Result.success(vo);
}
好使。
然而刚巧又看到一个问题:
@Update("update miaosha_goods set stock_count = stock_count -1 where goods_id = #{goodsId}")
public int reduceStock(SecKillGoods g);
看一下这个SQL,当商品ID相等时,库存减一,这里有个问题,现在假设库存还有一个,两个人同时调用了减库存,很明显,就把这个值给减成-1了。怎么办?
@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
public int reduceStock(SecKillGoods g);
多加一个判断条件就好了,只有库存大于0时,才去减库存。因为更新操作,我们的数据库本身会给这个操作进行加锁,不会出现两个线程同时更新一条记录的情况,所以我们是通过数据库来保证商品不会卖超了。
还有一种情况:
第一个用户看到我们库存有10,它刷我们的接口,同时发出两个请求req1,req2,首先判断商品的库存,都是10个,req1,req2都是OK的,于是两个请求都去判断是否已经秒杀到,req1很明显,没有秒杀到,req2也没有秒杀到,然后呢,两个请求同时减库存,下订单。库存是10,两个都能减库存,都能下订单,生成订单,这存在什么问题啊?也就是出现一个用户秒杀到了两个商品。如何解决这个问题???好像不是那么容易解决。实际上,还是要通过数据库来处理。什么意思呢?判断库存,判断秒杀到没,都不能处理这个问题。但是我们这个地方:createOrder,写了两个表,一个生成订单,一个生成秒杀订单。我们是限制一个用户只能秒杀一个商品,那么我们在这个表上建立唯一索引就OK啦!第一个记录是可以插进来的,第二个记录是插不进来的。插不进来之后insertSecKillOrder就会报错,由于加了事务,这里就会回滚,这也是最有效的办法。
也就是说,我们为什么要单独写出一个秒杀订单表,这里就看出他的用处来了,我们通过给秒杀订单表设置唯一索引,就能防止插入重复记录,当然,这个只是理论上会出现一个用户发了两次请求,我们实际在做秒杀的时候,提交表单之前,我们会让他生成一个验证码,输入验证码才能提交秒杀表单。我们不会让用户发出两个请求来的。但是我们为了防止这种情况,我们一定要在miaoshaorder这个地方建立唯一索引。
SQL和数据库索引两方面进行处理,从而保证我们的商品绝对不会卖超的。
还有个小地方:
public SecKillOrder getSecKillOrderByUserIdGoodsId(Long userId, Long goodsId) {
return orderDao.getSecKillOrderByUserIdGoodsId(userId, goodsId);
}
在判断是否已经秒杀到的时候,这里直接查询了数据库,其实这里是不用去查数据库的。当生成订单的时候啊,我们把订单写到我们的缓存中,这样,我们就不需要查数据库了,只需要查缓存就可以了,算是一个小的优化。实际上,这个优化,微乎其微。
public SecKillOrder getSecKillOrderByUserIdGoodsId(Long userId, Long goodsId) {
//return orderDao.getSecKillOrderByUserIdGoodsId(userId, goodsId);
return redisService.get(OrderKey.getMiaoshaOrderByUidGid, "" + userId + "_" + goodsId, SecKillOrder.class);
}
@Transactional
public OrderInfo createOrder(SecKillUser user, GoodsVo goods) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setUserId(user.getId());
orderInfo.setGoodsId(goods.getId());
orderInfo.setCreateDate(new Date());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsName(goods.getGoodsName());
orderInfo.setGoodsCount(1);
orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
//1pc, 2android, 3ios
orderInfo.setOrderChannel(1);
//订单状态,0新建未支付,1已支付,2已发货,3已收货,4,已退款,5已完成
orderInfo.setStatus(0);
long orderId = orderDao.insert(orderInfo);
SecKillOrder seckillOrder = new SecKillOrder();
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(orderId);
seckillOrder.setGoodsId(goods.getId());
orderDao.insertSecKillOrder(seckillOrder);
redisService.set(OrderKey.getMiaoshaOrderByUidGid, "" + user.getId() + "_" + goods.getId(), seckillOrder);
return orderInfo;
}
好了,今天就这些吧。