SecKill Project

引言:

基于SpringBoot框架构建热点商品秒杀项目,亮点: Redis实现分布式Session, 页面缓存,RabbitMQ+接口优化, 线上部署

概述

image-20200411143727434

image-20200411144022521

image-20200411144315168

image-20200411144431348

image-20200411144519383

image-20200411144550929

image-20200411144619165

image-20200411144719295

image-20200411144759843

image-20200411144901230

image-20200411144938608

第一章小点

Redis缓存key值统一设置规则,以及通用缓存Key封装方式。

image-20200411154218042

第二章小点

两次MD5

image-20200411153913886

登陆页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- jquery -->
<script type="text/javascript" th:src="@{/js/jquery.min.js}"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" th:href="@{/bootstrap/css/bootstrap.min.css}" />
<script type="text/javascript" th:src="@{/bootstrap/js/bootstrap.min.js}"></script>
<!-- jquery-validator -->
<script type="text/javascript" th:src="@{/jquery-validation/jquery.validate.min.js}"></script>
<script type="text/javascript" th:src="@{/jquery-validation/localization/messages_zh.min.js}"></script>
<!-- layer -->
<script type="text/javascript" th:src="@{/layer/layer.js}"></script>
<!-- md5.js -->
<script type="text/javascript" th:src="@{/js/md5.min.js}"></script>
<!-- common.js -->
<script type="text/javascript" th:src="@{/js/common.js}"></script>

@{/js/jquery.min.js}中第一个/代表的是resources/static路径

required="true" minlength="6" maxlength="16"jquery-validator提供的验证

参数验证

前台传的参数与对象里的属性名字相同spring就会自动包装,比如前台传的参数名字是mobilepasswordLoginVo对象里的属性名字也是mobilepassword就会自动包装

ajax前端传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<script>
function login(){
// 首先进行参数校验
$("#loginForm").validate({
// 通过时回调方法
submitHandler:function(form){
doLogin();
}
});
}
function doLogin(){
// loading框
g_showLoading();

// md5加密密码
var inputPass = $("#password").val(); // 获取表单明文密码
var salt = g_passsword_salt; // 盐值
var str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
var password = md5(str);

// ajax异步提交
$.ajax({
url: "/login/do_login",
type: "POST",
data:{
// 参数1直接从表单
mobile:$("#mobile").val(),
// 参数2是经过md加密计算的
password: password
},
// 回调1
success:function(data){
// 关框
layer.closeAll();
if(data.code == 0){
layer.msg("成功");
// 跳转到商品列表页面
window.location.href="/goods/to_list";
}else{
layer.msg(data.msg);
}
},
// 回调2
error:function(){
// 关框
layer.closeAll();
}
});
}
</script>

jsr303参数校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class })
public @interface IsMobile {

boolean required() default true;

String message() default "手机号码格式错误";

Class<?>[] groups() default { };

Class<? extends Payload>[] payload() default { };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class IsMobileValidator implements ConstraintValidator<IsMobile, String> {

private boolean required = false;

public void initialize(IsMobile constraintAnnotation) {
required = constraintAnnotation.required();
}

public boolean isValid(String value, ConstraintValidatorContext context) {
if(required) {
return ValidatorUtil.isMobile(value);
}else {
if(StringUtils.isEmpty(value)) {
return true;
}else {
return ValidatorUtil.isMobile(value);
}
}
}
}

使用自定义的注解进行参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class LoginVo {

@NotNull
@IsMobile // 自定义的验证器
private String mobile;

@NotNull
@Length(min=32)
private String password;

public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "LoginVo [mobile=" + mobile + ", password=" + password + "]";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Controller
@RequestMapping("/login")
public class LoginController {
private static Logger log = LoggerFactory.getLogger(LoginController.class);

@Autowired
MiaoshaUserService userService;

@RequestMapping("/to_login")
public String toLogin() {
return "login";
}

@RequestMapping("/do_login")
@ResponseBody
public Result<Boolean> doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
log.info(loginVo.toString());
//登录
userService.login(response, loginVo);
return Result.success(true);
}
}

异常捕获

service层为业务逻辑层,该层方法返回值一定要是跟业务相关逻辑(一些异常情况的返回最好不要出现在这里),对该层的异常采取的是抛出全局异常的方式,然后定义一个全局异常处理器。如下

1
2
3
4
5
6
7
8
9
10
11
public class GlobalException extends RuntimeException{
private static final long serialVersionUID = 1L;
private CodeMsg cm;
public GlobalException(CodeMsg cm) {
super(cm.toString());
this.cm = cm;
}
public CodeMsg getCm() {
return cm;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(value=Exception.class)
public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
e.printStackTrace();
if(e instanceof GlobalException) {
GlobalException ex = (GlobalException)e;
return Result.error(ex.getCm());
}else if(e instanceof BindException) {
BindException ex = (BindException)e;
List<ObjectError> errors = ex.getAllErrors();
ObjectError error = errors.get(0);
String msg = error.getDefaultMessage();
return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
}else {
return Result.error(CodeMsg.SERVER_ERROR);
}
}
}

分布式Session

现实情况下会有多台服务器,用户的请求会落在不同的服务器上。

第一步:目标是用户登录成功后将用户标识信息token存入cookie可供后续获取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
@Service
public class MiaoshaUserService {


public static final String COOKI_NAME_TOKEN = "token";

@Autowired
MiaoshaUserDao miaoshaUserDao;

@Autowired
RedisService redisService;

public MiaoshaUser getById(long id) {
return miaoshaUserDao.getById(id);
}


public MiaoshaUser getByToken(HttpServletResponse response, String token) {
// 一定要养成参数验证的习惯
if(StringUtils.isEmpty(token)) {
return null;
}
MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
//延长有效期
if(user != null) {
addCookie(response, token, user);
}
return user;
}

// 登陆业务逻辑
// 包装登陆验证方法
public boolean login(HttpServletResponse response, LoginVo loginVo) {
if(loginVo == null) {
// 异常时直接抛
throw new GlobalException(CodeMsg.SERVER_ERROR);
}
String mobile = loginVo.getMobile();
String formPass = loginVo.getPassword();
//判断手机号是否存在
MiaoshaUser user = getById(Long.parseLong(mobile));
if(user == null) {
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
//验证密码
String dbPass = user.getPassword();
String saltDB = user.getSalt();
// 计算从表单密码进行二次MD5后的值
String calcPass = MD5Util.formPassToDBPass(formPass, saltDB);
// 两者比较
if(!calcPass.equals(dbPass)) {
throw new GlobalException(CodeMsg.PASSWORD_ERROR);
}
//生成cookie
String token = UUIDUtil.uuid();
addCookie(response, token, user);
return true;
}

private void addCookie(HttpServletResponse response, String token, MiaoshaUser user) {
redisService.set(MiaoshaUserKey.token, token, user);
Cookie cookie = new Cookie(COOKI_NAME_TOKEN, token);
cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
cookie.setPath("/");
response.addCookie(cookie);
}

}

方便后续将token对应用户信息,,需要将用户信息存入第三方缓存中(redis)

redisService.set(MiaoshaUserKey.token, token, user);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
@Service
public class RedisService {
@Autowired
JedisPool jedisPool;

/**
* 获取当个对象
* */
public <T> T get(KeyPrefix prefix, String key, Class<T> clazz) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//生成真正的key
String realKey = prefix.getPrefix() + key;
String str = jedis.get(realKey);
T t = stringToBean(str, clazz);
return t;
}finally {
returnToPool(jedis);
}
}

/**
* 设置对象
* */
public <T> boolean set(KeyPrefix prefix, String key, T value) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
String str = beanToString(value);
if(str == null || str.length() <= 0) {
return false;
}
//生成真正的key
String realKey = prefix.getPrefix() + key;
int seconds = prefix.expireSeconds();
if(seconds <= 0) {
jedis.set(realKey, str);
}else {
jedis.setex(realKey, seconds, str);
}
return true;
}finally {
returnToPool(jedis);
}
}

/**
* 判断key是否存在
* */
public <T> boolean exists(KeyPrefix prefix, String key) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//生成真正的key
String realKey = prefix.getPrefix() + key;
return jedis.exists(realKey);
}finally {
returnToPool(jedis);
}
}

/**
* 增加值
* */
public <T> Long incr(KeyPrefix prefix, String key) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//生成真正的key
String realKey = prefix.getPrefix() + key;
return jedis.incr(realKey);
}finally {
returnToPool(jedis);
}
}

/**
* 减少值
* */
public <T> Long decr(KeyPrefix prefix, String key) {
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
//生成真正的key
String realKey = prefix.getPrefix() + key;
return jedis.decr(realKey);
}finally {
returnToPool(jedis);
}
}

private <T> String beanToString(T value) {
if(value == null) {
return null;
}
Class<?> clazz = value.getClass();
if(clazz == int.class || clazz == Integer.class) {
return ""+value;
}else if(clazz == String.class) {
return (String)value;
}else if(clazz == long.class || clazz == Long.class) {
return ""+value;
}else {
return JSON.toJSONString(value);
}
}

@SuppressWarnings("unchecked")
private <T> T stringToBean(String str, Class<T> clazz) {
if(str == null || str.length() <= 0 || clazz == null) {
return null;
}
if(clazz == int.class || clazz == Integer.class) {
return (T)Integer.valueOf(str);
}else if(clazz == String.class) {
return (T)str;
}else if(clazz == long.class || clazz == Long.class) {
return (T)Long.valueOf(str);
}else {
return JSON.toJavaObject(JSON.parseObject(str), clazz);
}
}

private void returnToPool(Jedis jedis) {
if(jedis != null) {
jedis.close();
}
}
}

image-20200412160254459

image-20200412160401214

第二步:根据cookie中token信息确定用户。完成将一个token映射成一个用户MiaoshaUser

image-20200412160901918

上面两步就基本完成了分布式Session的概况。

不足:

有效期设置。通过token获取用户信息时添加一个延长有限期的方法,即重新设置一个token,并将用户信息存入缓存中。(上述代码已经改过了)

并不优雅。这里是实现商品列表页(to_list)的用户登录判断,会有一大串代码,再到商品详情页(/to_detail)又会重新在验证一遍,很冗余。(上述代码已经改过了)

第三步:直接将MiaoshaUser对象传入方法参数中

image-20200412162746692

这里方法是实现ArgumentResolver,SpringMVC中controller方法中会有很多参数,例如HttpServletResponse response, Model model,这些参数的值是怎么来的?

image-20200412163350761

就是由上面这个方法实现的。框架会回调这个方法向我们的controller方法中赋值。这里想将MiaoshaUser添加入参数中,就需要在List<HandlerMethodArgumentResolver>中添加一个该类型的参数占住位置。UserArgumentResolver定义该参数,WebConfig中注入该参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

// 注入进去
@Autowired
UserArgumentResolver userArgumentResolver;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
// 添加进参数列表中
argumentResolvers.add(userArgumentResolver);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 自定义这个参数
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

@Autowired
MiaoshaUserService userService;

public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz== MiaoshaUser.class;
}

public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);

String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return null;
}
String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
return userService.getByToken(response, token);
}

private String getCookieValue(HttpServletRequest request, String cookiName) {
Cookie[] cookies = request.getCookies();
for(Cookie cookie : cookies) {
if(cookie.getName().equals(cookiName)) {
return cookie.getValue();
}
}
return null;
}
}

修改完成后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
@RequestMapping("/goods")
public class GoodsController {

@Autowired
MiaoshaUserService userService;

@Autowired
RedisService redisService;

@RequestMapping("/to_list")
public String list(Model model, MiaoshaUser user) {
model.addAttribute("user", user);
return "goods_list";
}
}

list方法变得很清爽,业务逻辑全部由UserArgumentResolver类中resolveArgument方法实现,以后更改获取session的方法也只需要到这个方法中变更。

第三章小点

数据库设计

image-20200412165739790

秒杀商品和秒杀订单两张表同其余两个表分开。

1
2
3
4
5
6
7
8
9
10
11
-- 商品表
CREATE TABLE `goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`goods_name` varchar(16) DEFAULT NULL COMMENT '商品名称',
`goods_title` varchar(64) DEFAULT NULL COMMENT '商品标题',
`goods_img` varchar(64) DEFAULT NULL COMMENT '商品的图片',
`goods_detail` longtext COMMENT '商品的详情介绍',
`goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品单价',
`goods_stock` int(11) DEFAULT '0' COMMENT '商品库存,-1表示没有限制',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4;
1
2
3
4
5
6
7
8
9
10
11
-- 秒杀表
CREATE TABLE `miaosha_goods` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '秒杀的商品表',
-- 对应goods表id
`goods_id` bigint(20) DEFAULT NULL COMMENT '商品ID',
`miaosha_price` decimal(10,2) DEFAULT '0.00' COMMENT '秒杀价',
`stock_count` int(11) DEFAULT NULL COMMENT '秒杀库存数量',
`start_date` datetime DEFAULT NULL COMMENT '秒杀开始时间',
`end_date` datetime DEFAULT NULL COMMENT '秒杀结束时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 订单表
CREATE TABLE `order_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
`goods_id` bigint(20) DEFAULT NULL COMMENT '商品ID',
`delivery_addr_id` bigint(20) DEFAULT NULL COMMENT '收获地址ID',
-- 避免了从goods表中关联
`goods_name` varchar(16) DEFAULT NULL COMMENT '冗余过来的商品名称',
`goods_count` int(11) DEFAULT '0' COMMENT '商品数量',
`goods_price` decimal(10,2) DEFAULT '0.00' COMMENT '商品单价',
`order_channel` tinyint(4) DEFAULT '0' COMMENT '1pc,2android,3ios',
`status` tinyint(4) DEFAULT '0' COMMENT '订单状态,0新建未支付,1已支付,2已发货,3已收货,4已退款,5已完成',
`create_date` datetime DEFAULT NULL COMMENT '订单的创建时间',
`pay_date` datetime DEFAULT NULL COMMENT '支付时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1565 DEFAULT CHARSET=utf8mb4;
1
2
3
4
5
6
7
8
9
10
11
12
-- 秒杀订单表
DROP TABLE IF EXISTS `miaosha_order`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `miaosha_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL COMMENT '用户ID',
`order_id` bigint(20) DEFAULT NULL COMMENT '订单ID',
`goods_id` bigint(20) DEFAULT NULL COMMENT '商品ID',
PRIMARY KEY (`id`),
UNIQUE KEY `u_uid_gid` (`user_id`,`goods_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1551 DEFAULT CHARSET=utf8mb4;

Vo类介绍

例如:LoginVo,表示的是表单提交信息的包装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class LoginVo {

@NotNull
@IsMobile
private String mobile;

@NotNull
@Length(min=32)
private String password;

public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "LoginVo [mobile=" + mobile + ", password=" + password + "]";
}
}

GoodsVo是对商品和秒杀商品的包装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GoodsVo extends Goods{
private Double miaoshaPrice;
private Integer stockCount;
private Date startDate;
private Date endDate;
public Integer getStockCount() {
return stockCount;
}
public void setStockCount(Integer stockCount) {
this.stockCount = stockCount;
}
public Date getStartDate() {
return startDate;
}
public void setStartDate(Date startDate) {
this.startDate = startDate;
}
public Date getEndDate() {
return endDate;
}
public void setEndDate(Date endDate) {
this.endDate = endDate;
}
public Double getMiaoshaPrice() {
return miaoshaPrice;
}
public void setMiaoshaPrice(Double miaoshaPrice) {
this.miaoshaPrice = miaoshaPrice;
}
}

这类包装类一般都是作为各层方法参数上的传输。

页面倒计时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script>
$(function(){
countDown();
});

function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}
</script>

秒杀执行

1
2
3
4
5
6
<td>
<form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
<button class="btn btn-primary btn-block" type="submit" id="buyButton">立即秒杀</button>
<input type="hidden" name="goodsId" th:value="${goods.id}" />
</form>
</td>

页面form表单的提交,里面的数据就是商品的ID(goodsId),提交到的路径是:/miaosha/do_miaosha

之后就再MiaoshaController里面完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Controller
@RequestMapping("/goods")
public class GoodsController {

@Autowired
MiaoshaUserService userService;

@Autowired
RedisService redisService;

@Autowired
GoodsService goodsService;

@RequestMapping("/to_detail/{goodsId}")
public String detail(Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);

GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);

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;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);
return "goods_detail";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class MiaoshaService {

@Autowired
GoodsService goodsService;

@Autowired
OrderService orderService;

@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
// 减库存 下订单 写入秒杀订单
// 在这里同样是对于库存的操作,通用方式是再service中引入service,不可直接使用别的dao
// 所以,减库存操作由GoodsService完成
goodsService.reduceStock(goods);
// order_info maiosha_order
// 这里是生成两个表,订单表和秒杀订单表,返回的是订单表
return orderService.createOrder(user, goods);
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class OrderService {

@Autowired
OrderDao orderDao;

public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
}

// 生成订单
// 事务
@Transactional
public OrderInfo createOrder(MiaoshaUser user, GoodsVo goods) {
// 商品订单
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateDate(new Date());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsCount(1);
orderInfo.setGoodsId(goods.getId());
orderInfo.setGoodsName(goods.getGoodsName());
orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
orderInfo.setOrderChannel(1);
orderInfo.setStatus(0);
orderInfo.setUserId(user.getId());
// 订单id
long orderId = orderDao.insert(orderInfo);

// 秒杀商品订单
MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
miaoshaOrder.setGoodsId(goods.getId());
miaoshaOrder.setOrderId(orderId);
miaoshaOrder.setUserId(user.getId());
orderDao.insertMiaoshaOrder(miaoshaOrder);
return orderInfo;
}
}

第四章小点

JMeter

并发测试:是指当并发达到多少时,网站的QPS是多少

QPS每秒查询率(Query Per Second)
每秒查询率QPS是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准,在因特网上,作为域名系统服务器的机器的性能经常用每秒查询率来衡量。对应fetches/sec,即每秒的响应请求数,也即是最大吞吐能力。 (看来是类似于TPS,只是应用于特定场景的吞吐量)

添加线程数

image-20200413093504398

配置一个Http请求的默认值

image-20200413093703330

配置http请求

image-20200413093822087

添加结果

image-20200413094028350

无任何优化的结果大概是在84的QPS

image-20200413094559371

使用top命令查看系统进程情况,load average参数已经严重过载,mysql进程占用CPU最多,所有的负载被其占尽。(说明瓶颈在这)

测试二:带参数的接口压测(新建一个获取用户信息的接口)

image-20200413095116321

多用户时,可以添加一个配置文件,然后引用配置文件即可。

image-20200413095811883

命令行测试

image-20200413095911842

将程序打jar包:F:/IdeaProjects/miaosha>mvn clean package

分析一下这个包:

image-20200413103917986

将jar包传到服务器上,开始运行:

image-20200413104155718

nohup会将运行结果输入到当前文件夹下的nohup.out文件中。

查看nohup.out文件能看到程序运行结果

image-20200413104418400

将Jmeter中商品列表的接口测试另存为生成一个.jmx文件,上传到服务器中,开始在服务器中压测。

image-20200413104845014

结果:

image-20200413105137072

/goods_list接口、QPS1267、5000并发*10

/do_miaosha接口测试

先生成Jmeter配置文件

image-20200413105533274

同时将所需文件上传服务器,(将miaosha.jmx配置文件上传路径修改)执行

image-20200413105730203

结果:

image-20200413105926592

image-20200413110044182

/do_miaosha接口、QPS1161、5000并发*10

Redis压测

image-20200413095952307

方式为使用redis自带的压测工具benchmark。第一条命令行含义为模拟100个并发,100000个请求。第二条命令标识更改为100字节测试(第一条命令时3字节),-p表示简化结果输出

结果:1秒能完成62695个请求

image-20200413100301929

第三条测试命令:

image-20200413100927007

表示只测试 setlpush命令,同样是100000个请求。

第四条命令:

image-20200413101034562

只测试指定的一条命令。

SpringBoot打war包

需求:将程序打成war包放到tomcat下运行。

方法:(pom引入两个依赖就好)

image-20200413101304638

1是编译时依赖,2是war包插件

最后还要修改一下主方法:

image-20200413102452760

打包:F:/IdeaProjects/miaosha>mvn clean package

完成后会生成文件:miaosha.war

放入tomcat文件加下:

image-20200413101943255

启动后会有个路径问题/miaosha,可以直接将war包放入ROOT文件下,或者将/miaosha部署为空。

附加:linux删除rm -rf file_name;下载sz file_name

第五章-页面优化

页面级采用缓存

页面缓存+URL缓存+对象缓存

粒度:页面缓存 > URL缓存 > 对象缓存

页面缓存

商品列表为例:

原:thymeleaf模板渲染goods_list,将数据放入model中,然后加载渲染

1
2
3
4
5
6
7
@RequestMapping(value="/to_list")
public String list(Model model,MiaoshaUser user) {
model.addAttribute("user", user);
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
return "goods_list";
}

改:将整个页面作为一个对象存入缓存

image-20200413143543547

实现方式:第三方取页面缓存 ? 输出 : 手动渲染页面(使用SpringBoot框架自带)+ 存入第三方缓存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequestMapping(value="/to_list", produces="text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user) {
model.addAttribute("user", user);
// 取缓存
String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
if(!StringUtils.isEmpty(html)) {
return html;
}
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
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秒差不多了)

URL缓存

与页面缓存原理一样,页面缓存类似于一个商品列表页的缓存,而URL缓存类似于商品详情页的缓存。

同样对于商品详情页面的缓存

原:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RequestMapping("/to_detail/{goodsId}")
public String detail(Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);

GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goods);

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;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
model.addAttribute("remainSeconds", remainSeconds);
return "goods_detail";
}

改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@RequestMapping(value="/to_detail2/{goodsId}",produces="text/html")
@ResponseBody
public String detail2(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user,
@PathVariable("goodsId")long goodsId) {
model.addAttribute("user", user);

//取缓存
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 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;
}
model.addAttribute("miaoshaStatus", miaoshaStatus);
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缓存不同,这里的粒度最细,对象级别。例如上述的分布式session实现:

1
2
3
4
5
6
7
8
9
10
11
public MiaoshaUser getByToken(HttpServletResponse response, String token) {
if(StringUtils.isEmpty(token)) {
return null;
}
MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
//延长有效期
if(user != null) {
addCookie(response, token, user);
}
return user;
}

这里将修改秒杀用户获取方式:

原:

1
2
3
public MiaoshaUser getById(long id) {
return miaoshaUserDao.getById(id);
}

实现方式:第三方取对象缓存 ? 输出 : 访问数据库取出 + 存入第三方缓存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
}

注意点:当有对对象的修改操作发生时,同时需要对缓存的更新。且顺序一定是先更新数据库数据,再更新缓存数据。

这里涉及缓存和数据库数据一致性问题,最常用的逻辑如下:

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

  • 命中:应用程序从cache中取数据,取到后返回。

  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

为什么呢?因为如果是先修改缓存,再数据库,当缓存失效时发生读操作,缓存无效,从数据库中读取脏数据,再存入缓存,后数据库更新,就会产生数据不一致现象。

这里也可以解释,在service层中调用的一定是别的service而不是别的dao,因为在各个service层中可能会有缓存数据的存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 例如对对象密码进行修改时
public boolean updatePassword(String token, long id, String formPass) {
//取user
MiaoshaUser user = getById(id);
if(user == null) {
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
}
// 1.更新数据库
MiaoshaUser toBeUpdate = new MiaoshaUser();
toBeUpdate.setId(id);
toBeUpdate.setPassword(MD5Util.formPassToDBPass(formPass, user.getSalt()));
miaoshaUserDao.update(toBeUpdate);
// 2.处理缓存
// 删缓存
redisService.delete(MiaoshaUserKey.getById, ""+id);
user.setPassword(toBeUpdate.getPassword());
// 重存缓存
redisService.set(MiaoshaUserKey.token, token, user);
return true;
}

这里将修改秒杀商品订单获取方式:

引入第三方缓存,执行秒杀验证用户是否重复秒杀时不直接从数据库中查找订单。

原:

1
2
3
public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
}

改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public MiaoshaOrder getMiaoshaOrderByUserIdGoodsId(long userId, long goodsId) {
//return orderDao.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
return redisService.get(OrderKey.getMiaoshaOrderByUidGid, ""+userId+"_"+goodsId, MiaoshaOrder.class);
}

@Transactional
public OrderInfo createOrder(MiaoshaUser user, GoodsVo goods) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateDate(new Date());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsCount(1);
orderInfo.setGoodsId(goods.getId());
orderInfo.setGoodsName(goods.getGoodsName());
orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
orderInfo.setOrderChannel(1);
orderInfo.setStatus(0);
orderInfo.setUserId(user.getId());
long orderId = orderDao.insert(orderInfo);
MiaoshaOrder miaoshaOrder = new MiaoshaOrder();
miaoshaOrder.setGoodsId(goods.getId());
miaoshaOrder.setOrderId(orderId);
miaoshaOrder.setUserId(user.getId());
orderDao.insertMiaoshaOrder(miaoshaOrder);

redisService.set(OrderKey.getMiaoshaOrderByUidGid, ""+user.getId()+"_"+goods.getId(), miaoshaOrder);

return orderInfo;
}

压测结果:QPS-2884

页面静态化,前后端分离

页面缓存与页面静态话的区别

页面静态化即所有页面都是纯HTML,通过JS和Ajax请求服务端拉取数据渲染页面。静态化后浏览器可以将HTML缓存在客户端,就不用重复下载页面数据,只需要下载动态数据即可,极大节省了网络流量。

而页面缓存仍是服务器端缓,仍需要从服务端下载数据,会耗费很大的流量。

image-20200413152722071

实现,这里不用上面的技术实现,而是使用jquery模拟实现方式。

案例

案例1:使用的商品详情页面/to_detail/{goodsId}

页面只存HTML(纯HTML),动态数据通过接口和服务端获取。

1.先将商品详情信息重新封装为GoodsDetailVo类,在详情页面方法中传递,该方法体中没有页面渲染操作了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RequestMapping(value="/detail/{goodsId}")
@ResponseBody
public Result<GoodsDetailVo> detail(HttpServletRequest request,
HttpServletResponse response, Model model,MiaoshaUser 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);
}

2.页面修改

案例2:秒杀执行静态化/do_miaosha

页面只存HTML,动态数据通过接口和服务端获取。先将秒杀订单封装为MiaoshaOrderVo,传递到页面中。

附加:浏览器缓存(的时间定义)
Pragma :HTTP1.0
Expire :HTTP1.0&1.1 服务器标准时间(带时区)
Cache-Controller :HTTP1.0&1.1 客户端定义i时间(300秒)

原:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@RequestMapping("/do_miaosha")
public String list(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return "login";
}
//判断库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {
model.addAttribute("errmsg", CodeMsg.MIAO_SHA_OVER.getMsg());
return "miaosha_fail";
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
model.addAttribute("errmsg", CodeMsg.REPEATE_MIAOSHA.getMsg());
return "miaosha_fail";
}
//减库存 下订单 写入秒杀订单
OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
model.addAttribute("orderInfo", orderInfo);
model.addAttribute("goods", goods);
return "order_detail";
}

改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequestMapping(value="/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<OrderInfo> miaosha(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//判断库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);//10个商品,req1 req2
int stock = goods.getStockCount();
if(stock <= 0) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//减库存 下订单 写入秒杀订单
OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
return Result.success(orderInfo);
}

页面动态调用服务端接口获取数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<script>
function doMiaosha(){
$.ajax({
url:"/miaosha/do_miaosha",
type:"POST",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
if(data.code == 0){
window.location.href="/order_detail.htm?orderId="+data.data.id;
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});

}

function render(detail){
var miaoshaStatus = detail.miaoshaStatus;
var remainSeconds = detail.remainSeconds;
var goods = detail.goods;
var user = detail.user;
if(user){
$("#userTip").hide();
}
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"));
$("#remainSeconds").val(remainSeconds);
$("#goodsId").val(goods.id);
$("#goodsPrice").text(goods.goodsPrice);
$("#miaoshaPrice").text(goods.miaoshaPrice);
$("#stockCount").text(goods.stockCount);
countDown();
}

$(function(){
//countDown();
getDetail();
});

function getDetail(){
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:"/goods/detail/"+goodsId,
type:"GET",
success:function(data){
if(data.code == 0){
render(data.data);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}

function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}
</script>

案例3:获取订单详情页面/order/detail

页面只存HTML,动态数据通过接口和服务端获取。先将秒杀订单封装为MiaoshaOrderVo,传递到页面中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequestMapping("/detail")
@ResponseBody
public Result<OrderDetailVo> info(Model model,MiaoshaUser 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);
}

超卖问题

第一步:在减库存操作中修改Updat sql语句:

image-20200414215009331

这是因为数据库会在Update执行时在执行语句的线程上加上锁,不会允许同时有多个线程执行。

第二部:设置同一用户不允许重买,即同一个用户秒杀到两个商品。

解决方法:利用数据库的唯一索引,在miaosha_order表中调价一个由用户id+商品id的唯一索引。

image-20200414215351562

image-20200414215703666

这样在创建订单的方法中,生成秒杀订单过程中如果有唯一索引冲突就会报错然后由于springboot的事务机制产生回滚。

总结:

image-20200414222739879

静态资源优化

image-20200414220416041

总结:并发问题的根源在于数据库的访问,最有效的解决方式时添加缓存,缓存的种类有很多,从用户的角度来说,从用户发起请求,在浏览器上可以通过页面的静态化将页面缓存在用户的浏览器端,在请求到达服务器之前,可以布置一些CDN节点,让请求访问CDN节点,再发来的请求访问时,可以在Nginx上添加缓存,再过来时可以在应用程序上添加缓存,即页面缓存。更细粒度的就是对象缓存,一直到最后的数据库缓存。

第六章-接口优化

服务级接口优化

总体思路:较少数据库访问。

image-20200511211400986

image-20200414223030637

image-20200414223148943

RabbitMQ

安装

安装erlang

安装RebbitMQ

涉及一些linux命令行可供参考

集成

image-20200414230810058

使用

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@Configuration
public class MQConfig {

public static final String MIAOSHA_QUEUE = "miaosha.queue";
public static final String QUEUE = "queue";
public static final String TOPIC_QUEUE1 = "topic.queue1";
public static final String TOPIC_QUEUE2 = "topic.queue2";
public static final String HEADER_QUEUE = "header.queue";
public static final String TOPIC_EXCHANGE = "topicExchage";
public static final String FANOUT_EXCHANGE = "fanoutxchage";
public static final String HEADERS_EXCHANGE = "headersExchage";

/**
* Direct模式 交换机Exchange
* */
@Bean
public Queue queue() {
return new Queue(QUEUE, true);
}

/**
* Topic模式 交换机Exchange
* */
@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);
}
@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.#");
}
/**
* Fanout模式 交换机Exchange
* */
@Bean
public FanoutExchange fanoutExchage(){
return new FanoutExchange(FANOUT_EXCHANGE);
}
@Bean
public Binding FanoutBinding1() {
return BindingBuilder.bind(topicQueue1()).to(fanoutExchage());
}
@Bean
public Binding FanoutBinding2() {
return BindingBuilder.bind(topicQueue2()).to(fanoutExchage());
}
/**
* Header模式 交换机Exchange
* */
@Bean
public HeadersExchange headersExchage(){
return new HeadersExchange(HEADERS_EXCHANGE);
}
@Bean
public Queue headerQueue1() {
return new Queue(HEADER_QUEUE, true);
}
@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();
}
}

消息发送者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class MQSender {

private static Logger log = LoggerFactory.getLogger(MQSender.class);

@Autowired
AmqpTemplate amqpTemplate ;

// 演示一个发送消息
public void send(Object message) {
String msg = RedisService.beanToString(message);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.QUEUE, msg);
}

// 发送真实秒杀消息
public void sendMiaoshaMessage(MiaoshaMessage mm) {
String msg = RedisService.beanToString(mm);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.MIAOSHA_QUEUE, msg);
}
}

消息接收者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Service
public class MQReceiver {
private static Logger log = LoggerFactory.getLogger(MQReceiver.class);

@Autowired
RedisService redisService;

@Autowired
GoodsService goodsService;

@Autowired
OrderService orderService;

@Autowired
MiaoshaService miaoshaService;


// 演示接受消息
@RabbitListener(queues=MQConfig.QUEUE)
public void receive(String message) {
log.info("receive message:"+message);
}

// 秒杀消息出队
// 类似之前的controller方法执行秒杀任务
@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();

// 查库存,这里访问的是DB
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {
return;
}

// 判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return;
}
//减库存 下订单 写入秒杀订单
miaoshaService.miaosha(user, goods);
}
}

秒杀优化

目标:将系统的同步下单改为异步下单

  1. 商品库存加载
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Controller
@RequestMapping("/miaosha")
public class MiaoshaController implements InitializingBean {

/**
* 系统初始化
* 系统启动时加载秒杀商品库存数量到redis中
* */
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if(goodsList == null) {
return;
}
for(GoodsVo goods : goodsList) {
// 存入redis
redisService.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
}
}
}
  1. redis预减库存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RequestMapping(value="/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}

// redis预减库存,返回减后的值
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}

// 判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
// 入队
// 秒杀消息对象
// 这里需要将上文的MQ配置更改
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
return Result.success(0);// 排队中
}

这里再修改一下秒杀代码,

原代码:

1
2
3
4
5
6
7
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下订单 写入秒杀订单
goodsService.reduceStock(goods);
//order_info maiosha_order
return orderService.createOrder(user, goods);
}

修改后:

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下订单 写入秒杀订单
boolean success = goodsService.reduceStock(goods);
if(success) {
//order_info maiosha_order
return orderService.createOrder(user, goods);
}else {
setGoodsOver(goods.getId());
return null;
}
}
  1. 前端的代码修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
<script>
function getMiaoshaResult(goodsId){
g_showLoading();
$.ajax({
url:"/miaosha/result",
type:"GET",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
if(data.code == 0){
var result = data.data;
if(result < 0){
layer.msg("对不起,秒杀失败");
}else if(result == 0){//继续轮询
setTimeout(function(){
getMiaoshaResult(goodsId);
}, 50);
}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("客户端请求有误");
}
});
}

function doMiaosha(){
$.ajax({
url:"/miaosha/do_miaosha",
type:"POST",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
if(data.code == 0){
//window.location.href="/order_detail.htm?orderId="+data.data.id;
getMiaoshaResult($("#goodsId").val());
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});

}

function render(detail){
var miaoshaStatus = detail.miaoshaStatus;
var remainSeconds = detail.remainSeconds;
var goods = detail.goods;
var user = detail.user;
if(user){
$("#userTip").hide();
}
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"));
$("#remainSeconds").val(remainSeconds);
$("#goodsId").val(goods.id);
$("#goodsPrice").text(goods.goodsPrice);
$("#miaoshaPrice").text(goods.miaoshaPrice);
$("#stockCount").text(goods.stockCount);
countDown();
}

$(function(){
//countDown();
getDetail();
});

function getDetail(){
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:"/goods/detail/"+goodsId,
type:"GET",
success:function(data){
if(data.code == 0){
render(data.data);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}

function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}

</script>
  1. 客户端轮询请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
	/**
* 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);
}
long result =miaoshaService.getMiaoshaResult(user.getId(), goodsId);
return Result.success(result);
}
  1. 设置内存标记
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 标记
private HashMap<Long, Boolean> localOverMap = new HashMap<Long, Boolean>();

@RequestMapping(value="/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
model.addAttribute("user", user);
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//内存标记,减少redis访问
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}

// redis预减库存
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
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);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中
}

Nginx简介

配置文件的主要参数

image-20200415211335146

Nginx监听服务器80端口,将所有请求反向代理(proxy_pass)给http://server_pool_miaosha,在http://server_pool_miaosha中可以配置多个tomcat服务器接受请求,这样就实现了横向扩展。weight参数表示的是请求分配的比重,即负载均衡的实现。max_failsfail_timeout是一种服务器探活机制,

第七章-安全优化

秒杀接口隐藏

image-20200511214319724

原因:因为我们的前端地址代码都是透明的,很容易受到攻击

  1. 前端代码修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<td> 
<form id="miaoshaForm" method="post" action="/miaosha/do_miaosha">
<button class="btn btn-primary btn-block" type="submit" id="buyButton" onclick="getMiaoshaPath()">立即秒杀</button>
<input type="hidden" name="goodsId" id="goodsId" />
</form>
<input type="hidden" name="goodsId" id="goodsId" />
</td>
<script>
function getMiaoshaPath(){
var goodsId = $("#goodsId").val();
g_showLoading();
$.ajax({
url:"/miaosha/path",
type:"GET",
data:{
goodsId:goodsId,
verifyCode:$("#verifyCode").val()
},
success:function(data){
if(data.code == 0){
var path = data.data;
doMiaosha(path);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}
</script>
  1. 后端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@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);
}
//内存标记,减少redis访问
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//预减库存
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
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);
sender.sendMiaoshaMessage(mm);
return Result.success(0);//排队中
}

@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}

String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}

图形验证

image-20200415213915167

  1. 前端,在秒杀进行中情况下显示图形验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<div class="row">
<div class="form-inline">
<img id="verifyCodeImg" width="80" height="32" style="display:none" onclick="refreshVerifyCode()"/>
<input id="verifyCode" class="form-control" style="display:none"/>
<!--点击秒杀返回的是获取接口参数-->
<button class="btn btn-primary" type="button" id="buyButton"onclick="getMiaoshaPath()">立即秒杀</button>
</div>
</div>

<script>
function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId="+$("#goodsId").val());
$("#verifyCodeImg").show();
$("#verifyCode").show();
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
$("#verifyCodeImg").hide();
$("#verifyCode").hide();
}
}
function refreshVerifyCode(){
$("#verifyCodeImg").attr("src", "/miaosha/verifyCode?goodsId="+$("#goodsId").val()+"&timestamp="+new Date().getTime());
}
</script>
  1. 后端对应验证码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequestMapping(value="/verifyCode", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaVerifyCod(HttpServletResponse response,MiaoshaUser user,
@RequestParam("goodsId")long goodsId) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
try {
BufferedImage image = miaoshaService.createVerifyCode(user, goodsId);
OutputStream out = response.getOutputStream();
ImageIO.write(image, "JPEG", out);
out.flush();
out.close();
return null;
}catch(Exception e) {
e.printStackTrace();
return Result.error(CodeMsg.MIAOSHA_FAIL);
}
}
  1. 验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode
) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}

修改后的MiaoshaService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
@Service
public class MiaoshaService {

@Autowired
GoodsService goodsService;

@Autowired
OrderService orderService;

@Autowired
RedisService redisService;

@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//减库存 下订单 写入秒杀订单
boolean success = goodsService.reduceStock(goods);
if(success) {
//order_info maiosha_order
return orderService.createOrder(user, goods);
}else {
setGoodsOver(goods.getId());
return null;
}
}

public long getMiaoshaResult(Long userId, long goodsId) {
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(userId, goodsId);
if(order != null) {//秒杀成功
return order.getOrderId();
}else {
boolean isOver = getGoodsOver(goodsId);
if(isOver) {
return -1;
}else {
return 0;
}
}
}

private void setGoodsOver(Long goodsId) {
redisService.set(MiaoshaKey.isGoodsOver, ""+goodsId, true);
}

private boolean getGoodsOver(long goodsId) {
return redisService.exists(MiaoshaKey.isGoodsOver, ""+goodsId);
}

public void reset(List<GoodsVo> goodsList) {
goodsService.resetStock(goodsList);
orderService.deleteOrders();
}

public boolean checkPath(MiaoshaUser user, long goodsId, String path) {
if(user == null || path == null) {
return false;
}
String pathOld = redisService.get(MiaoshaKey.getMiaoshaPath, ""+user.getId() + "_"+ goodsId, String.class);
return path.equals(pathOld);
}

public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
String str = MD5Util.md5(UUIDUtil.uuid()+"123456");
redisService.set(MiaoshaKey.getMiaoshaPath, ""+user.getId() + "_"+ goodsId, str);
return str;
}

public BufferedImage createVerifyCode(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
int width = 80;
int height = 32;
//create the image
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics g = image.getGraphics();
// set the background color
g.setColor(new Color(0xDCDCDC));
g.fillRect(0, 0, width, height);
// draw the border
g.setColor(Color.black);
g.drawRect(0, 0, width - 1, height - 1);
// create a random instance to generate the codes
Random rdm = new Random();
// make some confusion
for (int i = 0; i < 50; i++) {
int x = rdm.nextInt(width);
int y = rdm.nextInt(height);
g.drawOval(x, y, 0, 0);
}
// generate a random code
String verifyCode = generateVerifyCode(rdm);
g.setColor(new Color(0, 100, 0));
g.setFont(new Font("Candara", Font.BOLD, 24));
g.drawString(verifyCode, 8, 24);
g.dispose();
//把验证码存到redis中
int rnd = calc(verifyCode);
redisService.set(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, rnd);
//输出图片
return image;
}

public boolean checkVerifyCode(MiaoshaUser user, long goodsId, int verifyCode) {
if(user == null || goodsId <=0) {
return false;
}
Integer codeOld = redisService.get(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId, Integer.class);
if(codeOld == null || codeOld - verifyCode != 0 ) {
return false;
}
redisService.delete(MiaoshaKey.getMiaoshaVerifyCode, user.getId()+","+goodsId);
return true;
}

private static int calc(String exp) {
try {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("JavaScript");
return (Integer)engine.eval(exp);
}catch(Exception e) {
e.printStackTrace();
return 0;
}
}

private static char[] ops = new char[] {'+', '-', '*'};
/**
* + - *
* */
private String generateVerifyCode(Random rdm) {
int num1 = rdm.nextInt(10);
int num2 = rdm.nextInt(10);
int num3 = rdm.nextInt(10);
char op1 = ops[rdm.nextInt(3)];
char op2 = ops[rdm.nextInt(3)];
String exp = ""+ num1 + op1 + num2 + op2 + num3;
return exp;
}
}

接口防刷

缓存记录用户访问次数进行限制

  1. 一般方式:在秒杀接口中加入如下代码

image-20200512223440072

  1. 通用方式:注解拦截器

注解

1
2
3
4
5
6
7
@Retention(RUNTIME)
@Target(METHOD)
public @interface AccessLimit {
int seconds();
int maxCount();
boolean needLogin() default true;
}

拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter{

@Autowired
MiaoshaUserService userService;

@Autowired
RedisService redisService;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if(handler instanceof HandlerMethod) {
MiaoshaUser user = getUser(request, response);
UserContext.setUser(user);
HandlerMethod hm = (HandlerMethod)handler;
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if(accessLimit == null) {
return true;
}
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI();
if(needLogin) {
if(user == null) {
render(response, CodeMsg.SESSION_ERROR);
return false;
}
key += "_" + user.getId();
}else {
//do nothing
}
AccessKey ak = AccessKey.withExpire(seconds);
Integer count = redisService.get(ak, key, Integer.class);
if(count == null) {
redisService.set(ak, key, 1);
}else if(count < maxCount) {
redisService.incr(ak, key);
}else {
render(response, CodeMsg.ACCESS_LIMIT_REACHED);
return false;
}
}
return true;
}

private void render(HttpServletResponse response, CodeMsg cm)throws Exception {
response.setContentType("application/json;charset=UTF-8");
OutputStream out = response.getOutputStream();
String str = JSON.toJSONString(Result.error(cm));
out.write(str.getBytes("UTF-8"));
out.flush();
out.close();
}

private MiaoshaUser getUser(HttpServletRequest request, HttpServletResponse response) {
String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return null;
}
String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
return userService.getByToken(response, token);
}

private String getCookieValue(HttpServletRequest request, String cookiName) {
Cookie[] cookies = request.getCookies();
if(cookies == null || cookies.length <= 0){
return null;
}
for(Cookie cookie : cookies) {
if(cookie.getName().equals(cookiName)) {
return cookie.getValue();
}
}
return null;
}
}

这里修改了获取用户代码,修改前

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

@Autowired
MiaoshaUserService userService;

public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz==MiaoshaUser.class;
}

public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);

String paramToken = request.getParameter(MiaoshaUserService.COOKI_NAME_TOKEN);
String cookieToken = getCookieValue(request, MiaoshaUserService.COOKI_NAME_TOKEN);
if(StringUtils.isEmpty(cookieToken) && StringUtils.isEmpty(paramToken)) {
return null;
}
String token = StringUtils.isEmpty(paramToken)?cookieToken:paramToken;
return userService.getByToken(response, token);
}

private String getCookieValue(HttpServletRequest request, String cookiName) {
Cookie[] cookies = request.getCookies();
if(cookies == null || cookies.length <= 0){
return null;
}
for(Cookie cookie : cookies) {
if(cookie.getName().equals(cookiName)) {
return cookie.getValue();
}
}
return null;
}
}

修改后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

@Autowired
MiaoshaUserService userService;

public boolean supportsParameter(MethodParameter parameter) {
Class<?> clazz = parameter.getParameterType();
return clazz==MiaoshaUser.class;
}

public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return UserContext.getUser();
}
}

验证信息包装

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserContext {

private static ThreadLocal<MiaoshaUser> userHolder = new ThreadLocal<MiaoshaUser>();

public static void setUser(MiaoshaUser user) {
userHolder.set(user);
}

public static MiaoshaUser getUser() {
return userHolder.get();
}

}

注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter{

@Autowired
UserArgumentResolver userArgumentResolver;

@Autowired
AccessInterceptor accessInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(userArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessInterceptor);
}
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@AccessLimit(seconds=5, maxCount=5, needLogin=true)
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath(HttpServletRequest request, MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode
) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}

总结