项目地址
https://gitee.com/noah2021/miaosha 转载,亲测可用!
测试用例
在下订单之前需要先发布对应的商品用于在Redis
中生成口令避免大量请求导致服务器崩溃~~
发布商品的URL
是:http://127.0.0.1/item/publishpromo?id=1(最后的id
根据你在链接上看到的自己来就行)
项目测试地址是:http://127.0.0.1/miaosha/login.html
用户名:188888,密码:000000
当然也是支持注册账户的,不过没集成短信验证码的功能,验证码发布在服务器的控制台所以你啥也干不了。
课程介绍
本项目来自慕课网:聚焦Java性能优化 打造亿级流量秒杀系统
课程中借由“电商秒杀”案例,通过多种性能优化技术,总结了互联网项目中“秒杀”的经典性能优化方案技术,提供了统一的设计思维和思考方式,帮助真正理解性能优化中每个技术的使用以及背后的原理。
知识图谱
技术选型
前端: jQuery
后端: SpringBoot + Mybatis
**中间件:**RocketMQ + Redis + Druid
配置环境
依赖
- org.springframework.boot:spring-boot-start-parent:2.2.2.RELEASE
- org.springframework.boot:spring-boot-starter-web
- org.springframework.boot:spring-boot-starter-test
- org.springframework.boot:spring-boot-starter-jdbc
- org.mybatis.generator:mybatis-generator-maven-plugin:1.3.5
- org.mybatis.spring.boot:mybatis-spring-boot-starter:2.1.4
- mysql:mysql-connector-java:5.1.41
- com.alibaba:druid:1.2.3
- org.springframework.boot:spring-boot-starter-data-redis
- org.springframework.session:spring-session-data-redis:2.0.5.RELEASE
- org.projectlombok:lombok
- junit:junit:4.10
- org.apache.commons:commons-lang3:3.3.2
- org.hibernate:hibernate-validator:5.2.4.Final
- joda-time:joda-time:2.6
- com.google.guava:guava:18.0
- org.apache.rocketmq:rocketmq-client:4.3.0
- javax.xml.bind:jaxb-api:2.3.0(JavaEE的API,由于JDK9中不包含所以引入,JDK6/7/8不必引入)
- com.sun.xml.bind:jaxb-impl:2.3.0(同上)
- com.sun.xml.bind:jaxb-core:2.3.0(同上)
- javax.activation:activation:1.1.1(同上)
MySQL
这里我是通过宝塔面板安装的,服务端选择的是MariaDB
,数据库的初始密码设置在面板里。
当本地连接云服务器时出现Host xxx is not allowed to connect to this MariaDb server
,可能是你的帐号不允许从远程登陆,只能在localhost
。这个时候只要在localhost
的那台电脑,登入MySQL
后,更改 mysql
数据库里的 user
表里的 host
字段,从localhost
改称%
mysql -u root -p
use mysql;
update user set host = '%' where user = 'root' and host='localhost';
select host, user from user;
同样也会云服务器连不上MySQL
的情况,也同样是修改user
表的权限。结果如下:
然后重启MySQL服务或再执行执行一个语句mysql>FLUSH PRIVILEGES
使修改生效。
Redis
这一块很容易出错,还是要注意一下,步骤如下:
-
先修改redis的配置文件
redis.conf
- 在
NETWORK
块里面将访问权限更改为所有人也就是修改为BINK 0.0.0.0
- 在
GENERAL
块的daemonize
修改为yes
- 在视频中老师将
INCLUDES
里面添加了:/requirepass
,我修改后反而启动报错,删除了才好,对此修改持保留态度 - 在
SECURITY
块里面添加requirepass + 密码
用于多加一层验证,保护数据库安全。当客户端想要访问数据时,需要进行权限认证AUTH + 密码
- 在
-
创建
Redis
服务- 进入
Redis
目录的utils
目录 - 执行Shell文件并新建配置文件
redis.conf
,日志文件redis.log
和数据目录data
,这样是为了方便我们以后进行管理(在输入路径的时候不可撤销,建议在记事本上写好后粘贴)
./install_server.sh # 配置 [root@LEGION-Y7000 utils]# ./install_server.sh Welcome to the redis service installer This script will help you easily set up a running redis server Please select the redis port for this instance: [6379] Selecting default: 6379 # 进行 Please select the redis config file name [/etc/redis/6379.conf] /www/server/redis-5.0.8/redis.conf Please select the redis log file name [/var/log/redis_6379.log] /www/server/redis-5.0.8/redis.log Please select the data directory for this instance [/var/lib/redis/6379] /www/server/redis-5.0.8/data Please select the redis executable path [/usr/local/bin/redis-server] Selected config: Port : 6379 Config file : /www/server/redis-5.0.8/redis.conf Log file : /www/server/redis-5.0.8/redis.log Data dir : /www/server/redis-5.0.8/data Executable : /usr/local/bin/redis-server Cli Executable : /usr/local/bin/redis-cli Is this ok? Then press ENTER to go on or Ctrl-C to abort.ok Copied /tmp/6379.conf => /etc/init.d/redis_6379 Installing service... failed to glob pattern /etc/rc0.d/[SK][0-9][0-9]redis_6379: No such file or directory failed to glob pattern /etc/rc0.d/[SK][0-9][0-9]redis_6379: No such file or directory /var/run/redis_6379.pid exists, process is already running or crashed Installation successful!
- 服务安装成果后可通过命令
chkconfig --list | grep redis
或者在/etc/rc.d/init.d/redis_6379
查看redis_6379
服务的具体内容
- 进入
创建数据库
-- MySQL dump 10.16 Distrib 10.1.44-MariaDB, for Linux (x86_64)
--
-- Host: localhost Database: miaosha
-- ------------------------------------------------------
-- Server version 10.1.44-MariaDB
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Current Database: `miaosha`
--
CREATE DATABASE /*!32312 IF NOT EXISTS*/ `miaosha` /*!40100 DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci */;
USE `miaosha`;
--
-- Table structure for table `item`
--
DROP TABLE IF EXISTS `item`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `item` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(64) NOT NULL DEFAULT '',
`price` double(10,0) NOT NULL DEFAULT '0',
`description` varchar(500) NOT NULL DEFAULT '',
`sales` int(11) NOT NULL DEFAULT '0',
`img_url` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `item`
--
LOCK TABLES `item` WRITE;
/*!40000 ALTER TABLE `item` DISABLE KEYS */;
INSERT INTO `item` VALUES (1,'Sony_XM2',1100,'初级降噪',0,'https://img12.360buyimg.com/n7/jfs/t1/153308/37/12948/287783/5feda8ceEf68df9ea/fe428c62d634d809.jpg'),(2,'Sony_XM3',1200,'中级降噪',0,'https://img13.360buyimg.com/n7/jfs/t1/162012/37/5466/112680/601a7790E23094383/dd13972e46680ff6.jpg'),(3,'Sony_XM4',1300,'高级降噪',1,'https://img10.360buyimg.com/n7/jfs/t1/132549/13/7602/50860/5f3e4926E8dc899e7/ea99eabb3dba7ad1.jpg'),(9,'iPhoneX',3000,'引领业界潮流',0,'https://img12.360buyimg.com/n7/jfs/t1/165611/2/6231/405356/60236e1cE9c6501d4/6d12ddd970f7dd2e.png'),(10,'iPad',2000,'工作生产力',1,'https://img11.360buyimg.com/n7/jfs/t1/123771/23/12622/61075/5f616e9cE68afe904/f90cc40ce6de49bc.jpg');
/*!40000 ALTER TABLE `item` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `item_stock`
--
DROP TABLE IF EXISTS `item_stock`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `item_stock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`stock` int(11) NOT NULL DEFAULT '0',
`item_id` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `item_id_index` (`item_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `item_stock`
--
LOCK TABLES `item_stock` WRITE;
/*!40000 ALTER TABLE `item_stock` DISABLE KEYS */;
INSERT INTO `item_stock` VALUES (6,99,1),(7,99,2),(8,98,3),(9,99,9),(10,98,10);
/*!40000 ALTER TABLE `item_stock` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `order_info`
--
DROP TABLE IF EXISTS `order_info`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `order_info` (
`id` varchar(32) NOT NULL,
`user_id` int(11) NOT NULL DEFAULT '0',
`item_id` int(11) NOT NULL DEFAULT '0',
`item_price` double NOT NULL DEFAULT '0',
`amount` int(11) NOT NULL DEFAULT '0',
`order_price` double NOT NULL DEFAULT '0',
`promo_id` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `order_info`
--
LOCK TABLES `order_info` WRITE;
/*!40000 ALTER TABLE `order_info` DISABLE KEYS */;
INSERT INTO `order_info` VALUES ('2021021100000100',23,3,100,1,100,1),('2021021100000200',23,10,100,1,100,3);
/*!40000 ALTER TABLE `order_info` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `promo`
--
DROP TABLE IF EXISTS `promo`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `promo` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`promo_name` varchar(255) NOT NULL DEFAULT '',
`start_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
`item_id` int(11) NOT NULL DEFAULT '0',
`promo_item_price` double NOT NULL DEFAULT '0',
`end_date` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `promo`
--
LOCK TABLES `promo` WRITE;
/*!40000 ALTER TABLE `promo` DISABLE KEYS */;
INSERT INTO `promo` VALUES (1,'耳机促销','2021-02-11 00:00:00',3,100,'2021-02-28 00:00:00'),(2,'手机白菜价','2021-02-12 00:00:00',9,100,'2021-02-13 00:00:00'),(3,'平板甩卖','2021-02-11 00:00:00',10,100,'2021-03-01 00:00:00');
/*!40000 ALTER TABLE `promo` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `sequence_info`
--
DROP TABLE IF EXISTS `sequence_info`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `sequence_info` (
`name` varchar(255) NOT NULL,
`current_value` int(11) NOT NULL DEFAULT '0',
`step` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `sequence_info`
--
LOCK TABLES `sequence_info` WRITE;
/*!40000 ALTER TABLE `sequence_info` DISABLE KEYS */;
INSERT INTO `sequence_info` VALUES ('order_info',3,1);
/*!40000 ALTER TABLE `sequence_info` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `stock_log`
--
DROP TABLE IF EXISTS `stock_log`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
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;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `stock_log`
--
LOCK TABLES `stock_log` WRITE;
/*!40000 ALTER TABLE `stock_log` DISABLE KEYS */;
/*!40000 ALTER TABLE `stock_log` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `user_info`
--
DROP TABLE IF EXISTS `user_info`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `user_info` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL DEFAULT '',
`gender` tinyint(4) NOT NULL DEFAULT '0' COMMENT '//1代表男性,2代表女性',
`age` int(11) NOT NULL DEFAULT '0',
`telphone` varchar(255) NOT NULL DEFAULT '',
`register_mode` varchar(255) NOT NULL DEFAULT '' COMMENT '//byphone,bywechat,byalipay',
`third_party_id` varchar(64) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `telphone_unique_index` (`telphone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `user_info`
--
LOCK TABLES `user_info` WRITE;
/*!40000 ALTER TABLE `user_info` DISABLE KEYS */;
INSERT INTO `user_info` VALUES (23,'严辉华',0,50,'15839787863','iPhone',''),(24,'张永久',1,51,'13663978158','iPhone',''),(25,'张路民',1,22,'15737680205','iPhone','');
/*!40000 ALTER TABLE `user_info` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `user_password`
--
DROP TABLE IF EXISTS `user_password`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `user_password` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`encrpt_password` varchar(128) NOT NULL DEFAULT '',
`user_id` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `user_password`
--
LOCK TABLES `user_password` WRITE;
/*!40000 ALTER TABLE `user_password` DISABLE KEYS */;
INSERT INTO `user_password` VALUES (14,'NjY2NjY2',23),(15,'ODg4ODg4',24),(16,'MDAwMDAw',25);
/*!40000 ALTER TABLE `user_password` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2021-02-11 15:36:28
基础项目开发
自动生成耗时的pojo
类、dao
接口以及相应的mapper.xml
文件
需要使用mybatis
的自动生成的工具,完成对数据库文件的映射,这里需要在pom
文件里引入自动生成的插件依赖
<!--mybatis-generator的依赖 自动生成javabean和sql-->
<dependency>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.3.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.41</version>
<scope>runtime</scope>
</dependency>
编写mybatis-generator.xml
,用来自动生成pojo
类和XXXmapper.xml
文件
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
<context id="DB2Tables" targetRuntime="MyBatis3">
<!--数据库链接地址账号密码-->
<jdbcConnection driverClass="com.mysql.jdbc.Driver" connectionURL="jdbc:mysql://你的数据库服务器IP地址:3306/miaosha"
userId="root" password="admin">
</jdbcConnection>
<!--生成pojo存放位置-->
<javaModelGenerator targetPackage="com.noah2021.pojo" targetProject="src/main/java">
<property name="enableSubPackages" value="true"/>
<property name="trimStrings" value="true"/>
</javaModelGenerator>
<!--生成映射文件存放位置-->
<sqlMapGenerator targetPackage="mybatis.mapper" targetProject="src/main/resources">
<property name="enableSubPackages" value="true"/>
</sqlMapGenerator>
<!--生成Dao类存放位置-->
<!-- 客户端代码,生成易于使用的针对Model对象和XML配置文件 的代码
type="ANNOTATEDMAPPER",生成Java Model 和基于注解的Mapper对象
type="MIXEDMAPPER",生成基于注解的Java Model 和相应的Mapper对象
type="XMLMAPPER",生成SQLMap XML文件和独立的Mapper接口
-->
<javaClientGenerator type="XMLMAPPER" targetPackage="com.noah2021.dao" targetProject="src/main/java">
<property name="enableSubPackages" value="true"/>
</javaClientGenerator>
<!--生成对应表及类名,这里每一个表的五项属性是为了删除自动编写的复杂查询-->
<table tableName="user_info" domainObjectName="UserDO" enableCountByExample="false"
enableUpdateByExample="false" enableDeleteByExample="false"
enableSelectByExample="false" selectByExampleQueryId="false"></table>
<table tableName="user_password" domainObjectName="UserPasswordDO" enableCountByExample="false"
enableUpdateByExample="false" enableDeleteByExample="false"
enableSelectByExample="false" selectByExampleQueryId="false"></table>
</context>
</generatorConfiguration>
添加mybatis-gengerator
插件,注意版本应该和上面依赖的版本一致
<pluginManagement><!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
<plugins>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.3.5</version>
<dependencies>
<dependency>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-core</artifactId>
<version>1.3.5</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.41</version>
</dependency>
</dependencies>
<executions>
<execution>
<id>mybatis generator</id>
<phase>package</phase>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<!--允许移动生成的文件-->
<verbose>true</verbose>
<!--允许自动覆盖文件,第一次使用的时候用true,之后改回false-->
<overwrite>true</overwrite>
<configurationFile>
src/main/resources/mybatis-generator.xml
</configurationFile>
</configuration>
</plugin>
</plugins>
</pluginManagement>
进入Run/Debug Configurations
面板,添加一个maven
命令:mybatis-generator:generate
,然后执行命令,就可以得到下图所示的目录结构
mapper.xml
文件中的update
和insert
标签:使用useGeneratedKeys="true"
获取主键并赋值到keyProperty
设置的领域模型属性中,keyProperty
的值是对象的。
模型架构
接入层:View Object
与前端对接的模型,隐藏内部实现,仅供展示的聚合模型
业务层:Domain Model
领域模型,业务核心模型,拥有生命周期,贫血并以服务输出能力
数据层:Data Object
数据模型,同数据库映射,用以ORM
方式操作数据库的能力模型
业务操作
Demo示范
在真正的生产环境中,pojo
不可简单地将数据库的数据传送给service
,这里新建新建一个model
层用于保护数据库信息安全,接下来我们将按照上面模型架构的方式来写一个小demo
-
先新建一个
UserModel
类,它包含UserDO
的全部字段以及UserPasswordDO
的encrptPassword
字段,然后将他俩组装成UserModel
private Integer id; private String name; private Byte gender; private Integer age; private String telphone; private String registerMode; private String thirdPartyId; private String encrptPassword;
-
编写
service
层对应接口和类public interface UserService { public UserModel getUserById(Integer id); }
@Service public class UserServiceImpl implements UserService { @Autowired UserDOMapper userDOMapper; @Autowired UserPasswordDOMapper userPasswordDOMapper; @Override public UserModel getUserById(Integer id) { UserDO userDO = userDOMapper.selectByPrimaryKey(id); if(userDO == null) return null; //这里为了严谨,将UserPasswordDOMapper接口的selectByPrimarykey的方法名进行了修改 UserPasswordDO userPasswordDO = userPasswordDOMapper.selectByUserId(id); return convertFromDataObject(userDO,userPasswordDO); } //由userDO和userPasswordDO组装(验证)成一个userModel对象 public UserModel convertFromDataObject(UserDO userDO, UserPasswordDO userPasswordDO){ if(userDO == null) return null; UserModel userModel = new UserModel(); BeanUtils.copyProperties(userDO, userModel); //这里userModel只是缺一个encrptPassword变量,所以只用将userPasswordDO相应的字段赋过来即可 if(userPasswordDO != null) userModel.setEncrptPassword(userPasswordDO.getEncrptPassword()); return userModel; } }
-
编写
controller
类@Controller @RequestMapping("/user") public class UserController { @Autowired UserService userService; @RequestMapping("/get") @ResponseBody public UserModel getUser(@RequestParam("id")int id){ UserModel userModel = userService.getUserById(id); return userModel; } }
-
结果如图所示,这里存在不安全性,一旦请求数据被人截获用户的密码也就被知道了(虽然后面我们还会经过MD5对明文密码进行加密,这里最好还是不要被别人知道),能把
encrptPassword
去掉最好 -
之前,我们将
model
对象直接传给前端,现在为了去掉encrptPassword
,新加了一个viewobject
层,新建类UserVO
,它只包含下面字段private Integer id; private String name; private Byte gender; private Integer age; private String telephone;
-
在
UserController
将原来的userModel
对象转换成UserVO
对象,然后进行返回到前端,重启项目结果如下
CommonReturnType
当status
= 500时,需要给前端正确的提示,于是新建一个CommonReturnType
类
返回成功信息
public class CommonReturnType {
//表明对应请求的返回处理结果 "success" 或 "fail"
private String status;
//若status=success,则data内返回前端需要的json数据
//若status=fail,则data内使用通用的错误码格式
private Object data;
//定义一个通用的创建方法
public static CommonReturnType create(Object result){
return CommonReturnType.create(result,"success");
}
public static CommonReturnType create(Object result,String status){
CommonReturnType type = new CommonReturnType();
type.setStatus(status);
type.setData(result);
return type;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
}
再在UserController
里面返回CommonReturnType
类的数据,结果如下:
装饰器模式
装饰者(Decorator)和具体组件(ConcreteComponent)都继承自组件(Component),具体组件的方法实现不需要依赖于其它对象,而装饰者组合了一个组件,这样它可以装饰其它装饰者或者具体组件。所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。装饰者的方法有一部分是自己的,这属于它的功能,然后调用被装饰者的方法实现,从而也保留了被装饰者的功能。可以看到,具体组件应当是装饰层次的最低层,因为只有具体组件的方法实现不需要依赖于其它对象。
<>
![](https://gitee.com/noah2021/blogImage/raw/master/img/20210206094354.png)
返回失败信息
通过使用包装器(装饰器)组装类的实现:由于EmBusinessError
和BusinessException
都继承了CommonErr
接口,达到不用新建EmBusinessError
和BusinessException
的类就可获得errCode
和errMsg
的组装类,同时接口里面的setErrMsg
还达到可以替换原本的errMsg
进行自定义的功能
-
CommonErr
接口的实现//组件(Component) public interface CommonError { public int getErrCode(); public String getErrMsg(); //返回值是CommonError是为了BusinessException public CommonError setErrMsg(String errMsg); }
-
EmBusinessError
的实现//具体组件(ConcreteComponent) public enum EmBusinessError implements CommonError { //通用错误类型10001 PARAMETER_VALIDATION_ERROR(10001,"参数不合法"), UNKNOWN_ERROR(10002,"未知错误"), //20000开头为用户信息相关错误定义 USER_NOT_EXIST(20001,"用户不存在"), USER_LOGIN_FAIL(20002,"用户手机号或密码不正确"), USER_NOT_LOGIN(20003,"用户还未登陆"), //30000开头为交易信息错误定义 STOCK_NOT_ENOUGH(30001,"库存不足"), ; EmBusinessError(int errCode,String errMsg){ this.errCode = errCode; this.errMsg = errMsg; } private int errCode; private String errMsg; @Override public int getErrCode() { return this.errCode; } @Override public String getErrMsg() { return this.errMsg; } public void setErrCode(int errCode) { this.errCode = errCode; } @Override public CommonError setErrMsg(String errMsg) { this.errMsg = errMsg; return this; } }
-
BusinessException
的实现//装饰器(Decorator) public class BusinessException extends Exception implements CommonError { private CommonError commonError; //直接接收EmBusinessError的传参用于构造业务异常 public BusinessException(CommonError commonError){ super(); this.commonError = commonError; } //接收自定义errMsg的方式构造业务异常 public BusinessException(CommonError commonError,String errMsg){ super(); this.commonError = commonError; this.commonError.setErrMsg(errMsg); } @Override public int getErrCode() { return this.commonError.getErrCode(); } @Override public String getErrMsg() { return this.commonError.getErrMsg(); } @Override public CommonError setErrMsg(String errMsg) { this.commonError.setErrMsg(errMsg); return this; } public CommonError getCommonError() { return commonError; } }
-
修改
UserController
# 修改 @RequestMapping("/get") @ResponseBody public CommonReturnType getUser(@RequestParam("id") int id) throws BusinessException { UserModel userModel = userService.getUserById(id); if(userModel == null) //空指针异常 userModel.setEncptPassword("111"); //throw new BusinessException(EmBusinessError.USER_NOT_EXIST); UserVO userVO = convertFromUserModel(userModel); return CommonReturnType.create(userVO); } # 新增 //解决未被controller吸收的异常 @ExceptionHandler(Exception.class)//当收到Exception类型的异常进入该方法 @ResponseStatus(HttpStatus.OK)//即使收到异常也返回OK @ResponseBody public Object handlerException(HttpServletRequest request, Exception e){ BusinessException businessException = (BusinessException) e; CommonReturnType commonReturnType = new CommonReturnType(); commonReturnType.setStatus("fail"); HashMap<String, Object> data = new HashMap<>(); data.put("errCode", businessException.getErrCode()); data.put("errMsg", businessException.getErrMsg()); commonReturnType.setData(data); return commonReturnType; }
-
将新增的方法添加到基类
BaseController
并对代码进行优化,这样以后每个继承它的类都可以执行该业务public enum EmBusinessError implements CommonError { //通用错误类型 PARAMETER_VOLIDATION_ERROR(10001, "参数不合法"), //未知错误 UNKNOWN_ERROR(10002, "未知错误"), //20000开头为用户信息相关错误 USER_NOT_EXIST(20001," 用户不存在") ; private int errCode; private String errMsg;
public class BaseController { //解决未被controller吸收的异常 @ExceptionHandler(Exception.class)//当收到Exception类型的异常进入该方法 @ResponseStatus(HttpStatus.OK)//即使收到异常也返回OK @ResponseBody public Object handlerException(HttpServletRequest request, Exception e) { HashMap<String, Object> data = new HashMap<>(); if (e instanceof BusinessException) { BusinessException businessException = (BusinessException) e; data.put("errCode", businessException.getErrCode()); data.put("errMsg", businessException.getErrMsg()); } else { data.put("errCode", EmBusinessError.UNKNOWN_ERROR.getErrCode()); data.put("errMsg", EmBusinessError.UNKNOWN_ERROR.getErrMsg()); } return CommonReturnType.create(data, "fail"); } }
谷歌商店里面有一个JSON-handle
的插件特别好用,推荐一下~
短信验证码
这里实现了,一个简单的注册功能。前端代码略
这里贴一下UserController
@Autowired
HttpServletRequest httpServletRequest;
/*produces:它的作用是指定返回值类型,不但可以设置返回值类型还可以设定返回值的字符编码;
consumes: 指定处理请求的提交内容类型(Content-Type),例如application/json, text/html;*/
@RequestMapping(value = "/getotp",method = {RequestMethod.POST}, consumes={CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType getOtp(@RequestParam("telphone") String telphone){
Random random = new Random();
int randomInt = random.nextInt(99999);
randomInt += 10000;
String otpCode = String.valueOf(randomInt);
//手机号和验证码用key-value对的形式保存起来,应当放在redis里面,这里为了简单就输出到控制台上
httpServletRequest.getSession().setAttribute(telphone, "otpCode");
System.out.println("telphone: " + telphone + ", otpCode: "+ otpCode);
return CommonReturnType.create(null);
}
用户注册接口
-
定义函数签名,参数列表包括:
telphone
、otpCode
、name
、gender
、age
、password
-
验证输入的验证码和对应验证码是否符合
-
进入用户注册流程:
UserService
实现 → \rightarrow →UserModel
转换成UserDO
和UserPasswordDO
→ \rightarrow →UserServiceImpl
实现(注意判空)<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.7</version> </dependency> # 可以用apache的StringUtil类
这里选用
insertSelective
较于insert
的优点是:在传给的UserDO
字段为空时可以不覆盖数据库的默认字段。小tip:建议数据库的字段都设为非空字段,但是也不尽然,在网站设置强绑定(即第三方登陆仍然需要手机号注册)的情况下,手机号字段为唯一索引,而注册用户在用手机号注册后使用第三方登陆注册会出现注册不了的现象,这种情况手机号字段设成
null
是比较合适的,因为唯一索引不限制null
的唯一。
注册前端页面
- 编写前端注册页面
- 处理
getotp.html
和register.html
间的session
共享
DEFAULT_ALLOW_CREDENTIALS=true
:需配合前端设置xhrFields
授信后使得跨域session
共享
@CrossOrigin(allowCredentials = "true", allowedHeaders = "*")
//前端
xhrFields:{withCredentials:true}
- 修改之前的
MD5
加密方式
//由于JDK9不能用Base64Encoder所以改用Base64
public String encodeByMd5(String str) throws NoSuchAlgorithmException, UnsupportedEncodingException {
//确定计算方法
MessageDigest md5 = MessageDigest.getInstance("MD5");
//BASE64Encoder base64Encoder = new BASE64Encoder();
Base64.Encoder encoder = Base64.getEncoder();
//加密字符串
String newstr = encoder.encodeToString(md5.digest(str.getBytes("utf-8"));
return newstr;
}
截至目前,很容易出现bug
的两点:
- 由于
SpringBoot
版本太高,导致@CrossOrigin
加自定义属性的时候就会启动不了,我是把版本从2.4.2
降到2.2.2
才好 - 视频里判断验证码和
HttpServletRequest
获得的session
的值是否相同时用的是Druid
的StringUtils
类的equals
方法,通过Debug
发现一运行到那一行就会出现调用目标异常(栈溢出),改用Apache
的就行了
登陆
前端页面较为简单,略,后端业务实现步骤如下:
- 在
controller
类首先入参校验手机号和密码都不能为空,将密码通过MD5
的方式传入service
进行校验 - 首先通过手机号获取
id
后再在UserPasswordDO
表里通过user_id
拿到UserPasswordDO
对象,组合成UserModel
后的EncrptPassword
与传参传过来的加密密码进行对比,如果相同返回给controller
controller
通过session
传入两个变量LOGIN
、LOGIN_USER
留给以后用,返回给前端处理信息
优化表单校验⭐️
-
引入依赖
<dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-validator</artifactId> <version>5.4.1.Final</version> </dependency>
-
实现
ValidationResult
类来展示验证结果public class ValidationResult { //校验结果是否有错 private boolean hasErrors = false; //存放错误信息的map private Map<String, String> errorMsgMap = new HashMap<>(); public boolean isHasErrors() { return hasErrors; } public void setHasErrors(boolean hasErrors) { this.hasErrors = hasErrors; } public Map<String, String> getErrorMsgMap() { return errorMsgMap; } public void setErrorMsgMap(Map<String, String> errorMsgMap) { this.errorMsgMap = errorMsgMap; } //实现通用的通过格式化字符串信息获取错误结果的msg方法 public String getErrMsg() { return StringUtils.join(errorMsgMap.values().toArray(), ","); } }
-
实现
ValidatorImpl
与bean
绑定,然后返回校验结果@Component public class ValidatorImpl implements InitializingBean{ private Validator validator; //实现校验方法并返回校验结果 public ValidationResult validate(Object bean){ final ValidationResult result = new ValidationResult(); Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(bean); if(constraintViolationSet.size() > 0){ //有错误 result.setHasErrors(true); constraintViolationSet.forEach(constraintViolation->{ String errMsg = constraintViolation.getMessage(); String propertyName = constraintViolation.getPropertyPath().toString(); result.getErrorMsgMap().put(propertyName,errMsg); }); } return result; } @Override public void afterPropertiesSet() throws Exception { //将hibernate validator通过工厂的初始化方式使其实例化 this.validator = Validation.buildDefaultValidatorFactory().getValidator(); } }
-
在
model
类添加注解@Data @AllArgsConstructor @NoArgsConstructor public class UserModel { private Integer id; @NotNull(message = "用户名不能为空") private String name; @NotNull(message = "性别不能为空") private Byte gender; @NotNull(message = "年龄不能为空") @Min(value = 0, message = "年龄必须大于0") @Max(value = 200, message = "年龄必须小于200") private Integer age; @NotNull(message = "手机号不能为空") private String telphone; private String registerMode; private String thirdPartyId; @NotNull(message = "密码不能为空") private String encrptPassword; }
-
在
service
层进行校验@Autowired private ValidatorImpl validator; //方法内添加即可 ValidationResult result = validator.validate(userModel); if(result.isHasErrors()){ throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,result.getErrMsg()); }
经过Debug
我发现运行报错的原因是不能在Model
类上使用@NotBlank
注解,改成@NotNull
就可以了。可是网上说应用在String
类型的属性是没问题的,无解…
实现Item相关类
- 修改
pom
文件和mybatis
的文件自动生成Pojo
、mapper
和mapper.xml
,手动创建model
和ViewObject
- 实现
Item
相关的controller
和service
类,其中包含创建商品、获取商品列表、根据ID
查询商品
小tip
:
-
为了前端展示,常常定义的
ViewObject
比Pojo
类的属性更多,可以看出之前的User
相关类中Model
是由Pojo
类聚合而成,而ViewObject
也可以用Model
类聚合而成。 -
在
service
层实现model
和pojo
类的转换,controller
实现ViewObject
和model
层的转换。
创建商品
- 实现
ItemController
类,由前端页面传来的参数封装成ItemModel
对象,用ItemVO
类返回给前端 - 实现
ItemServiceImpl
类- 入参校验
- 由
ItemModel
转换成ItemDO
- 插入到数据库
- 返回创建的对象
获取商品列表⭐️
这里运用流式编程
的方法通过pojo
类组装成model
然后将其转换成集合
// service
public List<ItemModel> listItem() {
List<ItemDO> itemDOList = itemDOMapper.listItem();
List<ItemModel> itemModelList = itemDOList.stream().map(itemDO -> {
ItemStockDO itemStockDO = itemStockDOMapper.selectByItemId(itemDO.getId());
ItemModel itemModel = this.convertModelFromPojo(itemDO,itemStockDO);
return itemModel;
}).collect(Collectors.toList());
return itemModelList;
}
// controller
@RequestMapping(value = "/list",method = {RequestMethod.GET})
@ResponseBody
public CommonReturnType listItem(){
List<ItemModel> itemModelList = itemService.listItem();
//使用stream api将list内的itemModel转化为ItemVO;
List<ItemVO> itemVOList = itemModelList.stream().map(itemModel -> {
ItemVO itemVO = this.convertFromItemModel(itemModel);
return itemVO;
}).collect(Collectors.toList());
return CommonReturnType.create(itemVOList);
}
商品列表前端页面
利用DOM
操作填充table
,将商品信息展示到页面
<script>
// 定义全局商品数组信息
var g_itemList = [];
jQuery(document).ready(function () {
$.ajax({
type:"GET",
url:"http://localhost:8080/item/list",
xhrFields: {withCredentials: true},
success:function (data) {
if (data.status == "success") {
// alert("获取商品信息成功");
g_itemList = data.data;
reloadDom();
}else {
alert("获取商品信息失败,原因:"+data.data.errMsg);
}
},
error:function (data) {
alert("获取商品信息失败,原因:"+data.responseText);
}
})
})
function reloadDom() {
for (var i = 0; i < g_itemList.length; i ++){
var itemVO = g_itemList[i];
console.log(itemVO.title)
var dom = "<tr data-id='"+ itemVO.id +"' id='itemDetail"+ itemVO.id +"'><td>"+ itemVO.title +"</td><td><img style='width: 100px;height: auto' src='"+ itemVO.imgUrl +"'></td><td>"+ itemVO.description +"</td><td>"+ itemVO.price +"</td><td>"+ itemVO.stock +"</td><td>"+ itemVO.sales +"</td></tr>";
$("#container").append($(dom));
$("#itemDetail"+itemVO.id).on("click",function (e) {
window.location.href="getitem.html?id="+$(this).data("id");
})
}
}
</script>
下订单
-
根据
mybatis-generator.xml
自动生成OrderDO
、OrderDOMapper
、OrderDOMapper.xml
-
实现
OrderModer
类(注意itemPrice
属性的定义) -
OrderServiceImpl
的实现- 入参校验
- 修改
item_stock
表该商品的stock
,注意还要判断该商品的库存是否够 - 封装
OrderModel
对象,订单流水号的生成如下所示 - 将封装好的
OrderModel
对象转换成OrderDO
对象,然后插入order_info
表 - 修改
item
表的该商品的sales
- 返回
OrderModel
对象给controller
@Transactional(propagation = Propagation.REQUIRES_NEW) private String generateOrderNo(){ //订单号有16位 StringBuilder stringBuilder = new StringBuilder(); //前8位为时间信息,年月日 LocalDateTime now = LocalDateTime.now(); String nowDate = now.format(DateTimeFormatter.ISO_DATE).replace("-",""); stringBuilder.append(nowDate); //中间6位为自增序列 //获取当前sequence,在这里需要给sequence_info的getSequenceByName语句加锁:for update int sequence = 0; SequenceDO sequenceDO = sequenceDOMapper.getSequenceByName("order_info"); sequence = sequenceDO.getCurrentValue(); sequenceDO.setCurrentValue(sequenceDO.getCurrentValue() + sequenceDO.getStep()); sequenceDOMapper.updateByPrimaryKeySelective(sequenceDO); String sequenceStr = String.valueOf(sequence); for(int i = 0; i < 6-sequenceStr.length();i++){ stringBuilder.append(0); } stringBuilder.append(sequenceStr); //最后2位为分库分表位,暂时写死 stringBuilder.append("00"); return stringBuilder.toString(); }
-
OrderController
的实现- 查看
session
的IS_LOGIN
属性是否存在,若不存在则抛异常 - 查看
session
的LOGIN_USER
属性,获得该用户的id
(用于入参校验和插入order_info
表),操作service
进行下单
- 查看
活动商品
-
根据
mybatis-generator.xml
自动生成PromoDO
、PromoDOMapper
、PromoDOMapper.xml
-
实现
PromoModel
类(增加了status
属性)@AllArgsConstructor @NoArgsConstructor @Data public class PromoModel { private Integer id; //秒杀活动状态 1表示还未开始,2表示进行中,3表示已结束 private Integer status; //秒杀活动名称 private String promoName; //秒杀活动的开始时间,DateTime属于joda-time类 private DateTime startDate; //秒杀活动的结束时间 private DateTime endDate; //秒杀活动的适用商品 private Integer itemId; //秒杀活动的商品价格 private BigDecimal promoItemPrice; }
-
在
OrderModel
类中加了PromoId
属性,在ItemModel
里组合了PromoModel
类 -
实现
PromoServiceImpl
类@Override public PromoModel getPromoByItemId(Integer itemId) { PromoDO promoDO = promoDOMapper.selectByItemId(itemId); PromoModel promoModel = convertFromPojo(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); return promoModel; }
-
在
OrderController
和OrderServiceImpl
类的相关方法中增加和Promo
相关的参数,用于修改下单的相关信息@Override @Transactional public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId, Integer amount) throws BusinessException { //入参校验 ItemModel itemModel = itemService.getItemById(itemId); if (itemModel == null) { throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品信息不存在"); } UserModel userModel = userService.getUserById(userId); if (userModel == null) { throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "用户信息不存在"); } if (amount <= 0 || amount > 99) { throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息不正确"); } if (promoId != null) { if (promoId.intValue() != itemModel.getPromoModel().getId()) throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "活动信息不正确"); else if (itemModel.getPromoModel().getStatus() != 2) throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息未开始"); } //下单方式:1.落单减库存 2.支付减库存:会造成某人已下完单,但是当付款成功的时候却没货 boolean flag = itemService.decreaseStock(itemId, amount); if (!flag) throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH); //订单入库 OrderModel orderModel = new OrderModel(); orderModel.setUserId(userId); orderModel.setItemId(itemId); orderModel.setAmount(amount); if (promoId != null) orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice()); else orderModel.setItemPrice(itemModel.getPrice()); orderModel.setPromoId(promoId); //这里不再是商品价格itemModel.getPrice()而是之前订单价格orderModel.getItemPrice() BigDecimal orderPrice = orderModel.getItemPrice().multiply(new BigDecimal(amount)); orderModel.setOrderPrice(orderPrice); orderModel.setId(generateOrderNo()); //返回前端 OrderDO orderDO = convertFromOrderModel(orderModel); //插入到order_info表 orderDOMapper.insertSelective(orderDO); //增加销量 itemService.increaseSales(itemId, amount); return orderModel; }
-
实现活动商品前端
略
部署到云端
- 在
pom
文件中加入springboot
的maven
插件<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin>
- 在本机的命令行执行
mvn clean package
后得到的jar
包上传到云服务器 - 新建外挂配置文件
application.properties
并以该配置运行server.port=80 # 注意端口冲突,查看端口的命令是 netstat -lnp|grep 端口号 [root@LEGION-Y7000 intellij]# java -jar miaosha-0.0.1-SNAPSHOT.jar --spring.config.addition-location=/www/intellij/miaosha/application.properties
- 编写外挂配置脚本
deploy.sh
nohup java -Xms400m -Xmx400m -XX:NewSize=200m -XX:MaxNewSize=200m -jar miaosha-0.0.1-SNAPSHOT.jar --spring.config.addition-location=/www/intellij/miaosha/application.properties # 参数说明 nohup:以非停止方式运行程序,这样即便控制台退出了程序也不会停止 java:java命令启动,设置jvm初始和最大内存为400m,设置jvm中初始新生代和最大新生代大小为200m,设置成一样的目的是为减少扩展jvm内存池过程中向操作系统索要内存分配的消耗 spring.config.addtion-location=指定额外的配置文件
- 都把文件改成可执行文件
chmod -R 777 *
- 执行编写的
shell
文件,将文件结果都打印到nohup.out
文件里[root@LEGION-Y7000 miaosha]# ./deploy.sh & [1] 29471 [root@LEGION-Y7000 miaosha]# nohup: ignoring input and appending output to ‘nohup.out’
性能压测
-
添加一个线程组,再将
Http请求
、察看结果树
、聚合报告
添加进去,高级栏中要选Java
-
查看服务器性能
[root@LEGION-Y7000 miaosha]# ps -ef|grep java
root 3195 12989 0 18:29 pts/1 00:00:00 grep --color=auto java
root 26473 1 0 17:06 ? 00:00:00 jsvc.exec -java-home /usr/java/jdk1.8.0_121 -user www -pidfile /www/server/tomcat/logs/catalina-daemon.pid -wait 10 -outfile /www/server/tomcat/logs/catalina-daemon.out -errfile &1 -classpath /www/server/tomcat/bin/bootstrap.jar:/www/server/tomcat/bin/commons-daemon.jar:/www/server/tomcat/bin/tomcat-juli.jar -Djava.util.logging.config.file=/www/server/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Dcatalina.base=/www/server/tomcat -Dcatalina.home=/www/server/tomcat -Djava.io.tmpdir=/www/server/tomcat/temp org.apache.catalina.startup.Bootstrap
www 26474 26473 0 17:06 ? 00:00:10 jsvc.exec -java-home /usr/java/jdk1.8.0_121 -user www -pidfile /www/server/tomcat/logs/catalina-daemon.pid -wait 10 -outfile /www/server/tomcat/logs/catalina-daemon.out -errfile &1 -classpath /www/server/tomcat/bin/bootstrap.jar:/www/server/tomcat/bin/commons-daemon.jar:/www/server/tomcat/bin/tomcat-juli.jar -Djava.util.logging.config.file=/www/server/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Dcatalina.base=/www/server/tomcat -Dcatalina.home=/www/server/tomcat -Djava.io.tmpdir=/www/server/tomcat/temp org.apache.catalina.startup.Bootstrap
root 29472 29471 0 17:33 pts/1 00:00:15 java -Xms400m -Xmx400m -XX:NewSize=200m -XX:MaxNewSize=200m -jar miaosha-0.0.1-SNAPSHOT.jar --spring.config.addition-location=/www/intellij/miaosha/application.properties
[root@LEGION-Y7000 miaosha]# netstat -anp | grep 29472
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 29472/java
tcp 0 0 172.20.77.40:80 39.149.232.113:10140 ESTABLISHED 29472/java
tcp 0 0 172.20.77.40:80 39.149.232.113:10139 ESTABLISHED 29472/java
tcp 0 0 172.20.77.40:47530 你的IP:3306 ESTABLISHED 29472/java
unix 2 [ ] STREAM CONNECTED 858555 29472/java
unix 2 [ ] STREAM CONNECTED 859732 29472/java
[root@LEGION-Y7000 miaosha]# pstree -p 29472 | wc -l
30
[root@LEGION-Y7000 miaosha]# top -H
几个很重要的命令:
# 查看服务器状态
top -H
# 当前进程的并发线程数
pstree -p 29472 | wc -l
# 查看端口连接
netstat -lnp | grep 3306
优化Tomcat配置⭐️
tomcat
的配置容量总是不高(默认是10个),解决方案:
- 通过修改配置文件调优
-
查看
SpringBoot
配置:在spring-configuration-metadata.json
文件下,查看各节点的配置- server.tomcat.accept-count:等待队列长度。默认100;
- server.tomcat.max-connections:最大可被连接数,默认10000
- server.tomcat.max-threads:最大工作线程数,默认200
- server.tomcat.min-spare-threads:最小线程数,默认10
- 默认配置下,连接超过10000后出现拒绝连接情况;
- 默认配置下,触发的请求超过200+100后拒绝处理;
- -Xmx3550m -最大可用内存
-Xms3550m -JVM促使内存为3550m
-Xmn2g 年轻代大小为2G
-Xss128k -设置每个线程的堆栈大小
-XX:NewRatio=4:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)设置为4,则年轻代与年老代所占比值为1:4
-XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4;
-XX:MaxPermSize=16m:设置持久代大小为16m
-XX:MaxTenuringThreshold=0:设置垃圾最大年龄
-
修改外挂配置文件
server.port=80
server.tomcat.accept-count=1000
server.tomcat.max-threads=800
server.tomcat.min-spare-threads=100
- 杀掉线程再重新启动
[root@LEGION-Y7000 miaosha]# kill -9 29472
[root@LEGION-Y7000 miaosha]# ./deploy.sh &
[1] 4392
[root@LEGION-Y7000 miaosha]# nohup: ignoring input and appending output to ‘nohup.out’
- 调优结果
[root@LEGION-Y7000 miaosha]# pstree -p 27464 | wc -l
120
- 通过内嵌
Tomcat
开发
-
配置项目开发
keepAliveTimeOut
:多少毫秒后不响应的断开keepalive
(设置在服务端上)maxKeepAliveRequests
:多少次请求后keepalive
断开失效- 使用
WebServerFactoryCustomizer< ConfigurableServletWebServerFactory >
:定制化内嵌tomcat
配置
-
代码实现
@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);
}
});
}
}
- 数据库查询耗时
![image-20210212113835237](https://gitee.com/noah2021/blogImage/raw/master/img/20210223003224.png)
分布式扩展
![image-20210213112601642](https://gitee.com/noah2021/blogImage/raw/master/img/20210223003234.png)
数据库开放远程端口连接
需要4台阿里云服务器来进行:1台nginx
反向代理、1台数据库、2台应用程序
-
最开始的一台做数据库服务器,把之前的
/www/intellij/miaosha
文件夹内的都传输到另外两台应用程序服务器上scp -r /www/intellij root@应用服务器一的IP(私):/www/ scp -r /www/intellij root@应用服务器二的IP(私):/www/
-
在另外两台的服务器上启动应用程序
# 连接 ssh root@应用服务器一的IP(私) # ...
-
修改两个应用服务器的配置文件
application.properties
server.port=80 server.tomcat.accept-count=1000 server.tomcat.max-threads=800 server.tomcat.min-spare-threads=100 # 地址改成私网地址更优 spring.datasource.url=jdbc:mysql://你的IP:3306/miaosha?useUnicode=true&characterEncoding=UTF-8
-
发现
telnet 私网IP 3306
不通,遂修改user
表的权限GRANT ALL PRIVILEGES ON *.* to root@'%' identified by 'root'; FLUSH PRIVILEGES;
TELNET 私网IP 3306
后的结果是[root@LEGION-Y7000 miaosha]# telnet 172.20.77.40 3306 Trying 172.20.77.40... Connected to 172.20.77.40. Escape character is '^]'. Y 5.5.5-10.1.44-MariaDBUO&jhIr%^-? *TIO5X<d`710mysql_native_passwordConnection closed by foreign host.
-
安装
JDK
后启动应用./deploy.sh &
# 到达JDKrpm包所在目录,打开rpm包执行权限 chmod -R 777 jdk.rpm # 安装rpm rpm -ivh jdk.rpm # 检查java版本 java -version # 启动应用 ./deploy.sh &
将静态资源上传到云端
Nginx
作用:
- 作为
web
服务器 - 作为动静分离服务器
- 作为反向代理服务器
![image-20210212232646783](https://gitee.com/noah2021/blogImage/raw/master/img/20210223003223.png)
OpenResty
概述:
OpenResty
由Nginx
核心加很多第三方模块组成,默认集成了Lua
开发环境,使得Nginx
可以作为一个Web Server
使用- 借助于
Nginx
的事件驱动模型和非阻塞IO
,可以实现高性能的Web
应用程序 OpenResty
提供了大量组件如Mysql
、Redis
、Memcached
等等,使在Nginx
上开发应用更方便,更简单
常用Nginx
命令:
cd /usr/local/nginx/sbin/
./nginx 启动
./nginx -s stop 停止
./nginx -s quit 安全退出
./nginx -s reload 重新加载配置文件
ps aux|grep nginx 查看nginx进程
步骤:
-
在
Nginx
上部署OpenResty
- 上传
openresty.tar.gz
包到服务器,接着执行下面的命令:
chmod -R 777 openresty.tar.gz tar -xvzf openresty.tar.gz cd openresty ./configure # 我的没报错,是这样的 cd ../.. Type the following commands to build and install: gmake gmake install # 报错的话,执行下面的命令 yum install pcre-devel openssl-devel gcc curl # 编译 make # 安装 make install # 安装完成 make[2]: Leaving directory `/www/openresty-1.17.8.2/build/nginx-1.17.8' make[1]: Leaving directory `/www/openresty-1.17.8.2/build/nginx-1.17.8' mkdir -p /usr/local/openresty/site/lualib /usr/local/openresty/site/pod /usr/local/openresty/site/manifest ln -sf /usr/local/openresty/nginx/sbin/nginx /usr/local/openresty/bin/openresty # 在/usr/local/openresty/nginx目录下启动Nginx sbin/nginx -c conf/nginx.conf
由于我是在数据库端部署的
Nginx
,所以把端口号改成81,在宝塔面板和阿里云的安全组里放行后就可以访问index.html
了。 - 上传
-
将前端的页面进行修改后(通过增加
gethost.js
文件来替换前端页面的地址),上传到/usr/local/openresty/nginx/html
,至此才算真正部署一个项目到云端。
-
修改
nginx.conf
的配置文件,然后将静态资源全转移到新建的resources
的目录中location /resources/{ alias /usr/local/openresty/nginx/html/resources/; autoindex on; root html; index index.html index.htm; autoindex_exact_size off; autoindex_localtime on; }
-
重启
Nginx
:sbin/nginx -s reload
Nginx做反向代理服务器⭐️
- 设置
upstream server
- 设置动态请求
location
为proxy pass
路径 - 开启
tomcat access log
访问日志验证
反向代理配置,配置一个backend_server
,可以用于指向后端不同的server
集群,配置内容为server
集群的局域网ip
,以及轮巡的权重值,并且配置个location
,当访问规则命中location
任何一个规则的时候则可以进入反向代理规则。
- 修改
nginx.conf
文件
#gzip on;
upstream backend_server{
server 应用服务器一私网:81 weight=1;
server 应用服务器二私网:81 weight=1;
}
location / {
proxy_pass http://backend_server;# 轮询上面的两个服务器
#proxy_set_header Host $http_host:$proxy_port;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
-
重启
Nginx
-
开启
tomcat
的accesslog
# 先在秒杀项目的目录里新建一个tomcat的文件夹并授权777
# 修改外挂配置文件
server.tomcat.accesslog.enabled=true
server.tomcat.accesslog.directory=/www/intellij/miaosha/tomcat
server.tomcat.accesslog.pattern=%h %l %u %t "%r" %s %b %D
# 保存文件、杀掉java进程并重新部署
# %h 访问的用户IP地址。
# %l 访问逻辑用户名,通常返回'-'。
# %u 访问验证用户名,通常返回'-'。
# %t 访问日期。
# %r 访问的方式(post或者是get),访问的资源和使用的http协议版本
# %s 访问返回的http状态码。
# %b 访问资源返回的流量
# %D 处理请求的时间,以毫秒为单位
- 开启
Nginx
和后端应用程序由短连接修改成KeepAlive
(长连接)模式(默认情况下客户端和Nginx
,客户端和应用程序、应用程序和数据库服务器是长连接,而Nginx
和后端应用程序是短连接)
upstream backend_server{
server 应用服务器一私网:81 weight=1;
server 应用服务器二私网:81 weight=1;
keepalive 30;
}
location / {
proxy_pass http://backend_server;# 轮询上面的两个服务器
#proxy_set_header Host $http_host:$proxy_port;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Nginx向应用服务器默认配置是http 1.0,不使用keepalive
- 重启
Nginx
并进行压测
Nginx高性能的原因⭐️
epoll
多路复用完成非阻塞式的IO
操作;master-worker
进程模型,允许其进行平滑重启和配置,不会断开和客户端连接,基于worker
的单线程模型和epoll
多路复用的机制完成高效的操作;- 协程机制,完成单进程单线程模型,并支持并发编程调用接口,将每个请求对应到线程的某一个协程中,结合
epoll
多路复用的机制完成同步调用的开发;
epoll
多路复用(解决IO
阻塞回调通知问题)
I/O多路复用就是通过一种机制,可以监视多个描述符,一旦某个IO
能够读写,通知程序进行相应的读写操作。
I/O多路复用的场合
当客户处理多个描述字时(通常是交互式输入和网络套接字),必须使用I/O
复用
如果一个TCP
服务器既要处理监听套接字,又要处理已连接套接字,一般也要用到I/O
复用
如果一个服务器即要处理TCP
,又要处理UDP
,一般要使用I/O
复用
Java BIO模型
client
和server
之间通过TCP/IP
建立联系,javaclient
只有等到所有字节流socket.write
到TCP/IP
的缓冲区之后,对应的java client
才会返回;若网络很慢,缓冲区填满之后,client
就必须等待信息传输过去缓冲器有空闲使得缓冲区可以给上游去写时,才可达到直接返回的效果;
Linux select模型
变更触发轮询查找,文件描述符有1024数量上限;一旦java server
被唤醒,并且对应的socket
连接打上有变化的标识之后,就代表已经有数据可以让你读写
弊端:
轮询效率低,有1024数量限制
epoll模型
变更触发轮询,变更触发回调直接读取,理论上无上限。epoll
是为了解决select
和poll
的轮询方式效率低问题;
假设一个场景:
有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大。因此,select/poll
一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同:
epoll通过在Linux
内核中申请一个简单的文件系统(文件系统一般由B+树实现)
把原先的select/poll
调用分成了3个部分:
调用epoll_create()
建立一个epoll
对象(在epoll
文件系统中为这个句柄对象分配资源);
调用epoll_ctl
向epoll
对象中添加这100万个连接的套接字;
调用epoll_wait
收集发生的事件的连接;
实现上面说是的场景,只需要在进程启动时建立一个epoll
对象,然后在需要的时候向这个epoll
对象中添加或者删除连接。同时,epoll_wait
的效率也非常高,因为调用epoll_wait
时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
master-worker
进程模型
Nginx
多进程模型如下所示:
管理员理解为root
操作用户,用于启动管理nginx
进程;信号理解为启动或者重启Nginx
,每个worker
进程都是单线程的
Master进程的主要功能:
- 接收来自外界的信号;
- 向各个
worker
进程发送信号; - 监控
worker
进程的运行状态; - 当
worker
进程在异常情况下退出后,会自动重启新的worker
进程;
nginx
会启动一个master
进程,然后根据配置文件内的worker
进程的数量去启动相应的数量的worker
进程,master
进程和worker
进程是一个父子关系;master
进程用来管理worker
进程,worker
进程才是用来管理客户端连接的。
Master
进程会先创建好对应的socke
去监听对应的短裤,然后再fork
出多个worker
进程,master
会启动一个epoll
的多路复用模型;当client
想要在socket
端口建立经典的TCP三次握手建立连接的时候,对应的epoll
多路复用会产生一个回调,通知所有的可以accept
的worker
进程,但只有一个worker
进程会成功,其它的都会失败。
Nginx提供了一把共享锁accept_mutex来保证同一时刻只有一个work
进程在accept
连接,从而解决集群问题;当一个worker
进程accept
这个连接后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接;
- 协程机制
一个线程可以有多个协程,协程是线程的内存模型
- 依附于线程的内存模型,切换开销小;
- 遇阻塞即归还执行权,代码同步,调用新的不阻塞的协程;
- 无需加锁;
分布式会话实现
之前我们的会话请求是依赖SpringBoot
内嵌的tomcat
容器封装的HttpServletRequest
类来实现的,但是当我们实现了分布式扩展后,由于Nginx
不断轮询不同的应用程序服务器端,只有当连续两次轮巡到同一台服务器才能进行一次完整的会话,这样无疑是不现实。于是我们只有靠Redis
来实现分布式会话。
- 基于
cookie
传输sessionid
:由SpringBoot
内嵌的tomcat
容器实现迁移到Redis
实现 - 基于
token
传输类似sessionid
:java
代码实现迁移到Redis
实现
- 引入分布式
session
相关的redis
依赖,引入不同的版本时,可能会引起原有jar包版本不兼容。我是SpringBoot
版本是2.2.2
<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>
- 在
properties
中配置redis
属性# redis spring.redis.host=127.0.0.1 spring.redis.port=6379 spring.redis.database=10 #spring.redis.password # 设置 Jedis 连接池:最大连接数量、最小idle连接 spring.redis.jedis.pool.max-active=50 spring.redis.jedis.pool.min-idle=20
- 自定义配置
redis
连接状态 - 启动项目进行登陆,此时的
session
已经存入redis
但是还未序列化,故登陆失败 - 序列化
Redis
/*有两种序列化的方式,一、使用默认的`JDK`序列化方式; 二、修改对应`Redis`序列化方式改成`json`方式*/ public class UserModel implements Serializable
- 在本地进行登陆操作检验
Redis
可运行,重新打包上传,把Redis
部署到和数据库服务器一起(不能部署到应用服务器端) - 修改两个应用服务器的配置文件(spring.redis.host=你的IP,spring.redis.password=你的密码,若没有请忽略),重新部署
- 在
UserController
引入RedisTemplate
,建立登陆凭证token
和用户登陆态之间的联系,要给UserModel
进行序列化//login String uuidToken = UUID.randomUUID().toString(); uuidToken = uuidToken.replace("-", ""); redisTemplate.opsForValue().set(uuidToken, userModel); redisTemplate.expire(uuidToken, 1, TimeUnit.HOURS);//设置超时时间 return CommonReturnType.create(uuidToken);
- 前端验证:修改前端代码
getitem.html
以及gethost.js
,用作本机调试<!--login.html--> var token = data.data; window.localStorage["token"]=token; <!--getitem.html--> var token = window.localStorage["token"]; if(token == null){ alert("没有登陆,不能下单"); window.location.href="login.html"; return false; } <!--将token作为参数通过url传回应用程序服务器--> url: "http://" + g_host + "/order/createorder?token="+token
- 后端重复验证:修改后端代码
OrderController
@Autowired RedisTemplate redisTemplate; //封装下单请求 @RequestMapping(value = "/createorder",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED}) @ResponseBody public CommonReturnType createOrder(@RequestParam(name="itemId")Integer itemId, @RequestParam(name="amount")Integer amount, @RequestParam(name="promoId",required = false)Integer promoId ) throws BusinessException { // Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN"); // if(isLogin == null || !isLogin.booleanValue()){ // throw new BusinessException(EmBusinessError.USER_NOT_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)//说明token已经失效 throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单"); // UserModel userModel = (UserModel)httpServletRequest.getSession().getAttribute("LOGIN_USER"); OrderModel orderModel = orderService.createOrder(userModel.getId(),itemId,promoId,amount); return CommonReturnType.create(null); }
多级缓存
Redis缓存&本地缓冲⭐️
- 单机版
sentinal
哨兵模式- 集群
cluster
模式
-
Redis Sentinel
集群看成是一个ZooKeeper
集群,它是集群高可用的心脏,它一般是由 3~5 个节点组成,这样挂了个别节点集群还可以正常运转。它负责持续监控主从节点的健康,当主节点挂掉时,自动选择一个最优的从节点切换为主节点。客户端来连接集群时,会首先连接sentinel
,通过sentinel
来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向sentinel
要地址,sentinel
会将最新的主节点地址告诉客户端。如此应用程序将无需重启即可自动完成节点切换。Redis1
崩了以后会更改主从节点的身份: -
集群
cluster
模式的特点:-
所有的
redis
节点彼此互联(PING-PONG
机制),内部使用二进制协议优化传输速度和带宽; -
节点的
fail
是通过集群中超过半数的节点检测失效时才生效; -
客户端与
redis
节点直连,不需要中间proxy
层,客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可; -
redis-cluster
把所有的物理节点映射到[0-16383]slot
上,cluster
负责维护node<->slot<->value
-
-
Redis集中式缓存:商品详情动态内容实现(上)
把Item
的数据存取改成在Redis
上,接下来设置序列化方式,对于key
可以直接序列化,对于Value
还需补充由JodaDateTime
到Json
字符串的转换@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; } }
-
本地热点数据缓存:商品详情动态内容实现(下)
为了减少访问redis
的网络开销和redis
的广播消息,本地热点缓存的生命周期不会特别长;本地热点缓存是为了一些热点数据瞬时访问的容量来做服务的,对应的生命周期要比rediskey
的生命周期要短很多;这样才能做到被动失效的时候对于脏读失效的控制是非常小的。Guava cache
本质上是一个HashMap
:可以控制key
和value
的大小,以及key
的超时时间;可配置的LRU
策略,最近最少访问的key
,当内存不足的时候优先被淘汰;线程安全; -
引入
Guava Cache
的依赖<!--Guava Cache--> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>18.0</version> </dependency>
- 配置本地缓存
@PostConstruct
该注解被用来修饰一个非静态的void()方法。被@PostConstruct
修饰的方法会在服务器加载Servlet
的时候运行,并且只会被服务器执行一次。PostConstruct
在构造函数之后执行,init()
方法之前执行。该注解的方法在整个Bean初始化中的执行顺序:Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
```java
@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. 实现多级缓存查询商品详情
```java
//商品详情页浏览
@RequestMapping("/get")
@ResponseBody
public CommonReturnType getItem(@RequestParam("id") Integer id) throws BusinessException {
// ItemModel itemModel = itemService.getItemById(id);
ItemModel itemModel = null;
//取本地缓存
itemModel = (ItemModel) cacheService.getFromCommonCache("item_" + id);
if(itemModel == null){
//从redis中取
itemModel = (ItemModel) redisTemplate.opsForValue().get("item_" + id);
if (itemModel == null) {
//从mysql中取
itemModel = itemService.getItemById(id);
redisTemplate.opsForValue().set("item_" + id, itemModel);
redisTemplate.expire("item_" + id, 10, TimeUnit.MINUTES);
}
cacheService.setCommonCache("item_"+id, itemModel);
}
// throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH, "该商品不存在");
ItemVO itemVO = convertFromItemModel(itemModel);
return CommonReturnType.create(itemVO);
}
```
Nginx proxy cache缓存⭐️
- 前提:
Nginx
反向代理前置 - 依靠文件系统存索引级的文件
- 依靠内存缓存文件地址
Nginx proxy cache
的配置
# 声明一个cache缓冲节点的内容
# 做一个二级目录,先将对应的url做一次hash,取最后一位做一个文件目录的索引;
# 在取一位做第二级目录的索引来完成对应的操作,文件内容分散到多个目录,减少寻址的消耗;
# 在nginx内存当中,开了100m大小的空间用来存储keys_zone中的所有的key
# 文件存取7天,文件系统最多存取10个G
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
location / {
proxy_pass http://backend_server;
proxy_cache tmp_cache;
proxy_cache_key $uri;
proxy_cache_valid 200 206 304 302 7d;# 只有后端返回的状态码是这些,对应的cache操作才会生效,缓存周期7天
#proxy_set_header Host $http_host:$proxy_port;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
- 重启
Nginx
- 进入应用服务器端查看之前新建的
tomcat
目录下的accesslog
,可以发现最近要查询的数据已经在Nginx
反向代理服务器阻断了根本到不了tomcat
的accesslog
[root@LEGION-Y7000 miaosha]# ls
application.properties deploy.sh miaosha-0.0.1-SNAPSHOT.jar nohup.out tomcat
[root@LEGION-Y7000 miaosha]# cd tomcat
[root@LEGION-Y7000 tomcat]# ls
access_log.2021-02-13.log access_log.2021-02-14.log access_log.2021-02-15.log
[root@LEGION-Y7000 tomcat]# tail -f access_log.2021-02-15.log
39.149.232.3 - - [15/Feb/2021:18:49:47 +0800] "GET /item/get?id=1 HTTP/1.1" 200 326 4
39.149.232.3 - - [15/Feb/2021:18:49:47 +0800] "GET /favicon.ico HTTP/1.1" 200 98 3
39.149.232.3 - - [15/Feb/2021:18:49:47 +0800] "GET /item/get?id=1 HTTP/1.1" 200 326 4
39.149.232.3 - - [15/Feb/2021:18:49:47 +0800] "GET /favicon.ico HTTP/1.1" 200 98 2
39.149.232.3 - - [15/Feb/2021:18:53:58 +0800] "GET /item/get?id=1 HTTP/1.1" 200 326 4
39.149.232.3 - - [15/Feb/2021:18:58:33 +0800] "GET /item/get?id=1 HTTP/1.1" 200 326 4
39.149.232.3 - - [15/Feb/2021:18:58:34 +0800] "GET /favicon.ico HTTP/1.1" 200 98 2
39.149.232.3 - - [15/Feb/2021:19:10:52 +0800] "GET /item/list HTTP/1.1" 200 1472 8
39.149.232.3 - - [15/Feb/2021:19:10:53 +0800] "GET /item/get?id=1 HTTP/1.1" 200 326 5
39.149.232.3 - - [15/Feb/2021:19:10:56 +0800] "GET /item/get?id=3 HTTP/1.1" 200 1638 6
- 进入新建的
tmp_cache
目录,查看Nginx proxy cache
缓存
[root@LEGION-Y7000 tmp_cache]# ls
0 8 d
[root@LEGION-Y7000 tmp_cache]# cd 8
[root@LEGION-Y7000 8]# ls
f6
[root@LEGION-Y7000 8]# cd f6
[root@LEGION-Y7000 f6]# ls
86e4d1b3ba4f1464e409c74be4ef6f68
[root@LEGION-Y7000 f6]# cat 86e4d1b3ba4f1464e409c74be4ef6f68
F3`ÿÿÿÿÿÿÿÿŒ*`ksr¯`+Access-Control-Request-Headers莺ÿX[Kl8w²
KEY: /item/get
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 15 Feb 2021 10:53:58 GMT
{"status":"success","data":{"id":1,"title":"Sony_XM2","price":1100,"stock":86,"description":"初级降噪","sales":13,"imgUrl":"https://img12.360buyimg.com/n7/jfs/t1/153308/37/12948/287783/5feda8ceEf68df9ea/fe428c62d634d809.jpg","promoModel":null,"promoStatus":0,"promoPrice":null,"promoId":null,"startDate":null}}
Nginx lua缓存
Lua
协程机制Nginx
协程机制Nginx lua
插载点Nginx lua
实战
-
Nginx
协程-
nginx
的每一个Worker
进程都是在epoll
或queue
这种事件模型之上,封装成协程 -
每一个请求都有一个协程进行处理
-
即使
Nginx lua
需要运行lua
,相对与C
有一定的开销,但依旧能保证高并发的能力
Nginx
协程机制-
Nginx
每个工作进程创建一个lua
虚拟机 -
工作进程内的所有协程共享同一个
vm
-
每一个外部请求都是由一个
lua
协程处理,之间数据隔离 -
lua
代码调用io
等异步接口时,协程被挂起,上下文数据保持不变 -
自动保存,不阻塞工作进程
-
io
异步操作完成后还原协程上下文,代码继续执行
Nginx lua
插载点init_by_lua
:系统启动时调用;init_worker_by_lua
:worker
进程启动时调用;set_by_lua
:nginx
变量用复杂lua return
rewrite_by_lua
:重写url
规则access_by_lua
:权限验证阶段content_by_lua
:内容输出结点
-
-
Nginx lua
实战[root@LEGION-Y7000 openresty]# mkdir lua # 在新建的init.lua内输入文本 [root@LEGION-Y7000 lua]# vim init.lua [root@LEGION-Y7000 lua]# cat init.lua ngx.log(ngx.ERR,"init lua success"); [root@LEGION-Y7000 lua]# cd ../ [root@LEGION-Y7000 openresty]# cd nginx/ [root@LEGION-Y7000 nginx]# vim conf/nginx.conf # 在http块内加入下面 init_by_lua_file ../lua/init.lua; # 重启 [root@LEGION-Y7000 nginx]# sbin/nginx -c conf/nginx.conf nginx: [error] [lua] init.lua:1: init lua success nginx: [emerg] bind() to 0.0.0.0:81 failed (98: Address already in use) nginx: [emerg] bind() to 0.0.0.0:81 failed (98: Address already in use) nginx: [emerg] bind() to 0.0.0.0:81 failed (98: Address already in use) nginx: [emerg] bind() to 0.0.0.0:81 failed (98: Address already in use) nginx: [emerg] bind() to 0.0.0.0:81 failed (98: Address already in use) nginx: [emerg] still could not bind()
OpenResty实战
OpenResty hello world
[root@LEGION-Y7000 lua]# vim helloworld.lua
[root@LEGION-Y7000 lua]# cat helloworld.lua
ngx.exec("/item/get?id=1");
# 在server块内加入
location /helloworld{
content_by_lua_file ../lua/helloworld.lua;
}
# 设置url为http://你的IP/helloworld即可访问/item/get?id=1中的内容
shared dic
共享内存字典
# 在/usr/local/openresty/nginx/conf/nginx.conf内加入
lua_shared_dict my_cache 128m
server { # 参照,无意义
# 在lua目录里编辑文本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)
# 修改nginx.conf
location /luaitem/get{
default_type "application/json";
content_by_lua_file ../lua/itemsharedic.lua;
}
# 重启Nginx
# 关掉Nginx proxy cache(保留proxy_pass)然后访问http://你的IP/luaitem/get?id=3即可获得对应json数据
OpenResty redis
(推荐)
若nginx
可以连接到redis
上,进行只读不写,若redis
内没有对应的数据,那就回源到应用程序服务器上面,然后对应的应用程序服务器也判断一下redis
内有没有对应的数据,若没有,回源mysql
读取,读取之后放入redis
中 ,那下次h5
对应的ajax
请求就可以直接在redis
上做一个读的操作,nginx
不用管数据的更新机制,下游服务器可以填充redis
,nginx
只需要实时的感知redis
内数据的变化,在对redis
添加一个redis slave
,redis slave
通过redis master
做一个主从同步,更新对应的脏数据。
# 新建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("你的Redis服务器IP",6379)
cache:auth(XXXXXX) # redis的认证密码
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)
# 修改conf/nginx.conf
location /luaitem/get{
default_type "application/json";
content_by_lua_file ../lua/itemredis.lua;
}
# 重启Nginx,然后访问http://你的IP/luaitem/get?id=3即可获得对应json数据
页面静态化
静态请求CDN
DNS
用CNAME
解析到源站- 回源缓存设置
- 强推失效
用户将静态数据请求到ECS
服务器,ECS
服务器解析到阿里云的CDN
中,CDN
可以理解为一个无限大的内容磁盘缓存,本身没有文件存储的,当用户要访问getItem
一个静态资源文件的时候,只需要根据路由规则查看本地是否有这样的文件,有就直接返回,没有就回源到原站;回源到上图中的OSS
中去获取静态资源文件。如果取得了getItem
的html
静态资源文件,CDN
就可以一边返回对应的文件,一边把文件按照http
指示的生命周期缓存起来,以便于下一次用户在访问时,不用在回源到OSS
中,直接返回即可。
回源缓存设置
cache control响应头
cache control是服务端用来告诉客户端说,我这个http的response你可不可以缓存,以什么样的策略去缓存;
- private:客户端可以缓存/默认设置;
- public:客户端和代理服务器都可以缓存;
客户端往服务端发送http请求,中间可能会经过ngixn反向代理,也可能会经过正向代理的出口服务器,也可能会经过CDN网络。因此中间层的节点看到对应的cache control是private的时候认定只有请求发起的客户端/浏览器才可以进行缓存; - max-age = xxx:缓存的内容将在xxx秒后失效;
- no-cache:强制向服务端再验证一次;
会将对象的缓存存储在客户端,但是下一次用的时候需要向服务端验证一次这个缓存还能不能用,再去决定是否要去用之前用过的缓存; - no-store:不缓存请求的任何返回内容;
有效性判断
再验证一次,就是对缓存的有效性判断;
- ETag:资源唯一标识
一般是将请求的资源内容做一个MD5处理,在第一次返回的内容中加上Etag标识一起返回给浏览器,浏览器存储下对应的Etag,下一次缓存时,所谓的有效性判断就是将之前的Etag一起带到服务器中,用来验证不发送对应的响应而是发送对应的http请求并且带上Etag的值,服务端会将Etag的值和本地文件的Etag内容做比较,若一致,就返回一个304 not modify,告诉其服务端内容有效; - If-None-Match:客户端发送的匹配Etag标识符;
- Last-modified:资源最后被修改的时间;
- If-Modified-Since:客户端发送的匹配资源最后修改时间的标识符;
若这个时间早于Last-modified,说明资源是无效的,反之即有效
浏览器的三种刷新方式
- 回车刷新或a连接:看cache-control对应的max-age是否仍然有效,有效则直接from cache,若cache-control中位no-cache,则进入缓存协商逻辑;
- F5刷新或者command+R刷新:去掉cache-control中的max-age或者直接设置max-age为0,然后进入缓存协商逻辑;
- 强制刷新ctril+F5或者command+shift+R刷新:去掉cache-control和协商头,强制刷新;
- 协商机制,比较Last-modified和Etag到服务端,若服务端判断没变化则304不返回数据,否则200返回数据;
CDN自定义缓存策略
- 可自定义目录过期时间;
- 可自定义后缀名过期时间;
- 可自定义对应权重;
- 可通过界面或API强制cdn对应目录刷新(非保成功);
阿里云CDN缓存策略,这篇文章讲了CDN的自定义缓存策略,可以看一下细节;
静态资源部署策略
- css,js,img等元素使用带版本号部署,例如a.js?v=1.0不便利,且维护困难
html一般采取强推的概念,对应的html文件可以设置max-age,更新的时候,强推掉,让所有 CDN都失效调,全部回源。但对应的max-age设置为较短的时间; - css,js,img等元素使用带摘要部署:例如a.js?v=45edw存在先部署html还是先部署资源的覆盖问题;
- css,js,imh等元素使用摘要做文件名部署,例如45edw.js,新老版本并存,且可回滚,资源部署完成后再部署html;
对应部署策略
- 对应静态资源保持生命周期内不会变,max-age可设置的很长,无视失效更新周期;
- html文件设置no-cache或较短max age,以便于更新;
- html文件仍然可以设置较长的max age,依靠动态的获取版本号请求发送到后端,异步下载最新的版本号的html后展示渲染在前端;
- 动态请求也可以静态化成json资源推送到cdn上;
- 依靠异步请求获取后端节点对应资源状态做紧急下架处理;
- 可通过跑批仅仅推送cdn内容使其下架等操作;
全页面静态化
- html css js静态资源cdn化
- js ajax动态请求cdn化
- 全页面静态化
- 在服务端完成html,cdd,甚至js的load渲染成纯html文件后直接以静态资源的方式部署到CDN上。
phantomjs
- 无头浏览器,可以借助其模拟webkit js的执行;
应用: - 修改需要全页面静态化的实现,采用initView和hasInit方式防止多次初始化;
- 编写对应轮询生成内容方式;
- 将全静态化页面生成后推送到cdn;
缓存库存
交易性能瓶颈
JMeter
压测- 交易验证依赖数据库
- 库存行锁(减库存均是串行进行的)
- 后置处理逻辑
交易验证优化⭐️
- 用户风控策略优化:策略缓存模型化
- 活动校验策略优化:引入活动发布流程,模型缓存化,紧急下线能力
- 存在风险:
Redis
和MySQL
内数据不一致
-
下单时
ItemModel
、UserModel
模型缓存化:实现ItemServiceImpl
的getItemByIdInCache
方法和UserServiceImpl
的getUserByIdInCache
方法并在OrderServiceImpl
中使用 -
扣减库存缓存化:刚开始由于
decreaseStock
的SQL
语句中itemId
不是唯一索引,所以锁住的整个表,但是下单的时候并不只是一个商品,我们之前压测的却只是同一件商品这并不符合实际。所以加上唯一索引就会给这条SQL
语句加上一个行锁执行我们对应的操作优化了性能,由原来的整张表串行减库存变成itemId
对应的商品串行减库存但这也是一个性能瓶颈,解决方案是:活动发布同步库存进缓存,然后下单交易只需减Redis
缓存库存,接着异步消息扣减MySQL
数据库内库存<update id="decreaseStock"> <!-- WARNING - @mbg.generated This element is automatically generated by MyBatis Generator, do not modify. This element was generated on Mon Feb 08 21:40:03 CST 2021. --> update item_stock set stock = stock - #{amount, jdbcType=INTEGER} where item_id = #{itemId, jdbcType=INTEGER} and stock >= #{amount, jdbcType=INTEGER} </update>
-
运营发现活动有异常,在后台将对应的活动进行修改,比如将活动提前结束。若线上在redis的缓存没有正常过期,即便修改了活动时间,但是用户还是可以以活动秒杀价格交易,因此需要一个紧急下线能力。所以运营人员至少要在活动开始前半个小时将活动发布上去,半个小时内足够进行缓存的预热。然后设计一个紧急下线的接口(老师食言了,😓!!!),用代码实现可以清除redis内的缓存。当redis内无法查询状态,就会去数据库内查询活动状态,从而达到紧急下架的能力
库存行锁优化
itemId
需要创建唯一索引
alter table item_stock add unique index item_id_index(item_id)
- 扣减库存缓存化
-
活动发布同步库存进缓存
-
下单交易减缓存库存
-
问题:数据库记录不一致,缓存中修改了但是数据库中的数据没有进行修改;
- 异步同步数据库
-
活动发布同步库存进缓存
-
下单交易减缓存库存
-
异步消息扣减数据库内库存
可以让C端用户完成购买商品的高效体验,又能保证数据库最终的一致性
分布式事务
分布式设计CAP三方面:一致性、可用性、分区容忍性。
分区容忍性是必要的,要么选择强一致性,等待所有的数据都一致的时候才可用;要么就是牺牲强一致性变得可用。所以牺牲强一致性来实现CAP中的A和P(可用性和分区容忍性)。强一致性是重要的,但是不追求瞬时状态的强一致性,追求的是最终的一致性,达到基础可用、最终一致性、软状态;
软状态:在应用当中会瞬时的存在有数据不一致性的情况,比如一部分数据已经成功,另外一部分数据还在处理当中。那我们的业务认为这些是可以容忍的;
在我们的缓存库存中,redis中存储的状态都是正确的,但是由于异步消息队列的consumer没有被触发,在那一瞬时数据库的状态是错误的。但只要分布式事务的消息投递成功,数据库的状态就会被正确更新,这个设计就是用来处理库存最终一致性的方案。只要消息中间件有99%以上的高可用的方式,就有99%以上的概率是可以保证数据库的状态可以跟redis中的状态是一致的。
RocketMQ⭐️
- 安装
RocketMQ
并初始化
[root@LEGION-Y7000 www]# mkdir rocketmq
[root@LEGION-Y7000 www]# cd rocketmq/
[root@LEGION-Y7000 rocketmq]# wget https://mirrors.bfsu.edu.cn/apache/rocketmq/4.8.0/rocketmq-all-4.8.0-bin-release.zip
--2021-02-16 23:25:39-- https://mirrors.bfsu.edu.cn/apache/rocketmq/4.8.0/rocketmq-all-4.8.0-bin-release.zip
Resolving mirrors.bfsu.edu.cn (mirrors.bfsu.edu.cn)... 39.155.141.16, 2001:da8:20f:4435:4adf:37ff:fe55:2840
Connecting to mirrors.bfsu.edu.cn (mirrors.bfsu.edu.cn)|39.155.141.16|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 13881969 (13M) [application/zip]
Saving to: ‘rocketmq-all-4.8.0-bin-release.zip’
100%[============================================================================>] 13,881,969 --.-K/s in 0.1s
2021-02-16 23:25:39 (92.7 MB/s) - ‘rocketmq-all-4.8.0-bin-release.zip’ saved [13881969/13881969]
[root@LEGION-Y7000 rocketmq]# chmod -R 777 *
[root@LEGION-Y7000 rocketmq]# unzip rocketmq-all-4.8.0-bin-release.zip # 解压缩
[root@LEGION-Y7000 rocketmq]# ls
rocketmq-all-4.8.0-bin-release rocketmq-all-4.8.0-bin-release.zip
[root@LEGION-Y7000 rocketmq]# cd rocketmq-all-4.8.0-bin-release
[root@LEGION-Y7000 rocketmq-all-4.8.0-bin-release]# ls
benchmark bin conf lib LICENSE NOTICE README.md
Start Name Server
> nohup ./bin/mqnamesrv -n 你的IP:9876 &
> tail -f ~/logs/rocketmqlogs/namesrv.log # `~`代表的路径是`/root`
The Name Server boot success...
Start Broker
# 先打开安全组和防火墙的9876、10909、10911和10912端口,再修改runbroker.cmd内JAVA_OPT都改成512m后进行以下操作
# 在conf/broker.conf中加入下面配置
flushDiskType = ASYNC_FLUSH # 参照
namesrvAddr = 你的IP:9876
brokerIP1 = 你的IP
> nohup sh bin/mqbroker -n你的IP:9876 -c conf/broker.conf autoCreateTopicEnable=true &
> tail -f ~/logs/rocketmqlogs/broker.log
The broker[%s, 172.30.30.233:10911] boot success...
Send & Receive Messages
Before sending/receiving messages, we need to tell clients the location of name servers. RocketMQ provides multiple ways to achieve this. For simplicity, we use environment variable NAMESRV_ADDR
> export NAMESRV_ADDR=localhost:9876
> sh bin/tools.sh org.apache.rocketmq.example.quickstart.Producer
SendResult [sendStatus=SEND_OK, msgId= ...
> sh bin/tools.sh org.apache.rocketmq.example.quickstart.Consumer
ConsumeMessageThread_%d Receive New Messages: [MessageExt...
Shutdown Servers
> sh bin/mqshutdown broker
The mqbroker(36695) is running...
Send shutdown request to mqbroker(36695) OK
> sh bin/mqshutdown namesrv
The mqnamesrv(36664) is running...
Send shutdown request to mqnamesrv(36664) OK
- 指定
topic
为stock
[root@LEGION-Y7000 rocketmq-all-4.8.0-bin-release]# cd bin
[root@LEGION-Y7000 bin]# ./mqadmin updateTopic -n localhost:9876 -t stock -c DefaultCluster
# 报错
[root@LEGION-Y7000 bin]# vim tools.sh
# 修改 JAVA_OPT="${JAVA_OPT} -Djava.ext.dirs=${BASE_DIR}/lib:${JAVA_HOME}/jre/lib/ext:/usr/java/jdk1.8.0_121/jre/lib/ext"
[root@LEGION-Y7000 bin]# ./mqadmin updateTopic -n localhost:9876 -t stock -c DefaultCluster
RocketMQLog:WARN No appenders could be found for logger (io.netty.util.internal.PlatformDependent0).
RocketMQLog:WARN Please initialize the logger system properly.
create topic to 172.17.0.1:10911 success.
TopicConfig [topicName=stock, readQueueNums=8, writeQueueNums=8, perm=RW-, topicFilterType=SINGLE_TAG, topicSysFlag=0, order=false]
- 代码实现
/*连接mq*/
# mq
mq.nameserver.addr=你的IP:9876
mq.topicname=stock
/*引入依赖*/
<!--RocketMQ-->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.3.0</version>
</dependency>
/*实现MqProducer*/
@Component
public class MqProducer {
private DefaultMQProducer producer;
@Value("${mq.nameserver.addr}")
private String namesrvAddr;
@Value("${mq.topicname}")
private String topicName;
@PostConstruct
public void init() throws MQClientException {
producer = new DefaultMQProducer("producer_group");
producer.setNamesrvAddr(namesrvAddr);
producer.start();
}
//异步库存扣减消息
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;
}
}
/*实现MqConsumer*/
@Component
public class MqConsumer {
private DefaultMQPushConsumer consumer;
@Value("${mq.nameserver.addr}")
private String nameAddr;
@Value("${mq.topicname}")
private String topicName;
@Autowired
private ItemStockDOMapper itemStockDOMapper;
@PostConstruct
public void init() throws MQClientException {
consumer = new DefaultMQPushConsumer("stock_consumer_group");
consumer.setNamesrvAddr(nameAddr);
consumer.subscribe(topicName, "*");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
//实现库存真正到数据库内扣减的逻辑
Message msg = msgs.get(0);
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");
itemStockDOMapper.decreaseStock(itemId, amount);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
}
/*更新ItemServiceImpl*/
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) {
// int affectedRow = itemStockDOMapper.decreaseStock(itemId, amount);
Long row = redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue() * -1);
if (row >= 0) { // > 变 >=
//更新库存成功
boolean mqResult = producer.asyncReduceStock(itemId, amount);
if (!mqResult) {
redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
return false;
}
return true;
} else {
//更新库存失败,比如库存由0->-1,要更改回去
redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
return false;
}
}
<!--个人感觉下面老师补充的俩方法毫无意义...-->
ItemService.java
新建一个方法
//异步更新库存
boolean asyncDecreaseStock(Integer itemId,Integer amount);
//库存回补
boolean increaseStock(Integer itemId,Integer amount)throws BusinessException;
ItemServiceImpl.java
@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;
}
- 调试程序
- 根据
MySQL
里的promo
表查找匹配的item_id
和id
- 通过url:
http://localhost:8080/item/publishpromo?id=1
将promo_item_stock_x
存入Redis
- 点进对应的商品详情页下单并
Debug
- 根据
- 存在问题
- 异步消息发送失败
- 扣减操作执行失败
- 下单失败无法正确回补库存
事务性消息
改进⭐️
之前在OrderServiceImpl
的createOrder
方法中减库存存在问题:当出现减库存成功但是订单入库失败的情况会导致Redis
虽然
回滚了但是MQ
却无法取消消息,结果MySQL
中库存会比Redis
中少,造成少卖的情况。(MySQL
中库存比真实库存Redis
的少)
于是改进了方法:之前减库存分为两部分(Redis
中减库存,发送MQ
给MySQL
保证数据一致性),现在将发送MQ
的那部分放到createOrder
方法末尾。Spring
的@Transactional
只有在方法成功返回之后才会commit
,倘若因为网络问题或磁盘满了导致commit
失败,还是会白白扣掉库存。在前面的数据Commit
之后再执行afterCommit
方法,与此同时,抛异常的行为自然没有意义所以注掉
/*OrderServiceImpl*/
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
//异步更新库存
boolean mqResult = itemService.asyncDecreaseStock(itemId, amount);
// if (!mqResult) {
// itemService.increaseStock(itemId, amount);
// throw new BusinessException(EmBusinessError.MQ_SEND_FAIL);
// }
}
});
/*ItemServiceImpl*/
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) {
// int affectedRow = itemStockDOMapper.decreaseStock(itemId, amount);
Long row = redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue() * -1);
if (row >= 0) { // > 变 >=
//更新库存成功
// boolean mqResult = producer.asyncReduceStock(itemId, amount);
// if (!mqResult) {
// redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
// return false;
// }
return true;
} else {
//更新库存失败,比如库存由0->-1,要更改回去
increaseStock(itemId, amount);
return false;
}
}
现在只有一个问题了,如何保证MQ
发送必定成功?这就需要用到事务性消息:保证数据库的事务提交,只要事务提交了就一定会保证消息发送成功。数据库内事务回滚了,消息必定不发送,事务提交未知,消息也处于一个等待的状态
<!--MqProducer-->
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);
} 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;
}
//当executeLocalTransaction没返回明确的LocalTransactionState时就轮到checkLocalTransaction方法了
@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){
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;
}
}
此时,创建订单的任务已经完全被MqProducer
接管了,所以OrderController
就把createOrder
方法修改成
if (!mqProducer.transactionAsyncReduceStock(userModel.getId(), itemId, promoId, amount))
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下单失败");
库存流水
为了根据checkLocalTransaction
确定消息的状态,需要引入操作流水(操作型数据:log data
)
- 创建
stock_log
表,根据mybatis-generator
新建表相关文件
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
- 先完成库存流水init状态,然后通过事务型消息下单
/*ItemServiceImpl*/
@Override
@Transactional
public void initStockLog(Integer itemId, Integer amount) {
StockLogDO stockLogDO = new StockLogDO();
stockLogDO.setItemId(itemId);
stockLogDO.setAmount(amount);
stockLogDO.setStockLogId(UUID.randomUUID().toString().replace("-",""));
stockLogDO.setStatus(1);//1初始未知,2成功,3失败回滚
stockLogDOMapper.insertSelective(stockLogDO);
}
/*OrderController*/
itemService.initStockLog(itemId, amount);
- 将
stockLogId
放入create
方法内并修改相关代码,设置库存流水状态为成功
<!--OrderServiceImpl-->
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null)
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
stockLogDO.setStatus(2);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
- 实现
MqProducer
的checkLocalTransaction
方法
@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;
}
- 补充
MqProducer
的executeLocalTransaction
方法
@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;
}
问题本质:
没有库存操作流水:
对于操作型数据:log data,意义是库存扣减的操作记录下来,便于追踪库存操作流水具体的状态;根据这个状态去做对应的回滚,或者查询对应的状态,使很多异步型的操作可以在操作型数据上,例如编译人员在后台创建的一些配置。
主业务数据:master data,ItemModel就是主业务数据,记录了对应商品的主数据;ItemStock对应的库存也是主业务数据;
库存数据库最终一致性保证
方案:
引入库存操作流水,能够做到redis和数据库之间最终的一致性;
引入事务性消息机制;
带来的问题是:
redis不可用时如何处理;
扣减流水错误如何处理;
业务场景决定高可用技术实现
设计原则:
宁可少卖,不可超卖;
方案:
redis可以比实际数据库中少;
超时释放;
库存售罄
-
库存售罄标识;
-
售罄后不去操作后续流程;
-
售罄后通知各系统售罄;
-
回补上新
/*OrderServiceImpl*/
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) {
// int affectedRow = itemStockDOMapper.decreaseStock(itemId, amount);
Long row = redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue() * -1);
if (row > 0) { // > 变 >= 再分为<0 或者 =0
//更新库存成功
// boolean mqResult = producer.asyncReduceStock(itemId, amount);
// if (!mqResult) {
// redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
// return false;
// }
return true;
} else if(row == 0){
//打上库存售罄的标识别
redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId, "true");
return true;
}else {
//更新库存失败,比如库存由0->-1,要更改回去
increaseStock(itemId, amount);
return false;
}
}
/*OrderController*/
//若库存不足直接返回下单失败
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
后置流程
销量逻辑异步化
交易单逻辑异步化
流量削峰
秒杀令牌
-
原理
-
秒杀接口需要依靠令牌才能进入,对应的秒杀下单接口需要新增一个入参,表示对应前端用户获得传入的一个令牌,只有令牌处于合法之后,才能进入对应的秒杀下单的逻辑
-
秒杀令牌由秒杀活动模块负责生成,交易系统仅仅验证令牌的可靠性,以此来判断对应的秒杀接口是否可以被这次
http
的request
进入 -
秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
-
秒杀下单前需要获得秒杀令牌才能开始秒杀
-
-
后端代码实现
/*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 = convertFromPojo(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;
}
/*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);
}
/*注释掉`ItemServiceImpl`内已经验证过的代码*/
/*OrderController*/
//校验秒杀令牌是否正确
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 (!StringUtils.equals(promoToken,inRedisPromoToken))
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
}
- 前端代码实现
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"http://"+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:"http://"+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);
}
});
秒杀大闸⭐️
为了解决秒杀令牌在活动一开始无限制生成,影响系统的性能,提出了秒杀大闸的解决方案;
- 原理
依靠秒杀令牌的授权原理定制化发牌逻辑,解决用户对应流量问题,做到大闸功能;
根据秒杀商品初始化库存颁发对应数量令牌,控制大闸流量;
用户风控策略前置到秒杀令牌发放中;
库存售罄判断前置到秒杀令牌发放中。 - 代码实现
/*PromoServiceImpl*/
//将大闸限制的数字设到redis内
//publishPromo
redisTemplate.opsForValue().set("promo_door_count_"+promoId, itemModel.getStock().intValue()*5);
//获取秒杀大闸的count数量
//generateSecondKillToken
long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
if(result < 0){
return null;
}
- 缺陷
浪涌流量涌入后系统无法应对
多库存多商品等令牌限制能力弱 - 队列泄洪原理
排队有些时候比并发更高效(例如redis单线程模型,innodb mutex key等);
依靠排队去限制并发流量;
依靠排队和下游阻塞窗口程度调整队列释放流量大小;
以支付宝银行网关队列为例,支付宝需要对接许多银行网关,当你的支付宝绑定多张银行卡,那么支付宝对于这些银行都有不同的支付渠道。在大促活动时,支付宝的网关会有上亿级别的流量,银行的网关扛不住,支付宝就会将支付请求队列放到自己的消息队中,依靠银行网关承诺可以处理的TPS流量去泄洪;
消息队列就像“水库”一样,拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的 - 队列泄洪实现
/*OrderController*/
private ExecutorService executorService;
@PostConstruct
public void init() {
executorService = Executors.newFixedThreadPool(20);
}
//createOrder
//同步调用线程池的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中
比如说我们有100台机器,假设每台机器设置20个队列,那我们的拥塞窗口就是2000,但是由于负载均衡的关系,很难保证每台机器都能够平均收到对应的createOrder的请求,那如果将这2000个排队请求放入redis中,每次让redis去实现以及去获取对应拥塞窗口设置的大小,这种就是分布式队列;
本地和分布式有利有弊:
分布式队列最严重的就是性能问题,发送任何一次请求都会引起call网络的消耗,并且要对Redis产生对应的负载,Redis本身也是集中式的,虽然有扩展的余地。单点问题就是若Redis挂了,整个队列机制就失效了。
本地队列的好处就是完全维护在内存当中的,因此其对应的没有网络请求的消耗,只要JVM不挂,应用是存活的,那本地队列的功能就不会失效。因此企业级开发应用还是推荐使用本地队列,本地队列的性能以及高可用性对应的应用性和广泛性。当然我们也有对应的负载均衡的能力。
其实没有办法当我们每个本地对应服务器都能完全均匀地接受createOrder
这个请求,他有负载不均衡的问题。但是在高瓶颈高可用性的情况下,这些问题是可以被接受的。我们可以使用外部的分布式集中队列,当外部集中队列不可用时或者返回请求时间超时拉到不能接受的状态时,可以采用降级的策略,切回本地的内存队列。
防刷限流
验证码
- 验证码生成于验证技术
- 限流原理与实现
- 防黄牛技术
- 后端代码实现
/*OrderController.java*/
//生成验证码
@RequestMapping(value = "/generateverifycode",method = {RequestMethod.GET,RequestMethod.POST})
@ResponseBody
public void generateverifycode(HttpServletResponse response) throws BusinessException, IOException {
//根据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, "用户还未登陆,不能生成验证码");
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());
}
//generateToken
//通过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,"请求非法,验证码错误");
}
- 前端代码实现
<div id="verifyDiv" style="display: none" class="form-actions" >
<img src=""/>
<input id="verifyContent" type="text" value=""/>
<button class="btn blue" id="verifyButton" type="submit">
验证
</button>
</div>
$("#verifyButton").on("click",function () {
var token = window.localStorage["token"];
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"http://"+g_host+"/order/generatetoken?token="+token,
data:{
"itemId":g_itemVO.id,
"promoId":g_itemVO.promoId,
"verifyCode":$("#verifyContent").val()
},
xhrFields:{withCredentials:true},
success:function(data){
if(data.status == "success"){
var promoToken = data.data;
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"http://"+g_host+"/order/createorder?token="+token,
data:{
"itemId":g_itemVO.id,
"amount":1,
"promoId":g_itemVO.promoId,
"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);
}
});
});
$("#createorder").on("click", function () {
var token = window.localStorage["token"];
if(token == null){
alert("没有登陆,不能下单");
window.location.href="login.html";
return false;
}
$("#verifyDiv img").attr("src","http://"+g_host+"/order/generateverifycode?token="+token);
$("#verifyDiv").show();
});
限流技术
- 限流目的
- 流量远比你想象的要多
- 系统活着比挂了要好
- 宁愿只让少数人能用,也不要让所有人不能用
- 限流方案
- 限制并发:在
controller
入口设置一个计数器(假定计数器初始大小是一),在入口时减一,在出口时加一 - 行业常用的解决方案是限制
TPS
和QPS
- 令牌桶算法:限制每一秒流量的最大值以应对突发的流量,但不能超过限定值
- 漏桶算法:平滑网络流量,以固定的速率流入对应的操作
- 代码实现
<!--OrderController-->
private RateLimiter orderCreateRateLimiter;
@PostConstruct
public void init() {
executorService = Executors.newFixedThreadPool(20);
orderCreateRateLimiter = RateLimiter.create(300);
}
//createOrder
if(!orderCreateRateLimiter.tryAcquire()){
throw new BusinessException(EmBusinessError.RATE_LIMIT);
}
- 限流力度
- 接口维度
- 总维度
- 限流范围
- 集群限流:依赖
Redis
或其他的中间件技术做统一计数器,往往会产生性能瓶颈 - 单机限流:负载均衡的前提下单机平均限流效果更好
- 传统防刷
- 限制一个会话(
session_id
,token
)同一秒钟/分钟接口调用多少次:多会话接入绕开无效 - 限制一个
ip
同一秒钟/分钟 接口调用多少次:数量不好控制,容易误伤
防黄牛技术
- 黄牛为什么难防
- 模拟器作弊:模拟硬件设备,可修改设备信息
- 设备牧场作弊:工作室里有一批移动设备
- 人工作弊:靠佣金吸引兼职人员刷单
- 设备指纹
- 采集终端设备各项参数,启动应用时生成唯一设备指纹
- 根据对应设备指纹的参数猜测出模拟器等可疑设备概率
- 凭证系统
- 根据设备指纹下发凭证
- 关键业务链路上带上凭证并由业务系统到凭证服务器上验证
- 凭证服务器根据对应凭证所等价的设备指纹参数并根据实时行为风控系统判定对应凭证的可疑度分数
- 若分数低于某个数值则由业务系统返回固定错误码,拉起前端验证码验身,验身成功后加入凭证服务器对应分数