页面优化方案
页面缓存+URL缓存+对象缓存
页面缓存
渲染good_list.html页面的时候,直接从缓存里面取,如果缓存中没有,则我们手动进行渲染,从而减少对数据库的mysql访问。
- 取缓存
String html = redisService.get(GoodsKey.getGoodsList, "", String.class)
- 手动渲染模板
@Autowired
ThymeleafViewResolver thymeleafViewResolver;
@Autowired
ApplicationContext applicationContext;
SpringWebContext ctx = new SpringWebContext(request,response, request.getServletContext(),request.getLocale(), model.asMap(), applicationContext );
// 手动渲染
String html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
- 结果输出
if(!StringUtils.isEmpty(html)) {
// 如果非空,则保存到redis中
redisService.set(GoodsKey.getGoodsList, "", html);
}
return html;
注意:页面缓存有效期需要比较短,可以设为60秒
URL缓存
good_detail.html同理,但是不同详情的页面有不同的缓存。所以GoodsKey后面需要加上goodsId作为redis存储的真实key。
对象缓存
这里是把MiaoshaUser存储到redis中
public MiaoshaUser getById(long id) {
//取缓存
MiaoshaUser user = redisService.get(MiaoshaUserKey.getById, ""+id, MiaoshaUser.class);
if(user != null) {
return user;
}
//缓存中取不到,取数据库
user = miaoshaUserDao.getById(id);
if(user != null) {
redisService.set(MiaoshaUserKey.getById, ""+id, user);
}
return user;
}
但是考虑到用户可能会有更新密码的操作
// MiaoshaUserDao.java接口
@Update("update miaosha_user set password = #{password} where id = #{id}")
public void update(MiaoshaUser toBeUpdate);
public boolean updatePassword(String token, long id, String formPass) {
//取user
MiaoshaUser user = getById(id);
if(user == null) {
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
//更新数据库
MiaoshaUser toBeUpdate = new MiaoshaUser();
toBeUpdate.setId(id);
toBeUpdate.setPassword(MD5Util.formPassToDBPass(formPass, user.getSalt()));
miaoshaUserDao.update(toBeUpdate);
//处理缓存
redisService.delete(MiaoshaUserKey.getById, ""+id);
user.setPassword(toBeUpdate.getPassword());
redisService.set(MiaoshaUserKey.token, token, user);
return true;
}
Jmeter压测结果
线程数5000*10
QPS:1267/sec -> 2884/sec
CPU-load:15 -> 5
高性能网站设计之缓存更新的思路: blog.csdn.net/tTU1EvLDeLF…
页面静态化,前后端分离
- 常用技术AngularJS, Vue.js
- 优点:利用浏览器的缓存
- 商品详情静态化
- 秒杀静态化
解决超卖以及重复秒杀
- 解决超卖:SQL更新库存时加判断,防止库存变成负数
@Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
public int reduceStock(MiaoshaGoods g);
- 解决重复秒杀:
在MiaoshaOrder表中建立唯一索引(userId, orderId),并且存入到redis缓存中。
MySQL索引:如果要强烈使一列或多列具有唯一性,通常使用PRIMARY KEY约束。但是,每个表只能有一个主键。 因此,如果使多个列或多个组合列具有唯一性,则不能使用主键约束。
幸运的是,MySQL提供了另一种索引,叫做唯一索引,允许我们可以使一个或者多个列的值具有唯一性。另外,不会像主键索引一样,我们的每张表中可以有很多个唯一索引。
静态资源优化
- JS/CSS压缩,减少流量
- 多个JS/CSS组合,减少连接数,并不是一个传js就建立一个连接,因为一个TCP连接需要三次握手,很耗时间
- CDN就近访问
CDN优化
CDN加速意思就是在用户和我们的服务器之间加一个缓存机制,通过这个缓存机制动态获取IP地址根据地理位置,让用户到最近的服务器访问。 那么CDN是个啥? 全称Content Delivery Network即内容分发网络。
CDN是一组分布在多个不同的地理位置的WEB服务器,用于更加有效的向用户发布内容,在优化性能时,会根据距离的远近来选择 。
CDN系统能实时的根据网络流量和各节点的连接,负载状况及用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上,其目的是使用户能就近的获取请求数据,解决网络拥堵,提高访问速度,解决由于网络带宽小,用户访问量大,网点分布不均等原因导致的访问速度慢的问题。
由于CDN部署在网络运营商的机房,这些运营商又是终端用户网络的提供商,因此用户请求的第一跳就到达CDN服务器,当CDN服务器中缓存有用户请求的数据时,就可以从CDN直接返回给浏览器,因此就可以提高访问速度。
CDN能够缓存JavaScript脚本,css样式表,图片,图标,Flash等静态资源文件(不包括html页面),这些静态资源文件的访问频率很高,将其缓存在CDN可以极大地提高网站的访问速度,但由于CDN是部署在网络运营商的机房,所以在一般的网站很少用CDN加速。
总结
至此,页面缓存优化完毕,分为两个方案。第一个方案主要是使用了Redis来存储某些被用户访问过的秒杀商品页good_list.html和order_list.html,从而达到不必每个用户访问都需要与MySQL交互的目的。用户访问某个商品详情页面时,服务器只需要从缓存当中查询这个商品的详细信息,但是这种方法需要我们调用thymeleaf去从model中渲染出来一个html返回给用户,并且把这个页面存储在Redis中。第二个方案是页面静态化,good_list.html不再通过服务器端的GoodsController来渲染出一个html,而是直接通过html跳转到good_list.htm。本来页面是用html文件写的,然后数据是由接口动态地获取,所以我们的服务端只需要写接口就OK了,不需要再去渲染页面,而good_list.htm通过ajax与服务器端的Controller交互获取GoodsDetailVo里的商品详情。
秒杀接口优化(RabbitMQ)
思路:减少对数据库的访问
- 系统初始化,把商品库存数量加载到Redis
- 收到请求,Redis预减库存,库存不足,直接返回否则进入3
- 请求入队,立即返回排队中
- 请求出队,生成订单,减少库存,生成完订单之后,会把订单写入到缓存里面去供客户端查询
- 客户端轮询,是否秒杀成功
RabbitMQ 四种交换机模式
- Direct
//MQConfig.java
public static final String QUEUE = "queue";
@Bean
public Queue queue() {
return new Queue(QUEUE, true);
}
// Sender.java
public void send(Object message) {
String msg = RedisService.beanToString(message);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.QUEUE, msg);
}
// Receiver.java
@RabbitListener(queues=MQConfig.QUEUE)
public void receive(String message) {
log.info("receive message:"+message);
}
// 调用的时候
sender.send("xxxxxxxx");
- Topic
//MQConfig.java
public static final String TOPIC_QUEUE1 = "topic.queue1";
public static final String TOPIC_QUEUE2 = "topic.queue2";
public static final String TOPIC_EXCHANGE = "topicExchage";
@Bean
public Queue topicQueue1() {
return new Queue(TOPIC_QUEUE1, true);
}
@Bean
public Queue topicQueue2() {
return new Queue(TOPIC_QUEUE2, true);
}
@Bean
public TopicExchange topicExchage(){
return new TopicExchange(TOPIC_EXCHANGE);
}
// Topic绑定
@Bean
public Binding topicBinding1() {
return BindingBuilder.bind(topicQueue1()).to(topicExchage()).with("topic.key1");
}
@Bean
public Binding topicBinding2() {
return BindingBuilder.bind(topicQueue2()).to(topicExchage()).with("topic.#");
}
// Sender.java
public void sendTopic(Object message) {
String msg = RedisService.beanToString(message);
log.info("send topic message:"+msg);
amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE, "topic.key1", msg+"1");
amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE, "topic.key2", msg+"2");
}
// Receiver.java
@RabbitListener(queues=MQConfig.TOPIC_QUEUE1)
public void receiveTopic1(String message) {
log.info(" topic queue1 message:"+message);
}
@RabbitListener(queues=MQConfig.TOPIC_QUEUE2)
public void receiveTopic2(String message) {
log.info(" topic queue2 message:"+message);
}
- Fanout
//MQConfig.java
public static final String TOPIC_QUEUE1 = "topic.queue1";
public static final String TOPIC_QUEUE2 = "topic.queue2";
public static final String TOPIC_EXCHANGE = "topicExchage";
public static final String FANOUT_EXCHANGE = "fanoutxchage";
@Bean
public Queue topicQueue1() {
return new Queue(TOPIC_QUEUE1, true);
}
@Bean
public Queue topicQueue2() {
return new Queue(TOPIC_QUEUE2, true);
}
@Bean
public TopicExchange topicExchage(){
return new TopicExchange(TOPIC_EXCHANGE);
}
// Fanouot绑定
@Bean
public Binding FanoutBinding1() {
return BindingBuilder.bind(topicQueue1()).to(fanoutExchage());
}
@Bean
public Binding FanoutBinding2() {
return BindingBuilder.bind(topicQueue2()).to(fanoutExchage());
}
// Sender.java
public void sendTopic(Object message) {
String msg = RedisService.beanToString(message);
log.info("send topic message:"+msg);
amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE, "topic.key1", msg+"1");
amqpTemplate.convertAndSend(MQConfig.TOPIC_EXCHANGE, "topic.key2", msg+"2");
}
// Receiver.java
@RabbitListener(queues=MQConfig.TOPIC_QUEUE1)
public void receiveTopic1(String message) {
log.info(" topic queue1 message:"+message);
}
@RabbitListener(queues=MQConfig.TOPIC_QUEUE2)
public void receiveTopic2(String message) {
log.info(" topic queue2 message:"+message);
}
- Header
//MQConfig.java
@Bean
public HeadersExchange headersExchage(){
return new HeadersExchange(HEADERS_EXCHANGE);
}
@Bean
public Queue headerQueue1() {
return new Queue(HEADER_QUEUE, true);
}
// Header 把headQueue和Exchange绑定的时候指定K-V对
@Bean
public Binding headerBinding() {
Map<String, Object> map = new HashMap<String, Object>();
map.put("header1", "value1");
map.put("header2", "value2");
return BindingBuilder.bind(headerQueue1()).to(headersExchage()).whereAll(map).match();
}
// Sender.java
public void sendHeader(Object message) {
String msg = RedisService.beanToString(message);
log.info("send fanout message:"+msg);
MessageProperties properties = new MessageProperties();
properties.setHeader("header1", "value1");
properties.setHeader("header2", "value2");
Message obj = new Message(msg.getBytes(), properties);
amqpTemplate.convertAndSend(MQConfig.HEADERS_EXCHANGE, "", obj);
}
// Receiver.java
// 监听headerQueue队列
@RabbitListener(queues=MQConfig.HEADER_QUEUE)
public void receiveHeaderQueue(byte[] message) {
log.info(" header queue message:"+new String(message));
}
思路:减少对数据库的访问
- 系统初始化,把商品库存数量加载到Redis
- 收到请求,Redis预减库存,库存不足,直接返回否则进入3
- 请求入队,立即返回排队中
- 请求出队,生成订单,减少库存,生成完订单之后,会把订单写入到缓存里面去供客户端查询
- 客户端轮询,是否秒杀成功
- 设置库存over标志位,当用户下单时,先查看标志位,如果标志位为true,则无需访问redis
压测数据: 线程数:5000 * 10 QPS:1306 -> 2114 (提升不明显是因为redis、mysql、秒杀线程全部在同一台机器上)
/**
* 系统初始化,MiaoshaController实现InitializingBean接口,里面需要重写的方法是afterPropertiesSet
* */
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if(goodsList == null) {
return;
}
// 在系统启动的时候就把商品的库存加载到缓存里面去
for(GoodsVo goods : goodsList) {
redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
localOverMap.put(goods.getId(), false);
}
}
2.秒杀操作
@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@PathVariable("path") String path) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//库存over的标记,减少redis访问,当库存over的标记为1时,就没必要去访问redis数据库了
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//预减redis的库存,返回减了1之后的那个值
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
// 如果库存小于0,秒杀失败,并且把库存over标记设为true
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
// 已经有该用户秒杀订单的记录
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
// MQSender sender自动注入, 发送给receiver与MySQL数据库进行交互
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中,客户端开始轮询
}
- MQSender
public void sendMiaoshaMessage(MiaoshaMessage mm) {
String msg = RedisService.beanToString(mm);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
}
- MQReceiver
@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
log.info("receive message:"+message);
MiaoshaMessage mm = RedisService.stringToBean(message, MiaoshaMessage.class);
MiaoshaUser user = mm.getUser();
long goodsId = mm.getGoodsId();
// 这里面是访问数据库mysql的,因为只有很少的数据可以进来
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {
return;
}
//判断是否已经秒杀到了,orderService是调用了redisService的get方法
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
// 如果秒杀过了,则什么也不做
if(order != null) {
return;
}
//减库存 下订单 写入秒杀订单
miaoshaService.miaosha(user, goods);
}
- 写入秒杀订单
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下订单 写入秒杀订单
boolean success = goodsService.reduceStock(goods);
if(success) {
// order_info maiosha_order
// createOrder里面做的操作是:1、插入订单 2、插入秒杀订单,返回一个订单信息
return orderService.createOrder(user, goods);
}else {
setGoodsOver(goods.getId());
return null;
}
}
- 客户端接收秒杀状态 orderId:成功 -1:秒杀失败 0: 排队中
function getMiaoshaResult(goodsId){
g_showLoading();
$.ajax({
url:"/miaosha/result",
type:"GET",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
// 如果返回消息成功,则取出结果result
if(data.code == 0){
var result = data.data;
if(result < 0){
layer.msg("对不起,秒杀失败");
}else if(result == 0){
//继续轮询,200ms之后再轮询一次
setTimeout(function(){
getMiaoshaResult(goodsId);
}, 200);
}else{
layer.confirm("恭喜你,秒杀成功!查看订单?", {btn:["确定","取消"]},
function(){
// 这里要与数据库进行交互
window.location.href="/order_detail.htm?orderId="+result;
},
function(){
layer.closeAll();
});
}
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
- MiaoshaController里的轮询
/**
* orderId:成功
* -1:秒杀失败
* 0: 排队中
* */
@RequestMapping(value="/result", method=RequestMethod.GET)
@ResponseBody
public Result<Long> miaoshaResult(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
// orderId:成功 -1:秒杀失败 0: 排队中
long result = miaoshaService.getMiaoshaResult(user.getId(), goodsId);
return Result.success(result);
}
//MiaoshaService.java 里面取得秒杀结果的方法
public long getMiaoshaResult(Long userId, long goodsId) {
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
if(order != null) {//秒杀成功
return order.getOrderId();
}else {
// 商品是否卖完了
boolean isOver = getGoodsOver(goodsId);
// 此时order==null,没有生成订单
if(isOver) {
// 如果商品卖完了,返回-1
return -1;
}else {
// 如果商品没卖完,还在也没生成订单,则等待下一次轮询
return 0;
}
}
}
private void setGoodsOver(Long goodsId) {
// 在Redis里面保存id为goodsId的商品是否卖完的值
redisService.set(MiaoshaKey.isGoodsOver, ""+goodsId, true);
}
private boolean getGoodsOver(long goodsId) {
// 判断这个key有没有就行了
return redisService.exists(MiaoshaKey.isGoodsOver, ""+goodsId);
}
总结
至此,秒杀接口优化完毕,首先在系统初始化的时候,先把商品库存数量加载到Redis中。good_list.htm里面可以对MiaoshaController发送秒杀请求,MiaoshaController收到秒杀请求,先在Redis中预减库存(预减库存之后,如果秒杀失败了,不用把库存还回来吗??),如果库存不足,直接返回(还可以设置库存over的标志位,一旦标志位是true,就可以不用访问Redis),再通过orderService从缓存中调用getMiaoshaOrderByUserIdGoodsId()方法来判断是否已经秒杀过了,如果秒杀过了,返回错误:重复秒杀,否则秒杀请求由RabbitMQ入队,返回success(0),排队中,客户端通过ajax发送异步请求url:getMiaoshaResult来每隔200ms轮询"/miaosha/result"接口,最后调用的是orderService.getMiaoshaOrderByUserIdGoodsId()里面的redisService.get(OrderKey.getMiaoshaOrderByUidGid, ""+userId+"_"+goodsId, MiaoshaOrder.class)从Redis里面查询秒杀请求的处理结果。返回-1则秒杀失败,返回0表示则继续轮询,>0则表示秒杀成功。
而队列另一端的RabbitListener负责处理将秒杀订单写入到MySQL数据库中,此时要根据秒杀请求中goodsId来查询这个good是否还有库存,如果没有库存的话则直接返回,如果有库存,并且该用户并没有秒杀过该商品的订单记录,这时执行miaosha()操作-->减库存(goodsService.reduceStock()),如果goodsService.reduceStock()返回true,则创建订单,否则设置库存over=true。