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
这里我是通过宝塔面板安装的,服务端选择的是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.conf
NETWORK
块里面将访问权限更改为所有人也就是修改为BINK 0.0.0.0
GENERAL
块的daemonize
修改为yes
INCLUDES
里面添加了:/requirepass
,我修改后反而启动报错,删除了才好,对此修改持保留态度SECURITY
块里面添加requirepass + 密码
用于多加一层验证,保护数据库安全。当客户端想要访问数据时,需要进行权限认证AUTH + 密码
创建Redis
服务
Redis
目录的utils
目录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
文件里引入自动生成的插件依赖
<dependency>
<groupId>org.mybatis.generatorgroupId>
<artifactId>mybatis-generator-maven-pluginartifactId>
<version>1.3.5version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>5.1.41version>
<scope>runtimescope>
dependency>
编写mybatis-generator.xml
,用来自动生成pojo
类和XXXmapper.xml
文件
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>
<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>
<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>
<plugins>
<plugin>
<groupId>org.mybatis.generatorgroupId>
<artifactId>mybatis-generator-maven-pluginartifactId>
<version>1.3.5version>
<dependencies>
<dependency>
<groupId>org.mybatis.generatorgroupId>
<artifactId>mybatis-generator-coreartifactId>
<version>1.3.5version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>5.1.41version>
dependency>
dependencies>
<executions>
<execution>
<id>mybatis generatorid>
<phase>packagephase>
<goals>
<goal>generategoal>
goals>
execution>
executions>
<configuration>
<verbose>trueverbose>
<overwrite>trueoverwrite>
<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
方式操作数据库的能力模型
在真正的生产环境中,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
对象,然后进行返回到前端,重启项目结果如下
当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),具体组件的方法实现不需要依赖于其它对象,而装饰者组合了一个组件,这样它可以装饰其它装饰者或者具体组件。所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。装饰者的方法有一部分是自己的,这属于它的功能,然后调用被装饰者的方法实现,从而也保留了被装饰者的功能。可以看到,具体组件应当是装饰层次的最低层,因为只有具体组件的方法实现不需要依赖于其它对象。
<>
通过使用包装器(装饰器)组装类的实现:由于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.commonsgroupId>
<artifactId>commons-lang3artifactId>
<version>3.7version>
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.hibernategroupId>
<artifactId>hibernate-validatorartifactId>
<version>5.4.1.Finalversion>
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
类型的属性是没问题的,无解…
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 = ""+ itemVO.title +" "+ itemVO.description +" "+ itemVO.price +" "+ itemVO.stock +" "+ itemVO.sales +" ";
$("#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.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
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’
[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
的配置容量总是不高(默认是10个),解决方案:
查看SpringBoot
配置:在spring-configuration-metadata.json
文件下,查看各节点的配置
修改外挂配置文件
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);
}
});
}
}
需要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
服务器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
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
并进行压测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.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
<version>2.0.5.RELEASEversion>
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
部署到和数据库服务器一起(不能部署到应用服务器端)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
,用作本机调试
var token = data.data;
window.localStorage["token"]=token;
var token = window.localStorage["token"];
if(token == null){
alert("没有登陆,不能下单");
window.location.href="login.html";
return false;
}
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);
}
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
的依赖
<dependency>
<groupId>com.google.guavagroupId>
<artifactId>guavaartifactId>
<version>18.0version>
dependency>
@PostConstruct
该注解被用来修饰一个非静态的void()方法。被@PostConstruct
修饰的方法会在服务器加载Servlet
的时候运行,并且只会被服务器执行一次。PostConstruct
在构造函数之后执行,init()
方法之前执行。该注解的方法在整个Bean初始化中的执行顺序:Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
```java
@Service
public class CacheServiceImpl implements CacheService {
private Cache 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
反向代理前置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}}
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 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数据
DNS
用CNAME
解析到源站用户将静态数据请求到ECS
服务器,ECS
服务器解析到阿里云的CDN
中,CDN
可以理解为一个无限大的内容磁盘缓存,本身没有文件存储的,当用户要访问getItem
一个静态资源文件的时候,只需要根据路由规则查看本地是否有这样的文件,有就直接返回,没有就回源到原站;回源到上图中的OSS
中去获取静态资源文件。如果取得了getItem
的html
静态资源文件,CDN
就可以一边返回对应的文件,一边把文件按照http
指示的生命周期缓存起来,以便于下一次用户在访问时,不用在回源到OSS
中,直接返回即可。
cache control响应头
cache control是服务端用来告诉客户端说,我这个http的response你可不可以缓存,以什么样的策略去缓存;
再验证一次,就是对缓存的有效性判断;
阿里云CDN缓存策略,这篇文章讲了CDN的自定义缓存策略,可以看一下细节;
静态资源部署策略
对应部署策略
phantomjs
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
并初始化[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
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
/*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;
}
/*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
分布式比如说我们有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
同一秒钟/分钟 接口调用多少次:数量不好控制,容易误伤