服务名称 | ip | 端口 |
---|---|---|
数据库1 | 106.14.105.113 | 3306 |
数据库2 | 106.14.105.113 | 4406 |
秒杀服务1 | 106.14.105.113 | 9090 |
秒杀服务2 | 106.14.105.113 | 9091 |
本章对这门课程进行说明,包括:电商秒杀场景的介绍、秒杀系统涉及模块的介绍,秒杀核心的性能优化知识点的介绍,课程的学习规划等。
秒杀系统能力
核心处理能力
承载容量极限
课程目标
基于免费课程的秒杀项目做性能质的提升
互联网架构核心技术的拓展化应用
动手实践,理论应用相结合
学习环境介绍
IntelliJ IDEA 2018.1.3
阿里云ECS或本 Linux地虚拟机,操作系统 centos7.4
MySQL5.6数据库, Redis4.0.1缓存,消息队列
rocketmq4.5, phantomjs无头浏览器
本章会介绍前期秒杀免费课程当中所涉及的基础框架搭建知识,项目分层,源码导读等,帮助大家更快的理解秒杀的基础项目,为后续更深一步的课程学习打基础。
为什么要把用户数据表和用户密码表分开?
因为用户密码可能存储在加密数据库中甚至其他系统当中。除了登录操作,修改密码操作等等跟密码相关的操作之外,其余相关的用户信息操作是不需要密码的。在接口调用的时候,可以减少一次数据库的查询并且节省数据空间。数据库表结构更多地是用来关注于存储层面以及查询效率。
全局异常处理器404,405问题
本章结合前面的秒杀项目介绍了在云端的部署秒杀项目的方案及云端部署的意义,引入了jmeter压测工具完成了性能的摸底测试,发现容器等基础配置的性能瓶颈并进行性能优化。
本章目标
项目云端部署
jmeter性能压测
如何发现系统瓶颈问题
云端部署
操作系统及运行环境
数据库
应用程序
私有部署
操作系统及运行环境
数据库
应用程序
操作系统及运行环境
阿里云 centos虚机
检测系统是否存在Java环境
java -version
赋予安装包可执行权限
chmod 777 jdk-8u65-linux-x64.rpm
安装
rpm -ivh jdk-8u65-linux-x64.rpm
会默认安装在usr目录下
cd //usr/
cd java/
ll
再次检测系统是否存在Java环境
java -version
指定环境变量
vim ~/.bash_profile
JAVA_HOME=https://usr/java/jdk1.8.0_65
PATH=$PATH:$JAVA_HOME/bin
source ~/.bash_profile
安装MySQL所有相关依赖
yum install mysql*
yum install mariadb-server
启动
systemctl start mariadb.service
查看启动情况
ps -ef|grep mysql
netstat -anp |grep 3306
修改数据库密码
mysqladmin -u root password root
连接数据库
mysql -uroot -proot
数据库
◆备份
◆上传
◆恢复
导入数据
mysql -uroot -proot < /root/data/miaosha.sql
springboot该如何打包?
应用程序
maven打包
上传
mvn clean package
这样打出来的包是不能用的
--修改pom文件
添加依赖
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
重新打包
mvn clean package
上传jar包至服务器
运行
cd //var/
mkdir www/
cd www/
mkdir miaosha
cd miaosha/
mv ~/app/miaosha-1.0-SNAPSHOT.jar ./miaosha.jar
chmod -R 777 *
java -jar miaosha.jar
打开云服务器的8090端口
外挂配置文件
先读取工程内部的配置文件,再去读取外挂的配置文件,会以外挂的配置文件为主
新建配置文件
vim application.properties
server.port=80
使用外挂配置文件
java -jar miaosha.jar --spring.config.addition-location=https://var/www/miaosha/application.properties
外挂配置文件生效
编写 deploy脚本启动
vim deploy.sh
nohup java -Xms400m -Xmx400m -XX:NewSize=200m -XX:MaxNewSize=200m -jar miaosha.jar --spring.config.addition-location=https://var/www/miaosha/application.properties
java:java命令启动,设置jvm初始和最大内存为2048m,2个g大小,设置jvm中初始新生代和最大新生代大小为1024m,设置成一样的目的是为了减少扩展jvm内存池过程中向操作系统索要内存分配的消耗,
-Xms400m:最大堆栈的参数,默认是256,这里我们设置为400m
-Xmx400m:最小堆栈的参数
-XX:NewSize=200m:指定新生代jvm的大小
-XX:MaxNewSize=200m:指定最大新生代jvm的大小
赋予可执行权限
chmod -R 777 *
启动
./deploy.sh &
jmeter性能压测
线程组
Http请求
查看结果树
聚合报告
在Windows上启动jmeter
线程组
http请求
查看结果树
聚合报告
查看java进程
ps -ef|grep java
netstat -anp|grep 19284
在hosts文件做映射
测试
进行压测
再次测试
Average:平均的响应时间
Median:对应中位数的响应时间
90%Line:90%的返回是落在35毫秒之内的
Min:最小的返回是21毫秒完成的
最大值:最大的要48秒才返回
能够承受越来越多的高并发,并发数支持的越高,并且能够越来越快地返回,那么它的TPS就自然地高了,性能自然就高了。
比如说,200个并发数过来后,系统对应的并发数量上不去了,说明我的系统最多能承受200个并发。
发现容量问题
server端并发线程数上不去
查看进程编号
ps -ef|grep java
查看进程上有多少个线程数量
pstree -p 19284
pstree -p 19284|wc -l
有28个线程,也就是说我们的tomcat服务器在没有丝毫压力的情况下,内部自动维护了有28个线程的线程池
top -H
load average:我们的服务器是两核的,load average就应该控制在2以内。超过2,就说服务器处在非常忙的状态
增大线程数
果然出现了问题
我们发现server端最多只能开40个线程
调优措施:
修改内置tomcat服务器的默认配置
vim application.properties
server.port=9090
server.tomcat.accept-count=1000
server.tomcat.max-threads=800
server.tomcat.min-spare-threads=100
重启应用
./deploy.sh &
查看效果
pstree -p 19721|wc -l
无压力的情况下,就维护了115线程
设置这两个参数是为了保护我们的系统不受对应连接的客户端的拖累,在满足用户业务需求的情况下,又能合理地利用我们服务端的资源。
新建config包,该包下新建文件WebServerConfiguration
package com.imooc.miaoshaproject.config;
import org.apache.catalina.connector.Connector;
import org.apache.coyote.http11.Http11NioProtocol;
import org.springframework.boot.web.embedded.tomcat.TomcatConnectorCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.stereotype.Component;
/**
* Created by hzllb on 2019/2/6.
*/
//当Spring容器内没有TomcatEmbeddedServletContainerFactory这个bean时,会吧此bean加载进spring容器中
@Component
public class WebServerConfiguration implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory configurableWebServerFactory) {
//使用对应工厂类提供给我们的接口定制化我们的tomcat connector
((TomcatServletWebServerFactory)configurableWebServerFactory).addConnectorCustomizers(new TomcatConnectorCustomizer() {
@Override
public void customize(Connector connector) {
Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
//定制化keepalivetimeout,设置30秒内没有请求则服务端自动断开keepalive链接
protocol.setKeepAliveTimeout(30000);
//当客户端发送超过10000个请求则自动断开keepalive链接
protocol.setMaxKeepAliveRequests(10000);
}
});
}
}
总结
云端部署
压力测试
发现容量问题
优化方向
本章介绍了单机容量瓶劲的天花板,在其基础上进行反向代理负载均衡的优化,深入讲解了nginx高性能的原因,并使用nginx做了动静分离的服务器部署,同时在项目中引入了分布式会话管理的机制解决登录态一致性的问题。
本章目标
nginx反向代理负载均衡
分布式会话管理
使用 redis实现分布式会话存储
nginx反向代理负载均衡
单机容量问题,水平扩展
nginx反向代理
负载均衡配置
现在的架构:
解决方法:
改进的架构
首先安装docker compose
(国内镜像,安装贼快)
curl -L https://get.daocloud.io/docker/compose/releases/download/1.25.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
docker-compose.yml
version: '3.1'
services:
mysql:
restart: always
image: mysql:5.7.22
container_name: mysql
ports:
- 4406:3306
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: root
command:
--character-set-server=utf8mb4
--collation-server=utf8mb4_general_ci
--explicit_defaults_for_timestamp=true
--lower_case_table_names=1
--max_allowed_packet=128M
--sql-mode="STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO"
volumes:
- mysql-data:/var/lib/mysql
volumes:
mysql-data:
Dockerfile
FROM openjdk:8-jre
RUN mkdir /app
WORKDIR /app
RUN mkdir tomcat
RUN chmod -R 777 tomcat/
COPY miaosha.jar /app/
COPY application.properties /app/
COPY deploy.sh /app/
RUN chmod 777 /app/*
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
CMD ["sh", "deploy.sh","&"]
EXPOSE 9091
docker-compose.yml
version: '3.1'
services:
miaosha1:
build:
context: .
dockerfile: Dockerfile
restart: always
image: junglegodlion/miaosha1
container_name: jungle-miaosha1
ports:
- 9091:9091
docker-compose up -d --build
查看日志
docker-compose logs -f
测试
浏览器访问 https://106.14.105.113:9091/item/get?id=6
nginx
使用 nginx作为web服务器
使用 nginx作为动静分离服务器
使用 nginx作为反向代理服务器
架构设计
打开静态资源文件
新建gethost.js文件
--定义一个全局变量
var g_host = "106.14.105.113:9091";
在其它html文件中引入这个js文件
<script src="./gethost.js" type="text/javascript"></script>
OpenResty® 是一款基于 NGINX 和 LuaJIT 的 Web 平台。
chmod -R 777 openresty-1.13.6.2.tar.gz
tar -xvzf openresty-1.13.6.2.tar.gz -C ~/app/
编译
cd openresty-1.13.6.2/
./configure
gmake
gmake install
cd /usr/local/openresty/
启动nginx
cd nginx/
sbin/nginx -c conf/nginx.conf
默认起在80端口
netstat -an|grep 80
前端资源文件要放在html目录下
测试: https://106.14.105.113/getotp.html
在服务器端进行ip映射
vim //etc/hosts
修改nginx配置文件
vim conf/nginx.conf
location /resources/ {
alias /usr/local/openresty/nginx/html/resources/;
index index.html index.htm;
}
alias是替换的作用
到达html目录下
mkdir resources
mv *.html resources/
mv gethost.js resources/
cp -r static resources/
修改配置后直接sbin/nginx -s reload
无缝重启
sbin/nginx -s reload
1.设置 upstream server
2.设置动态请求 location为proxy pass路径
注意:ip尽量使用局域网ip,端口如果是80,就不用写
weight表示权重
(理论上这里的ip是不同的,因为是不同的服务器)
vim conf/nginx.conf
upstream backend_server{
server 172.19.253.55:9090 weight=1;
server 172.19.253.55:9091 weight=1;
}
location / {
proxy_pass https://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;
}
重启nginx
sbin/nginx -s reload
查看日志是否有报错
cd logs/
tail -f error.log
检查静态资源是否有问题
检查动态请求是否有问题
3.开启 tomcat access log验证
进入miaosha项目
cd //var/www/miaosha
mkdir tomcat
chmod -R 777 tomcat/
vim application.properties
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=/var/www/miaosha/tomcat
server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D
参数解释:
%h:remote hostname,也就是远端请求的ip地址
%u:远端主机的user
%t:处理时长
%r:会打印出请求方法,请求的url
%s:http的返回状态码
%b:response的大小
%D:处理请求的时长
重启tomcat后,会发现tomcat已生成日志文件
tail -f access_log.2020-03-13.log
修改gethost.js文件,将ip变为域名
vim gethost.js
var g_host = "miaoshaserver";
nginx服务器和客户端保持的是长连接,但是跟后端java程序是短链接,默认是没有keep-alive
将nginx服务器与应用服务器的连接改为长连接
cd /usr/local/openresty/nginx/
vim conf/nginx.conf
keepalive 30;
proxy_http_version 1.1;
proxy_set_header Connection "";
重启nginx
sbin/nginx -s reload
1.解决了io阻塞的回调通知的问题
2.平滑的过度,平滑地重启,并且基于worker的单线程模型并且依靠多路复用完成高效的操作
3.将每个用户的请求对应到线程中的每一个协程中,然后在协程中使用多路复用的机制,来完成同步调用的开发,完成高性能的操作
bio模型
select模型
epoll模型
master是worker的父进程,master进程可以管理worker进程的内存空间,也就是说master进程可以拥有worker进程的内存空间的内存变量,函数堆栈。甚至socket的文件距离。
worker进程用于与客户端连接的进程
来了http请求,master不处理,交由worker处理。master,work共享内存,所有三个进程去抢占内存锁
ps -ef|grep nginx
协程切换的开销非常地小,不像线程一样有cpu的开销,只需要内存的切换开销,协程的运行其实就是线程的运行
引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.0.5.RELEASE</version>
</dependency>
在config目录下新建文件RedisConfig
package com.imooc.miaoshaproject.config;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.stereotype.Component;
/**
* Created by hzllb on 2019/2/10.
*/
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
}
安装redis
chmod -R 777 redis-4.0.1.tar.gz
tar -xvzf redis-4.0.1.tar.gz -C ~/app/
cd redis-4.0.1/
make
make install
启动
cd src/
./redis-server &
客户端连接
./redis-cli
在application.properties
增加配置
#配置springboot对redis的依赖
spring.redis.host=127.0.0.1
spring.redis.port=6379
# 默认有16个数据库,这里选用第10个数据库
spring.redis.database=10
#spring.redis.password=
#设置jedis连接池
spring.redis.jedis.pool.max-active=50
spring.redis.jedis.pool.min-idle=20
在本机启动秒杀项目
测试
报错:序列化问题
Caused by: org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [com.imooc.miaoshaproject.service.model.UserModel]
解决
查看redis变化
redis第10个数据库存在键值
修改redis配置文件
vim redis.conf
启动redis
src/redis-server ./redis.conf &
将秒杀项目打包上传
修改外挂文件application.properties
# 添加
spring.redis.host=106.14.105.113
修改UserController
@Autowired
private RedisTemplate redisTemplate;
//修改成若用户登录验证成功后将对应的登录信息和登录凭证一起存入redis中
//生成登录凭证token,UUID
String uuidToken = UUID.randomUUID().toString();
uuidToken = uuidToken.replace("-","");
//建议token和用户登陆态之间的联系
redisTemplate.opsForValue().set(uuidToken,userModel);
// 设置超时时间
redisTemplate.expire(uuidToken,1, TimeUnit.HOURS);
// this.httpServletRequest.getSession().setAttribute("IS_LOGIN",true);
// this.httpServletRequest.getSession().setAttribute("LOGIN_USER",userModel);
//下发了token
return CommonReturnType.create(uuidToken);
修改前端代码
--修改login.html
--修改getitem.html
修改OrderController
@Autowired
private RedisTemplate redisTemplate;
//Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
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,"用户还未登陆,不能下单");
}
本章的核心目标是优化商品详情页对应的动态请求的性能。通过多级缓存:redis、guava cache、nginx lua缓存实现了一套削峰的多级缓存方案,优雅的依靠不同的热点分类使用不同类型的多级缓存并设置不同的失效策略,解决动态请求的性能问题。...
本章目标
掌握多级缓存的定义
掌握 redis缓存,本地缓存
掌握热点 nginx lua缓存
缓存设计
用快速存取设备,用内存
将缓存推到离用户最近的地方
脏缓存清理
多级缓存
redis缓存
热点内存本地缓存
nginx proxy cache缓存
nginx lua缓存
架构
redis缓存
单机版
sentinal哨兵模式
集群 cluster模式
--sentinal哨兵模式
--集群 cluster模式
在ItemController
中添加缓存
@Autowired
private RedisTemplate redisTemplate;
//根据商品的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);
redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
改造RedisTemplate
--RedisConfig
package com.imooc.miaoshaproject.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.imooc.miaoshaproject.serializer.JodaDateTimeJsonDeserializer;
import com.imooc.miaoshaproject.serializer.JodaDateTimeJsonSerializer;
import org.joda.time.DateTime;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.stereotype.Component;
/**
* Created by hzllb on 2019/2/10.
*/
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
public class RedisConfig {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
//首先解决key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
//解决value的序列化方式
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(DateTime.class,new JodaDateTimeJsonSerializer());
simpleModule.addDeserializer(DateTime.class,new JodaDateTimeJsonDeserializer());
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
objectMapper.registerModule(simpleModule);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
自定义DateTime序列化方式
新建serializer包
--JodaDateTimeJsonSerializer
package com.imooc.miaoshaproject.serializer;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.joda.time.DateTime;
import java.io.IOException;
/**
* Created by hzllb on 2019/2/14.
*/
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"));
}
}
--JodaDateTimeJsonDeserializer
package com.imooc.miaoshaproject.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;
/**
* Created by hzllb on 2019/2/14.
*/
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);
}
}
测试:
浏览器访问 https://localhost:8090/item/get?id=6
本地缓存就是Java虚拟机的缓存,即jvm的缓存
本地热点缓存
热点数据
脏读不敏感
内存可控
Guava cache
可控制的大小和超时时间
可配置的lru策略
线程安全
引入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>18.0</version>
</dependency>
实现
1.CacheService
package com.imooc.miaoshaproject.service;
/**
* Created by hzllb on 2019/2/16.
*/
//封装本地缓存操作类
public interface CacheService {
//存方法
void setCommonCache(String key,Object value);
//取方法
Object getFromCommonCache(String key);
}
2.CacheServiceImpl
package com.imooc.miaoshaproject.service.impl;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.imooc.miaoshaproject.service.CacheService;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
* Created by hzllb on 2019/2/16.
*/
@Service
public class CacheServiceImpl implements CacheService {
private Cache<String,Object> commonCache = null;
@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);
}
}
3.修改ItemController
@Autowired
private CacheService cacheService;
//先取本地缓存
itemModel = (ItemModel) cacheService.getFromCommonCache("item_"+id);
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);
redisTemplate.expire("item_"+id,10, TimeUnit.MINUTES);
}
//填充本地缓存
cacheService.setCommonCache("item_"+id,itemModel);
nginx可以用来做反向代理,统一收口用户第一层的入口请求
nginx proxy cache缓存
nginx反向代理前置
依靠文件系统存索引级的文件
依靠内存缓存文件地址
vim nginx.conf
# 申明一个cache缓存节点的内容
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
proxy_cache tmp_cache;
proxy_cache_key $uri;
proxy_cache_valid 200 206 304 302 7d;
重启nginx
sbin/nginx -s reload
但是这个并不好用,因为读文件读的是磁盘,而不是内存,所以比较慢
这里我并不会用它
nginx lua
◆ lua协程机制
◆ nginx协程机制
◆ nginx lua插载点
◆ openResty
协程机制
依附于线程的内存模型,切换开销小
遇阻塞及归还执行权,代码同步
无需加锁
lua脚本
nginx协程
nginx的每一个Worker进程都是在epoll或kqueue这种事件模型之上,封装成协程。
每一个请求都有一个协程进行处理。
即使 ngx_lua须要运行Lua,相对C有一定的开销,但依旧能保证高并发能力
先关闭nginx
pkill -9 nginx
cd //usr/local/openresty
mkdir lua
cd lua
vim init.lua
ngx.log(ngx.ERR,"init lua success");
vim conf/nginx.conf
init_by_lua_file ../lua/init.lua;
启动nginx
cd nginx/
sbin/nginx -c conf/nginx.conf
查看日志
cd logs/
tail -f error.log
以上只是测试
vim conf/nginx.conf
location /staticitem/get{
content_by_lua_file ../lua/staticitem.lua;
}
cd lua
vim staticitem.lua
(以http response方式返回一串字符串)
ngx.say("hello static item lua");
重启nginx
sbin/nginx -s reload
测试
浏览器访问 https://106.14.105.113/staticitem/get
返回了一个文件
指定返回样式
vim conf/nginx.conf
default_type "text/html";
重启nginx
sbin/nginx -s reload
浏览器访问 https://106.14.105.113/staticitem/get
cd nginx
vim conf/nginx.conf
lua_shared_dict my_cache 128m;
cd lua
vim itemsharedic.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)
vim conf/nginx.conf
location /luaitem/get{
default_type "application/json";
content_by_lua_file ../lua/itemsharedic.lua;
}
重启nginx
sbin/nginx -s reload
测试:浏览器 https://106.14.105.113/luaitem/get?id=6
架构:
cd //usr/local/openresty/lualib/resty/redis.lua
里面包含对redis的操作
cd lua
vim 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("106.14.105.113",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)
vim nginx/conf/nginx.conf
重启nginx
sbin/nginx -s reload
本章讲述了cdn的核心原理并将静态页面部署到cdn上,之后使用了phantomjs的无头浏览器方案实现了将静态请求和动态请求合并一同部署到cdn上,更进一步的将商品详情页的流量能力提升到极致。
静态请求CDN
DNS用 CNAME解析到源站
回源缓存设置
强推失效
CDN自定义缓存策略
◆可自定义目录过期时间
◆可自定义后缀名过期时间
◆可自定义对应权重
◆可通过界面或api强制cdn对应目录刷新(非保成功)
打开phantomjs\bin文件夹,双击运行phantomjs.exe,出现如下界面,那么你就可以运行JS代码了。
新建getitem.js
var page = require("webpage").create();
var fs = require("fs");
page.open("https://miaoshaserver/resources/getitem.html?id=6",function (status) {
console.log("status = " + status);
setTimeout(function () {
fs.write("getitem.html",page.content,"w");
phantom.exit();
},1000);
});
修改getitem.html
<input type="hidden" id="isInit" value="0"/>
function hasInit(){
var isInit = $("#isInit").val();
return isInit;
}
function setHasInit(){
$("#isInit").val("1");
}
function initView(){
var isInit = hasInit();
if(isInit == "1"){
return;
}
//获取商品详情
$.ajax({
type:"GET",
url:"https://"+g_host+"/item/get",
data:{
"id":getParam("id"),
},
xhrFields:{withCredentials:true},
success:function(data){
if(data.status == "success"){
g_itemVO = data.data;
reloadDom();
setInterval(reloadDom,1000);
setHasInit();
}else{
alert("获取信息失败,原因为"+data.data.errMsg);
}
},
error:function(data){
alert("获取信息失败,原因为"+data.responseText);
}
});
}
initView();
修改getitem.js
var page = require("webpage").create();
var fs = require("fs");
page.open("https://miaoshaserver/resources/getitem.html?id=6",function (status) {
console.log("status = " + status);
var isInit = "0";
setInterval(function () {
if (isInit!="1") {
page.evaluate(function () {
initView();
});
isInit = page.evaluate(function () {
return hasInit();
});
} else {
fs.write("getitem.html",page.content,"w");
phantom.exit();
}
},1000);
});
本章介绍了下单交易的性能优化技术,通过交易验证缓存的优化,库存缓存模型优化解决了交易流程中繁琐耗性能的验证缓存,并解决数据库库存行锁的问题,同时也引入了缓存与数据库分布式提交过程中不一致的风险
本章目标
掌握高效交易验证方式
掌握缓存库存模型
交易性能瓶颈
jmeter压测
交易验证完全依赖数据库
库存行锁
后置处理逻辑
jmeter压测
处理逻辑
交易验证优化
用户风控策略优化:策略缓存模型化
活动校验策略优化:引入活动发布流程模型缓存化,紧急下线能力
1.ItemService添加新的方法
//item及promo model缓存模型
ItemModel getItemByIdInCache(Integer id);
2.ItemServiceImpl实现新方法
@Autowired
private RedisTemplate redisTemplate;
@Override
public ItemModel getItemByIdInCache(Integer id) {
ItemModel itemModel = (ItemModel) redisTemplate.opsForValue().get("item_validate_"+id);
if(itemModel == null){
itemModel = this.getItemById(id);
redisTemplate.opsForValue().set("item_validate_"+id,itemModel);
redisTemplate.expire("item_validate_"+id,10, TimeUnit.MINUTES);
}
return itemModel;
}
3.修改OrderServiceImpl
//1.校验下单状态,下单的商品是否存在,用户是否合法,购买数量是否正确
//ItemModel itemModel = itemService.getItemById(itemId);
ItemModel itemModel = itemService.getItemByIdInCache(itemId);
4.UserService添加新的方法
//通过缓存获取用户对象
UserModel getUserByIdInCache(Integer id);
5.UserServiceImpl实现方法
@Override
public UserModel getUserByIdInCache(Integer id) {
UserModel userModel = (UserModel) redisTemplate.opsForValue().get("user_validate_"+id);
if(userModel == null){
userModel = this.getUserById(id);
redisTemplate.opsForValue().set("usejavar_validate_"+id,userModel);
redisTemplate.expire("user_validate_"+id,10, TimeUnit.MINUTES);
}
return userModel;
}
6.修改OrderServiceImpl
UserModel userModel = userService.getUserByIdInCache(userId);
itemId被大量使用
给itemId加上索引
ALTER TABLE item_stock ADD UNIQUE INDEX item_id_index(item_id)
库存行锁优化
扣减库存缓存化
异步同步数据库
库存数据库最终一致性保证
扣减库存缓存化
方案:
(1)活动发布同步库存进缓存
(2)下单交易减缓存库存
实现扣减库存缓存化
第一步实现活动发布同步库存进缓存
1.PromoService中添加活动发布方法
//活动发布
void publishPromo(Integer promoId);
2.PromoServiceImpl中实现方法
@Override
public void publishPromo(Integer promoId) {
//通过活动id获取活动
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
if(promoDO.getItemId() == null || promoDO.getItemId().intValue() == 0){
return;
}
ItemModel itemModel = itemService.getItemById(promoDO.getItemId());
// 活动开始前,商品下架。活动开始后,商品上架
//将库存同步到redis内
redisTemplate.opsForValue().set("promo_item_stock_"+itemModel.getId(), itemModel.getStock());
}
3.ItemController中添加新方法
@RequestMapping(value = "/publishpromo",method = {RequestMethod.GET})
@ResponseBody
public CommonReturnType publishpromo(@RequestParam(name = "id")Integer id){
promoService.publishPromo(id);
return CommonReturnType.create(null);
}
第二步实现下单交易减缓存库存
1.ItemServiceImpl修改decreaseStock方法
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) throws BusinessException {
//int affectedRow = itemStockDOMapper.decreaseStock(itemId,amount);
long result = redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue() * -1);
if(result >0){
//更新库存成功
return true;
}else if(result == 0){
//打上库存已售罄的标识
redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId,"true");
//更新库存成功
return true;
}else{
//更新库存失败
increaseStock(itemId,amount);
return false;
}
}
异步同步数据库
方案:
(1)活动发布同步库存进缓存
(2)下单交易减缓存库存
(3)异步消息扣减数据库内库存
异步消息队列 rocketmq
高性能,高并发,分布式消息中间件
典型应用场景:分布式事务异步解耦
rocketmq是阿里巴巴根据Kafka改造的
wget https://archive.apache.org/dist/rocketmq/4.6.1/rocketmq-all-4.6.1-bin-release.zip
chmod 777 rocketmq-all-4.6.1-bin-release.zip
yum install unzip
unzip rocketmq-all-4.6.1-bin-release.zip
Start Name Server
nohup sh bin/mqnamesrv &
tail -f ~/logs/rocketmqlogs/namesrv.log
Start Broker
nohup sh bin/mqbroker -n localhost:9876 &
tail -f ~/logs/rocketmqlogs/broker.log
1.application. properties 添加配置
mq.nameserver.addr=115.28.67.199:9876
mq.topicname=TopicTest
2.引入依赖
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.3.0</version>
</dependency>
2.新建mq目录
--MqConsumer
--MqProducer
3.ItemServiceImpl调用MqProducer方法
异步同步数据库
问题:
(1)异步消息发送失败
(2)扣减操作执行失败
(3)下单失败无法正确回补库存
本章延续之前缓存库存所引入的事务不一致的问题,使用了异步化的事务型消息解决了最终一致性的问题,同时引入库存售罄这样的方案解决过载击穿的问题。
本章目标
掌握异步化事务型消息模型
掌握库存售罄模型
1.在ItemService中添加方法
//异步更新库存
boolean asyncDecreaseStock(Integer itemId,Integer amount);
//库存回补
boolean increaseStock(Integer itemId,Integer amount)throws BusinessException;
2.ItemServiceImpl实现方法
@Override
public boolean asyncDecreaseStock(Integer itemId, Integer amount) {
boolean mqResult = mqProducer.asyncReduceStock(itemId,amount);
return mqResult;
}
@Override
public boolean increaseStock(Integer itemId, Integer amount) throws BusinessException {
redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue());
return true;
}
3.当前面所有的事务都执行完,最后再发异步更新库存的操作
使用rocketmq的事务型消息
MqProducer
package com.imooc.miaoshaproject.mq;
import com.alibaba.fastjson.JSON;
import com.imooc.miaoshaproject.dao.StockLogDOMapper;
import com.imooc.miaoshaproject.dataobject.StockLogDO;
import com.imooc.miaoshaproject.error.BusinessException;
import com.imooc.miaoshaproject.service.OrderService;
import org.apache.rocketmq.client.exception.MQBrokerException;
import org.apache.rocketmq.client.exception.MQClientException;
import org.apache.rocketmq.client.producer.*;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.remoting.exception.RemotingException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
/**
* Created by hzllb on 2019/2/23.
*/
@Component
public class MqProducer {
private DefaultMQProducer producer;
private TransactionMQProducer transactionMQProducer;
@Value("${mq.nameserver.addr}")
private String nameAddr;
@Value("${mq.topicname}")
private String topicName;
@Autowired
private OrderService orderService;
@Autowired
private StockLogDOMapper stockLogDOMapper;
@PostConstruct
public void init() throws MQClientException {
//做mq producer的初始化
producer = new DefaultMQProducer("producer_group");
producer.setNamesrvAddr(nameAddr);
producer.start();
transactionMQProducer = new TransactionMQProducer("transaction_producer_group");
transactionMQProducer.setNamesrvAddr(nameAddr);
transactionMQProducer.start();
transactionMQProducer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
//真正要做的事 创建订单
Integer itemId = (Integer) ((Map)arg).get("itemId");
Integer promoId = (Integer) ((Map)arg).get("promoId");
Integer userId = (Integer) ((Map)arg).get("userId");
Integer amount = (Integer) ((Map)arg).get("amount");
String stockLogId = (String) ((Map)arg).get("stockLogId");
try {
orderService.createOrder(userId,itemId,promoId,amount,stockLogId);
} catch (BusinessException e) {
e.printStackTrace();
//设置对应的stockLog为回滚状态
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
stockLogDO.setStatus(3);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
return LocalTransactionState.COMMIT_MESSAGE;
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
//根据是否扣减库存成功,来判断要返回COMMIT,ROLLBACK还是继续UNKNOWN
String jsonString = new String(msg.getBody());
Map<String,Object>map = JSON.parseObject(jsonString, Map.class);
Integer itemId = (Integer) map.get("itemId");
Integer amount = (Integer) map.get("amount");
String stockLogId = (String) map.get("stockLogId");
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null){
return LocalTransactionState.UNKNOW;
}
if(stockLogDO.getStatus().intValue() == 2){
return LocalTransactionState.COMMIT_MESSAGE;
}else if(stockLogDO.getStatus().intValue() == 1){
return LocalTransactionState.UNKNOW;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
});
}
//事务型同步库存扣减消息
public boolean transactionAsyncReduceStock(Integer userId,Integer itemId,Integer promoId,Integer amount,String stockLogId){
Map<String,Object> bodyMap = new HashMap<>();
bodyMap.put("itemId",itemId);
bodyMap.put("amount",amount);
bodyMap.put("stockLogId",stockLogId);
Map<String,Object> argsMap = new HashMap<>();
argsMap.put("itemId",itemId);
argsMap.put("amount",amount);
argsMap.put("userId",userId);
argsMap.put("promoId",promoId);
argsMap.put("stockLogId",stockLogId);
Message message = new Message(topicName,"increase",
JSON.toJSON(bodyMap).toString().getBytes(Charset.forName("UTF-8")));
TransactionSendResult sendResult = null;
try {
sendResult = transactionMQProducer.sendMessageInTransaction(message,argsMap);
} catch (MQClientException e) {
e.printStackTrace();
return false;
}
if(sendResult.getLocalTransactionState() == LocalTransactionState.ROLLBACK_MESSAGE){
return false;
}else if(sendResult.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE){
return true;
}else{
return false;
}
}
//同步库存扣减消息
public boolean asyncReduceStock(Integer itemId,Integer amount) {
Map<String,Object> bodyMap = new HashMap<>();
bodyMap.put("itemId",itemId);
bodyMap.put("amount",amount);
Message message = new Message(topicName,"increase",
JSON.toJSON(bodyMap).toString().getBytes(Charset.forName("UTF-8")));
try {
producer.send(message);
} catch (MQClientException e) {
e.printStackTrace();
return false;
} catch (RemotingException e) {
e.printStackTrace();
return false;
} catch (MQBrokerException e) {
e.printStackTrace();
return false;
} catch (InterruptedException e) {
e.printStackTrace();
return false;
}
return true;
}
}
异步同步数据库
问题:
(1)异步消息发送失败
(2)扣减操作执行失败
(3)下单失败无法正确回补库存
操作流水
问题本质:
没有库存操作流水
操作流水:记录进行了哪些操作
1.新建表stock_log
,用来做库存流水型数据
CREATE TABLE `stock_log` (
`stock_log_id` varchar(64) NOT NULL,
`item_id` int(11) NOT NULL DEFAULT '0',
`amount` int(11) NOT NULL DEFAULT '0',
`status` int(11) NOT NULL DEFAULT '0' COMMENT '//1表示初始状态,2表示下单扣减库存成功,3表示下单回滚',
PRIMARY KEY (`stock_log_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
2.mybatis生成相应类
mybatis-generator.xml
<table tableName="stock_log" domainObjectName="StockLogDO" enableCountByExample="false"
enableUpdateByExample="false" enableDeleteByExample="false"
enableSelectByExample="false" selectByExampleQueryId="false"></table>
3.ItemService新增方法
//初始化库存流水
String initStockLog(Integer itemId,Integer amount);
1.ItemServiceImpl实现方法
//初始化对应的库存流水
@Override
@Transactional
public String initStockLog(Integer itemId, Integer amount) {
StockLogDO stockLogDO = new StockLogDO();
stockLogDO.setItemId(itemId);
stockLogDO.setAmount(amount);
stockLogDO.setStockLogId(UUID.randomUUID().toString().replace("-",""));
stockLogDO.setStatus(1);
stockLogDOMapper.insertSelective(stockLogDO);
return stockLogDO.getStockLogId();
}
2.OrderController
//加入库存流水init状态
String stockLogId = itemService.initStockLog(itemId,amount);
//再去完成对应的下单事务型消息机制
if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败");
}
return CommonReturnType.create(null);
1.OrderServiceImpl
//设置库存流水状态为成功
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null){
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
}
stockLogDO.setStatus(2);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
库存数据库最终一致性保证
方案:
(1)引入库存操作流水
(2)引入事务性消息机制
◆问题:
(1) redis不可用时如何处理
(2)扣减流水错误如何处理
业务场景决定高可用技术实现
设计原则:
宁可少卖,不能超卖(有的商家采取的是宁可多买)
方案:
(1) redis可以比实际数据库中少
(2)超时释放
库存售罄
库存售罄标识
售罄后不去操作后续流程
售罄后通知各系统售罄
回补上新
1.ItemServiceImpl
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) throws BusinessException {
//int affectedRow = itemStockDOMapper.decreaseStock(itemId,amount);
long result = redisTemplate.opsForValue().increment("promo_item_stock_"+itemId,amount.intValue() * -1);
if(result >0){
//更新库存成功
return true;
}else if(result == 0){
//打上库存已售罄的标识
redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId,"true");
//更新库存成功
return true;
}else{
//更新库存失败
increaseStock(itemId,amount);
return false;
}
}
2.OrderController
//判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
后置流程
销量逻辑异步化
交易单逻辑异步化
交易单逻辑异步化
生成交易单 sequence后直接异步返回
前端轮询异步单状态
即便查询优化,交易优化技术用到极致后,只要外部的流量超过了系统可承载的范围就有拖垮系统的风险。本章通过秒杀令牌,秒杀大闸,队列泄洪等流量削峰技术解决全站的流量高性能运行效率。
本章目标
掌握秒杀令牌的原理和使用方式
掌握秒杀大闸的原理和使用方式
掌握队列泄洪的原理和使用方式
抛缺陷
秒杀下单接口会被脚本不停的刷
秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
秒杀验证逻辑复杂,对交易系统产生无关联负载
秒杀令牌原理
秒杀接口需要依靠令牌才能进入
秒杀的令牌由秒杀活动模块负责生成
秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
秒杀下单前需要先获得秒杀令牌
代码实现:
1.PromoService中新增方法
//生成秒杀用的令牌
String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId);
2.PromoServiceImpl实现方法
@Override
public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
//判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
return null;
}
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
//dataobject->model
PromoModel promoModel = convertFromDataObject(promoDO);
if(promoModel == null){
return null;
}
//判断当前时间是否秒杀活动即将开始或正在进行
if(promoModel.getStartDate().isAfterNow()){
promoModel.setStatus(1);
}else if(promoModel.getEndDate().isBeforeNow()){
promoModel.setStatus(3);
}else{
promoModel.setStatus(2);
}
//判断活动是否正在进行
if(promoModel.getStatus().intValue() != 2){
return null;
}
//判断item信息是否存在
ItemModel itemModel = itemService.getItemByIdInCache(itemId);
if(itemModel == null){
return null;
}
//判断用户信息是否存在
UserModel userModel = userService.getUserByIdInCache(userId);
if(userModel == null){
return null;
}
//获取秒杀大闸的count数量
long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
if(result < 0){
return null;
}
//生成token并且存入redis内并给一个5分钟的有效期
String token = UUID.randomUUID().toString().replace("-","");
redisTemplate.opsForValue().set("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,token);
redisTemplate.expire("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,5, TimeUnit.MINUTES);
return token;
}
3.OrderController
//生成秒杀令牌
@RequestMapping(value = "/generatetoken",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType generatetoken(@RequestParam(name="itemId")Integer itemId,
@RequestParam(name="promoId")Integer promoId) throws BusinessException {
//根据token获取用户信息
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,"用户还未登陆,不能下单");
}
//获取秒杀访问令牌
String promoToken = promoService.generateSecondKillToken(promoId,itemId,userModel.getId());
if(promoToken == null){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
}
//返回对应的结果
return CommonReturnType.create(promoToken);
}
// 校验秒杀令牌是否正确
String token = httpServletRequest.getParameterMap().get("token")[0];
//校验秒杀令牌是否正确
if(promoId != null){
String inRedisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_"+promoId+"_userid_"+userModel.getId()+"_itemid_"+itemId);
if(inRedisPromoToken == null){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀令牌校验失败");
}
if(!org.apache.commons.lang3.StringUtils.equals(promoToken,inRedisPromoToken)){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀令牌校验失败");
}
}
修改前端代码
--修改getitem.html
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"https://"+g_host+"/order/generatetoken?token="+token,
data:{
"itemId":g_itemVO.id,
"promoId":g_itemVO.promoId
},
xhrFields:{withCredentials:true},
success:function(data){
if(data.status == "success"){
var promoToken = data.data;
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"https://"+g_host+"/order/createorder?token="+token,
data:{
"itemId":g_itemVO.id,
"amount":1,
"promoId":g_itemVO.promoId,
"promoToken":promoToken
},
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);
}
});
}else{
alert("获取令牌失败,原因为"+data.data.errMsg);
if(data.data.errCode == 20003){
window.location.href="login.html";
}
}
},
error:function(data){
alert("获取令牌失败,原因为"+data.responseText);
}
});
缺陷
秒杀令牌只要活动一开始就无限制生成,影响系统性能
秒杀大闸原理
依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量
用户风控策略前置到秒杀令牌发放中
库存售罄判断前置到秒杀令牌发放中
代码实现:
1.PromoServiceImpl
根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量
//将大闸的限制数字设到redis内
redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 5);
//获取秒杀大闸的count数量
long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
if(result < 0){
return null;
}
库存售罄判断前置到秒杀令牌发放中
//判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
return null;
}
抛缺陷
浪涌流量涌入后系统无法应对
多库存,多商品等令牌限制能力弱
队列泄洪原理
排队有些时候比并发更高效(例如 redis单线程模型,innodb mutex key等)
依靠排队去限制并发流量
依靠排队和下游拥塞窗口程度调整队列释放流量大小
支付宝银行网关队列举例(支付宝有很高处理并发的能力,但下游银行没有)
1.OrderController
private ExecutorService executorService;
@PostConstruct
public void init(){
executorService = Executors.newFixedThreadPool(20);
}
//同步调用线程池的submit方法
//拥塞窗口为20的等待队列,用来队列化泄洪
Future<Object> future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
//加入库存流水init状态
String stockLogId = itemService.initStockLog(itemId,amount);
//再去完成对应的下单事务型消息机制
if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败");
}
return null;
}
});
try {
future.get();
} catch (InterruptedException e) {
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
} catch (ExecutionException e) {
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
}
本地or分布式
本地:将队列维护在本地内存中
分布式:将队列设置到外部 redis内
本章介绍了常见的黄牛入侵手段,以及如何使用对应的防刷手段防止黄牛入侵。同时业务的发展预估永远可能高于系统可承载的能力,因此介绍了使用多种限流技术保证系统的稳定。
本章目标
掌握验证码生成与验证技术
掌握限流原理与实现
掌握防黄牛技术
验证码
包装秒杀令牌前置,需要验证码来错峰
数学公式验证码生成器
代码实现:
1.生成验证码
--新建util目录,该目录下新建CodeUtil
文件
package com.imooc.miaoshaproject.util;
/**
* Created by hzllb on 2019/3/9.
*/
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import javax.imageio.ImageIO;
public class CodeUtil {
private static int width = 90;// 定义图片的width
private static int height = 20;// 定义图片的height
private static int codeCount = 4;// 定义图片上显示验证码的个数
private static int xx = 15;
private static int fontHeight = 18;
private static int codeY = 16;
private static char[] codeSequence = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' };
/**
* 生成一个map集合
* code为生成的验证码
* codePic为生成的验证码BufferedImage对象
* @return
*/
public static Map<String,Object> generateCodeAndPic() {
// 定义图像buffer
BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// Graphics2D gd = buffImg.createGraphics();
// Graphics2D gd = (Graphics2D) buffImg.getGraphics();
Graphics gd = buffImg.getGraphics();
// 创建一个随机数生成器类
Random random = new Random();
// 将图像填充为白色
gd.setColor(Color.WHITE);
gd.fillRect(0, 0, width, height);
// 创建字体,字体的大小应该根据图片的高度来定。
Font font = new Font("Fixedsys", Font.BOLD, fontHeight);
// 设置字体。
gd.setFont(font);
// 画边框。
gd.setColor(Color.BLACK);
gd.drawRect(0, 0, width - 1, height - 1);
// 随机产生40条干扰线,使图象中的认证码不易被其它程序探测到。
gd.setColor(Color.BLACK);
for (int i = 0; i < 30; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
gd.drawLine(x, y, x + xl, y + yl);
}
// randomCode用于保存随机产生的验证码,以便用户登录后进行验证。
StringBuffer randomCode = new StringBuffer();
int red = 0, green = 0, blue = 0;
// 随机产生codeCount数字的验证码。
for (int i = 0; i < codeCount; i++) {
// 得到随机产生的验证码数字。
String code = String.valueOf(codeSequence[random.nextInt(36)]);
// 产生随机的颜色分量来构造颜色值,这样输出的每位数字的颜色值都将不同。
red = random.nextInt(255);
green = random.nextInt(255);
blue = random.nextInt(255);
// 用随机产生的颜色将验证码绘制到图像中。
gd.setColor(new Color(red, green, blue));
gd.drawString(code, (i + 1) * xx, codeY);
// 将产生的四个随机数组合在一起。
randomCode.append(code);
}
Map<String,Object> map =new HashMap<String,Object>();
//存放验证码
map.put("code", randomCode);
//存放生成的验证码BufferedImage对象
map.put("codePic", buffImg);
return map;
}
public static void main(String[] args) throws Exception {
//创建文件输出流对象
OutputStream out = new FileOutputStream(""+System.currentTimeMillis()+".jpg");
Map<String,Object> map = CodeUtil.generateCodeAndPic();
ImageIO.write((RenderedImage) map.get("codePic"), "jpeg", out);
System.out.println("验证码的值为:"+map.get("code"));
}
}
2.OrderController
//生成验证码
@RequestMapping(value = "/generateverifycode",method = {RequestMethod.GET,RequestMethod.POST})
@ResponseBody
public void generateverifycode(HttpServletResponse response) throws BusinessException, IOException {
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,"用户还未登陆,不能生成验证码");
}
Map<String,Object> map = CodeUtil.generateCodeAndPic();
redisTemplate.opsForValue().set("verify_code_"+userModel.getId(),map.get("code"));
redisTemplate.expire("verify_code_"+userModel.getId(),10,TimeUnit.MINUTES);
ImageIO.write((RenderedImage) map.get("codePic"), "jpeg", response.getOutputStream());
}
//生成秒杀令牌
@RequestMapping(value = "/generatetoken",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType generatetoken(@RequestParam(name="itemId")Integer itemId,
@RequestParam(name="promoId")Integer promoId,
@RequestParam(name="verifyCode")String verifyCode) throws BusinessException {
//根据token获取用户信息
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,"用户还未登陆,不能下单");
}
//通过verifycode验证验证码的有效性
String redisVerifyCode = (String) redisTemplate.opsForValue().get("verify_code_"+userModel.getId());
if(StringUtils.isEmpty(redisVerifyCode)){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法");
}
if(!redisVerifyCode.equalsIgnoreCase(verifyCode)){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"请求非法,验证码错误");
}
//获取秒杀访问令牌
String promoToken = promoService.generateSecondKillToken(promoId,itemId,userModel.getId());
if(promoToken == null){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
}
//返回对应的结果
return CommonReturnType.create(promoToken);
}
浏览器访问: https://localhost:8090/order/generateverifycode?token=35d0be10933b4227886439577de305ad
修改前端代码
--getitem.html
<div id="verifyDiv" style="display:none;" class="form-actions">
<img src=""/>
<input type="text" id="verifyContent" value=""/>
<button class="btn blue" id="verifyButton" type="submit">
验证
</button>
</div>
$("#verifyDiv img").attr("src","https://"+g_host+"/order/generateverifycode?token="+token);
$("#verifyDiv").show();
限流目的
流量远比你想的要多
系统活着比挂了要好
宁愿只让少数人能用,也不要让所有人不能用
TPS:用来衡量对数据库会产生写操作,transaction操作的一个容量指标
QPS:查询每秒数量的指标
限流方案
限并发
令牌桶算法
漏桶算法
令牌桶算法
漏桶算法
令牌桶算法:限制每一秒的流量的最大值,可以应对一些突发的流量
漏桶算法:是用来平滑网络流量,以固定的速率流入
互联网行业用的最多的还是令牌桶算法
限流力度
接口维度
总维度
限流范围
◆集群限流:依赖 redis或其他的中间件技术做统一计数器,往往会产生性能瓶颈
◆单机限流:负载均衡的前提下单机平均限流效果更好
代码实现:
1.OrderController
private RateLimiter orderCreateRateLimiter;
@PostConstruct
public void init(){
executorService = Executors.newFixedThreadPool(20);
orderCreateRateLimiter = RateLimiter.create(300);
}
if(orderCreateRateLimiter.acquire() <= 0){
throw new BusinessException(EmBusinessError.RATELIMIT);
}
本章主要对课程所介绍的内容做总结,列出所涉及到的关键知识点,回顾电商秒杀系统,并提出问题以及扩展方案。
项目框架回顾
项目结构分层、业务逻辑分层、领域模型分层
代码实战中成长,并发现问题
业务编码过程中需要思考性能问题
性能压测框架
云端部署体验企业级开发流程
容器优化通用方案,管道优化通用方案
分布式扩展
负载均衡设计
水平扩展vs垂直扩展
查询优化技术之多级缓存
多级缓存屏障系统
读不到脏读
越近越好的缓存
查询优化技术之页面静态化
CDN的美妙设计
一切皆页面,一切皆静态
交易优化技术之缓存库存
交易验证:性能和正确性的权衡
库存模型:性能和可用性的权衡
交易优化技术之事务型消息
ACID vs CAP & BASE
最终一致性方案
流量错峰技术
防浪费
防洪峰、防击穿
排队
防刷限流
错峰
限流
防黄牛