Seckill
基本环境配置
首先创建项目Seckill
pom.xml导入相关的maven依赖
在application.yml中配置
端口
数据库信息
mybatis配置
建立主启动类
@SpringBootApplication
@MapperScan("com.hspedu.seckill")
创建数据库
id,nickname,password,slat
password进行MD5加密防止被盗
得先加入MD5依赖
客户端----MD5(passworde明文+salt1)--->后端(md5(md5(password明文+salt1)+salt2)
编写POJO类(类似之前的Intity类)
@Data
@TableName("seckill_user")
User{
@TableId(value = "id",type=IdType.ASSIGN_ID) //自增ID
privatie Long id;
nickname;
TelePhone;
}
编写UserMapper接口
public interface UserMapper extends BaseMapper<User>{
}
编写UserMapper.xml文件实现其方法
<mapper namespace="com.hspedu.seckill.apper.UserMapper">
<resultMap id="BaseResultMap" type=".../User"> //对应User的属性
<id column="id" property="id" /> //column是数据库里名字,property是User的映射
nickname;
创建枚举类方便返回不同结果
@Getter
@ToString
@AllArgsConstructor
public enum RespBeanEnum{
//通用
SUCCESS(200,"SUCCESS"),
ERROR(500,"登录失败");
//登录
LOGIN_ERROR(500210,"用户id或密码错误"),
MOBILE_ERROR(500211,"手机格式不对"),
private final Integer code;
private final String message;
}
建立RespBean(返回信息的时候可能还带数据)
@Data
@NoArgsConstructor
@AllArgsConstructor
RespBean{
private long code;
private String message;
private Object obj;
//成功后同时携带数据
public static RespBean success(Object data){
return new RespBean(RespEnum.SUCCESS.getCode(),
RespEnum.SUCCESS.getMessage(),data);
}
//成功后不携带数据
public static RespBean success(Object data){
return new RespBean(RespEnum.SUCCESS.getCode(),
RespEnum.SUCCESS.getMessage(),null);
}
//失败-返回失败信息,不携带数据
public static RespBean error(RespBeanEnum respBeanEnum){
return new RespBean(respBeanEnum.getCode())
}
//失败-返回失败信息,携带数据
public static RespBean error(RespBeanEnum respBeanEnum,Object data){
return new RespBean(respBeanEnum.getCode(),data);
}
}
LoginVo (接收用户登录时发送的信息)
@Data public class LoginVo{ private String mobile; private String passworde; }
ValidatorUtil(验证手机号正确性)
public class ValidatorUtil{ private static finl Pattern mobile_pattern = Pattern.compile(...正则表达式) public static boolean isMobile(String mobile){ if(!StringUtils.hasText(mobile)){ return false; } Matcher matcher = mobile_pattern.matcher(mobile); return matcher.mathches();; } }
开始写Service层
public interface UserService extends IService<User>{
//Iservice声明了很多方法,也可以加入自己定义的
RespBean doLogin(LoginVo loginVo,requet,response)
}
实现Service接口
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper,User>
implements UserService {
@Resource
private UserMapper userMapper;
@Override
//重写接口的自定义方法
doLogin(...){
//先接收mobile和密码
String mobile = loginVo.getMoboile();
String pwd = loginVo.getPassword();
//判断手机号和密码是否为空
if(!StringUtils.hasText(mobile) || !StringUtils.hasText(password)){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
//验证手机号格式
if(!ValidatorUtil.isMobile(mobile)){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
//查询DB
User user = userMapper.selectById(mobile);
if(null == user){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
//若用户存在则对比密码
if(!MD5Util.midPassToDBPASS...){
return RespBean.error(RespBeanEnum.LOGIN_ERROR);
}
return RespBean.success(); //登录成功
}
}
然后是控制层
得先再pom引入spring-boot-starter-validation来验证
@Controller
@RequetMapping("/login")
LoginController{
@Resource //装备Usersevice
private UserService userservice;
@RequestMapping("/toLogin")
public String toLogin(){ //到登录页面
return "login";
}
@RequestMapping("/doLogin") //如果是返回信息则直接用RespBean返回
@ResponseBody //意思为返回数据而非跳转页面
public RespBean doLogin(@Valid LoginVo loginVo,request,response){
return userService.doLogin(loginVo,request,response); //验证
}
}
前端(就不写了)通过ajax请求将数据打到后端控制台的doLogin
得通过Maven的Complie编译到target目录
function doLgin(){
$.ajax({
url:"/login/doLogin"
type:"POST"
data:{
mobaile:... //对应LoginVo的两个属性进行封装
password:...
},
success:function(data){ //data是从后端拿到的信息,得校验
if(data.code==200){
alert(data.message);
}
....
}
})
}
定义全局异常
@Data
@All...
@No
public GlobalException extend RuntimeException{
private RespBeanEnum respBeanEnum; //返回的异常就是枚举类里的
}
全局异常处理器
@RestControllerAdvice //加了这个注解,这个类就是全局异常
Public class GlobalExceptionHandler{
@ExceptionHander(Exception.class)
public RespBean ExceptionHandeler(Excepton e){
GlobalException ex = (GlobalException) e;
...处理逻辑
return RespBean.error(RespBeanEnum.ERROR);
}
}
然后UerServiceImpl就可以这么修改
//return RespBean.error(..)
throw new GlobalException(RespBeanEnum.LOGIN_ERROR);
记录Session
用户验证成功后,保存Session记录用户信息,进入到商品列表
UUIDUtil标识用户的唯一性
public class UUIDUtil{
public static String uuid(){
return UUID.randomUUID().toString().repalce("-",""); //替换掉 -
}
}
CookieUtil工具类可以更方便操作cookie
publica class CookieUtil{
//很多代码都是固定的,就不写了
}
通过Service保存UUID
UserServiceimpl{
... {
String ticket = UUIDUtil.uuid(); //每个用户生成唯一ticket
request.getSession().setAttribute(ticket,user);
//将登录成功的用户信息保存到Session,唯一标识,session的key就是ticket
//通过Cookie工具类设置cookie
CookieUtil.setCookie(requst,response,"userTicket",ticket);
cookie名字 cookie值
}
}
进入商品页面
新写个Controller
@Controller
@Req..("/goods")
public class GoodController{
@RequstMapping("/toList")
public String toList(HttpSession session,Model model, //要拿到Session
@CookieValue("userTicket") String ticket){ //获取Cookie指定值
if(!StringUtils.hasText(ticket)){
return "login";
}
User user = (User)session.getAttribute(ticket); //看看有没有登录成功信息
if(null == user){ //没有登录成功
return "login"; //返回登录
}
model.addAttribute("user",user); //成功则将user放入model
return "goodlist";
}
}
分布式Session
先提出问题
集群
TomcatA
client Nigix
TomcatB
假如甲来秒杀,TomcatA没有记录他,好,它可以秒杀并记录,但TomcatB并未记录甲再请求可能导致超卖
解决:
1.Session绑定(使用较少)
服务器把某个用户的请求,交给Tomcat集群中的一个节点,以后此节点负责保存该用户session,可以利用负载均衡的源地址Hash算法实现,同一个ip地址请求发送到同一台服务器
2.Session复制(小型架构使用较多)
集群中的服务器同步他们之间的session,使每台都保存所有用户Session
3.前端存储(数据大小受cookie限制,用的较少)
字面意思
4.后端集中存储(安全容易水平拓展但有点复杂)
集群
TomcatA
client Nigix
TomcatB Redis存储Session
故现在选择将用户Session信息统一保存到Redis进行管理,而不是分布式地存放到不同服务器
request.getSession().setAttribute(ticket,user) //这个session保存到服务器的语句就得改
这里就需要安装redis-desktop-manager(Redis可视化操作工具)
Spring整合Redis
pom.xml
spring-session-data-redis
application.yml配置redis
redis:
host:
port:
database:
..
直接将用户登陆信息放到Redis利于操作
key = user:...
value=user...
就得用到RedisTemplate,最好自定义配置,系统自带的不太好
@Configuration
public class RedisConfig{
....
}
在UserServiceImpl配置
UserServiceImpl{
@Resource
private RedisTemplate redistemplate; //这是自己配置的
修改这句request.getSession().setAttribute(ticket,user)
redisTemplate.opsForValue().set("user:"+ticket,user); //登录信息存到Redis
}
这样Controller也要到Redis获取信息,现在UserService定义方法并在UerServiceImp实现
interface UserService{
User getUserByCookie(String userTicket,requst,response)
}
UserServiceImpl{
@Resource RedisTemplate redisTemplate;
@Override
User getUer(...){ //获取redis值
User user = (User)redisTemplate.opsForValue().get("user"+userTicket);
//如果用户不为空,就重新设置cookie,刷新,根据业务需求来
if(user != null){
CookieUtil.setCookie(requst,response,"userTicket",userTicket);
}
}
}
GoodController{
@Resource
private UserService userservice; //装配userservice
toList(Model model,
@Requst
HttpServletRequest request,
HttpServletResponse response){
//从redis获取用户
User userByCookie = userService.getUerByCookie(ticket,requst,response);
}
}
商品页面数据
商品属性
t_goods
id,
goods_name
goods_title
goods_imag
...
秒杀商品属性
t_seckill_goods
id
goods_id
seckill_price
start_data //秒杀开始时间
end_data //结束时间
...
在Java里pojo包实现他们两个的实体类
@Data
@TableName("t_goods")
Goods{
@TableId(value = "id",type = IdType.AUTO)
id
goodsname
goodsTitle
...
}
@Date
@T...
SecKillGoods{
@TableId(value = "id",type=IdType.AUTO)
private Long id;
goodsId
seckillPrice
...
}
因为到时候在页面展示时秒杀价和原价是同时展现的,所以得合并两张表价格信息
vo类下新建GoodsVo(对应显示在秒杀商品列表信息)
@Data
@All
@No
public class GoodsVo extends Goods{ //先继承Goods再补全seckillGoods的
startDate;
endDate;
...
}
Mapper层-Goods的
public interface GoodsMapper extends BaseMapper<Goods>{
//获取商品列表-秒杀
List<GoodsVo> findGoodsVo()
}
配置其对应Mapper.xml文件
GoodsMapper.xml
//通用查询映射结果
<resultMap id="..." type="com..pojo.Goods">
<id column="id" property="id"/>
<result column="goods_name" property = "goodsName"/>
...
</resultMap>
<select id="findGoodsVo" resultType="..vo.GoodsVo">
SELECT g.id,g.goods_name,g.goos_img,sg.start_date,sg.end_date
FROM t_goods g
LEFT JOIN t_seckill_goods sg
ON g.id = sg.goods.id
</select>
Mapper层-seckillGoods的
public interface SeckillGoodsMapper extends
BaseMapper<SeckillGoods>{
}
对应XMl文件
//通用查询映射结果
<resultMap id="BaseResultMap" type="com..pojo.SeckillGoods">
<id column="id" property="id"/>
<result column="goods_name" property = "goodsName"/>
...
</resultMap>
Service层-Goods
public interface GoodsService extends IService<Goods>{ //秒杀商品集合 List<GoodVo> findGoodsVo(); }
实现类
@Service public class GoodsServiceImpl extends ServiceImpl<GoodsMapper,Goods> implements Goodservice{ @Resource goodsMapper;.. //装配Mapper @Ovrride findGoods{ goodsmapper.findGoodsVo(); } }
Service层SeckillGoods
public interface SeckillGoodsService extends IService<SeckillGoods>{ }
@Service public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper,SeckillGoods> implements SeckillGoodservice{ }
修改GoodsCtroller
GoodsSCtroller{
@Resource
private GoodeService goodsService;
@RequstMapping("/.."){
//将商品列表信息,放入model,携带到下一模板使用
model.addAttribute("goodsList",goosService.findGoodsVo());
}
}
通过model携带的数据到goodsList.html将数据展示在页面
<tr th:each"goods,goodstStat" : ${goodsList}> //这里对应上面
<td th:text=${goods.goodsName}
同时让前端接收到200后直接进入商品页面
success:function(data){
if(data.code==200){
Window.location.href="/goods/toList"
}
}
然后就进入商品展示页面了
在GoodsMapper加入方法获取商品详情
GoodsMapper{
//获取指定商品详情
GoodsVo findGoodsVoByGoodsId(Long goodsId);
}
XML对应
GoodsMapper.xml
<select id="findGoodsVoByGoodsId" resultType="..vo.GoodsVo">
SELECT g.id,g.goods_name,g.goos_img,sg.start_date,sg.end_date
FROM t_goods g
LEFT JOIN t_seckill_goods sg
ON g.id = sg.goods.id
WHERE g.id = #{goodsId} //通过方法findGoodsVoByGoodsId的形参决定
</select>
修改Service层(Goods)及其实现层
GoodsService{ GoodsVo findGoodsVoByGoodsId(Long goodsId) }
GoodsServiceImpl{ GoodsVo findGoodsVoByGoodsId(Long goodsId){ goodsMapper.findGoodsVoByGoodsId(goodsId); } }
然后就是Controller层
GoodsController{ //进入商品详情页 //因为前端有这个 href="'/goods/toDetail/'+${goods.id}" @RequstMapping("/toDetail/{goodsId}") public String toDetail(Model model,User user,@PathVariabel Long goodsId){ //User是自定义参数解析器包装request和response处理后来的 if(user == null){ //判断有没有登陆 return "login"; } model.addAttribute("user",user); //model携带数据到前端 GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId); model.addAttribute("goods",goodsVo); //这里的名称要和前端匹配配合 return "goodsDetail"; } }
秒杀倒计时
即可以在页面展示秒杀开始时间和秒杀倒计时
在GoodsController中做修改
..
{
//返回商品详情时,同时返回商品秒杀状态和剩余时间
//定义 secKillStatus秒杀状态 0:秒杀开始 1:秒杀进行中 2:秒杀结束
//remainSeconds 剩余秒杀时间 -1:秒杀已结束
Date startDate = goodsVo.getStartDate(); //得到开始时间
Date endDate = goodsVo.getEndDate(); //得到结束时间
Date nowDate = new Date();
int secKillStatus = 0;
int remainSeconds = 0;
if(nowDate.before(startDate)){ //还没有开始秒杀
remainSecond =(int)(startDate.getTime()-nowDate.getTime())/1000; //还有多久开始秒杀
}else if(nowDate.after(endDate)){ //秒杀已结束
secKillStatus = 2;
remainSeconds = -1;
}else{
secKillStatus = 1;
remainSeconds = 0;
}
model.addAttribute("secKillStatus",secKillStatus); //通过model传给前端
model.addAttribute("remainSeconds",remainSeconds);
}
秒杀基本实现
秒杀成功进入页面填写相关信息;秒杀失败返回信息(库存不够,重复购买等)
同样得创建两张表
t_order普通订单 和 t_seckill_order
因为用户可能是正常购买,也可能是秒杀
t_order普通订单{
id
user_id
goods_id
goods_name
...
}
t_seckill_order秒杀表{
id;
user_id
order_id
...
UNIQUE KEY `seckill_uid_gid`(`user_id`,`good_id`)USNIG BTREE COMMENT '用户id'
//商品id的唯一索引,解决同一个用户多次抢购
}
创建他们对应的Entity实体类
Order 和 SeckillOrder
然后经典Mapper接口
public interface OrderMapper extends BaseMapper<Order>{ }
public interface SeckillMapper extends BaseMapper<SeckillOrder>{ }
OrderMapper.xml <mapper namespace="com.....GoodMapper"> //通用映射结果集 <resultMap id="BaseResultMap" type="...Order"> //假如以后返回实体类类型Order则其 <id column="id" property="id" /> property 字段对应表格这些字段 <result column="user_id" property="userId" /> <result column="goods_id" property="goodsId" /> ... </mapper>
SeckillOrder.xml类似
Service层
public interface OrderService extends IService<Order>{ //完成秒杀方法 Order seckill(User user,GoodsVo goodsVo); //谁来买什么 }
public interface SeckillOrderService extends IService<SeckillOrder>{ }
Service实现类
@Service public class OrderServiceImpl extends ServiceImpl<OrderMapper,Order> implements OrderService{ @Resource private SeckillGoodsService seckillGoodsService; //装配他方便查库存 @Resource OderMapper.. @Resource seckillOrderService... seckill(user,goodsVo){ //查询秒杀商品库存量 ,判断是否够会在controller里判断 SeckillGoods seckillGoods = seckillGoodsService.getOne(goodsVo.getId()); //完成基本秒杀操作 seckillGoods.setStackCount(seckillGoods.getStockCount()-1); //库存减一 seckillGoodsService.updateById(seckillGoods); //生成普通订单 Order order = new Order(); order.setUserId(user.getId()); order.setGoodsId(goodsVo.getId()); ... orderMapper.insert(order); //保存订单 //生成秒杀商品订单 SeckillOrder seckillOrder = new SeckillOrder(); seckillOrder.setGoodeId(goodVo.getId()); ... seckillOrderService.save(seckillOrder); //保存订单 return order } }
Controller修改
@Controller @RequestMapping("/seckill") //这里根据前端跳转来 SeckillController{ @Resource goodsService; seckillOrderService; orderService; //处理用户抢购/秒杀请求 @RequstMapping("/doSeckill") public String doSeckill(Model model,User user,Long goodsId){ if(user == null){ //用户没登录 return "login"; } model.addAttribute("user",user); //user放入model GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId); if(goodsVo.getStockCount() < 1){ //判断库存 model.addAttribute("errms",RespBeanEnum.ENTRY_STOCK.getMessage()); return "seckillFail"; //返回错误页面 } if(seckillOrderService.getOne("user_id",user_id),"gdid.." )!=null){ // 判断是否为复购 / userid,goodsid是否存在 model.addAttribute("errms",RespBeanEnum.ENTRY_STOCK.getMessage()); return "seckillFail"; } //都通过就抢购 Order order = orderService.seckill(user,goodsVo); if(order == null){ //抢购失败的话 model.addAttribute("errms",RespBeanEnum.ENTRY_STOCK.getMessage()); return "seckillFail"; } model.addAttribute("order",order); //带入下一个页面 model.addAttribute("goods",goods); ... return "orderDetail"; //跳转到订单详情页html } }
JMeter
Apache基于Java开发的压力测试工具,用于对软件做压力测试的,可以测试静态和动态资源,可对服务器,网络或对象模拟巨大的负载
双击jmeter.bat即可启动
模拟
新增线程组
线程数: 10
Ramp-Up(秒): 0
循环次数: 5
HTTP请求默认值
协议:http 名称:localhost 端口 8080
HTTP请求
HTTP请求:GET 路径:/goods/toList
监听器-聚合报告
监听器-察看结果树
监听器-图形结果 //这四个可要可不要
监听器-结果报告
还得加个cookie管理器不然登不进去
HTTP Cookie管理器
名称 值 域 ...
模拟俩个用户请求
数据库添加
1330000000 jack
1330000001 smith
创建配置文件text文件
1330000000,23e238db32414kd //第二个是cookie(票)值
1330000001,36aef4klmnnoikm
进行Jmeter配置
CSV 数据文件设置
文件名: ..text //就是上面那个
变量名称:userId,userTicket //userTicket值就是从text中取
分隔符: ,
HTTP Cookie管理器
名称 值 域 路径
userTicket ${userTicket}
//对应上面的userTicket值
实战:压测商品抢购
得要2000个用户 先创建UserUtil,创建用户并登录得到userticket写入config.txt
public class UserUtil{
... //用到直接复制粘贴即可
}
然后新增线程组
HTTP-秒杀 HTTP请求 路径 GET /seckill/doSeckill 参数 名称 值 goodsId 1 //秒杀goodsId为1的商品
测试后发现会超卖
这就是高并发引起的问题,得解决
seckillGoods.setStockCount(seckillGoods.getStockCount()-1); //比如高并发可能会使20个线程同时拿到StockCount, 然后20个才减去一个1,就是不具备原子性
页面优化
多用户在查看商品列表和商品详细时,每一个用户都需要到DB查询,DB压力很大,但商品信息又不变化,可以通过Redis缓存页面来进行优化。直接将查询结果缓存到Redis进行返回。就是减少对数据库的访问。
原始:
浏览器 后端程序 数据库
改进:
浏览器 后端程序 Redis 数据库
//第一次查询通过Redis到数据库查然后返回缓存到Redis
//后面的相同请求查询就可以到Redis得到
优化
Controller
GoodsController{
....
@Resource
private ThymeleafViewResolver thyleafViewResolver; //手动渲染需要的模板解析器
@RequestMapping("/toList",produces="text/html;charset=utf-8")
@ResponseBody
public String toList(Model model,User user,request,response){
//先到Redis看有没有缓存页面
ValueOperaions vs = redisTemplate.opsForValue();
String html = (String)valueOperations.get("goodsList");
if(StringUtils.hasText(html)){
return html;
}
...
//如果没有从Redis获取到,则手动渲染加入Redis
WebContext webContext = //获取Web上下文
new WenContext(request,response,requst.getServletContext,model.asMap());
取出model数据
html = thyleafViewResolver.getTemplaateEngine().process("goodsList",webContext);
//拿到模板引擎 渲染模板
vs.set("goodsList",html,60,TimeUnit.SECONDS);
//缓存到redis的key名称 60s更新一次
return html;
}
}
toDetail类似弄到Redis缓存去
小问题
还有个小问题:因为Redis60s更新一次,如果在这期间修改数据了,但用户期间拿不到最新数据怎么办?同样这个问题缓存在Redis的对象也有?
在Redis冷却期间修改了数据可以直接将Redis的数据删除这样Redis会重新从数据库,提前修改数据库数据即可
先修改Service
UserService{ //方法,更新密码 RespBean updatePWD(String userTicket,String pwd,request,response) //拿到userTicket直接从Redis找,requset可能会返回数据,密码为新密码 }
//实现该方法 UserServiceImpl{ updatePWD(...){ User user = getUserByCookie(userTicket,rst,rse); //通过票据得到对应user if(user == null){ //不存在则抛异常 throw new GlobalException(RespBeanEnum,MOBILE_NOT_EXIST); } //设置新密码 user.setPassword(MD5Util.inputPassToDBPass(password,user.getSlat())); //更新到数据库 userMapper.updateByid(user); //删除在Redis的该用户数据 redisTemplate.delete("user:"+userTicket); } }
Controller层
UserController{
@Resource
userService
@RequstMapping("/updpwd")
@ResponseBody
public RespBean updatePWD (String userTicket,String PWD,rst,rse){
return userService.updatePWD(...//上面这四个)
}
}
这下解决多用户高并发秒杀商品出现的超卖和多订单问题
浏览器 过滤 seckill方法 //这里可能 //这里可能20个请求才将库存减1 请求冲过来
seckillGoods.setStockCount(seckillGoods.getStockCount()-1); //主要就是这句话会出问题 //比如高并发可能会使20个线程同时拿到StockCount, 然后20个才减去一个1,就是不具备原子性
修改OrderServiceImpl
OrderServiceimp{
//Mysql默认隔离级别 可重复读 执行update语句会在事务中锁定要更新的行,
这样可防止其他会话在同一执行update,delete;
boolean update = seckillGoodsService.update(new UpdateWa<SeckillGoods>()
.setSql("stock_count = stock_count-1")
.eq("goods_id",goodsVo.getId()).gt("stock_count",0));
//只有更新成功才返回true
if(!update){
return null; //如果不为真,防止订单再增加(下面的)
}
Order order = new Order()...
...
//这里可以将秒杀订单放入Redis,这样查询某个用户是否秒杀过时,可直接到Redis查询
redisTemplate.opsForValue().set
("order:"+user.getId()+":"+goodsVo.getId(),seckillOrder);
// 秒杀订单key => order:用户id:商品id
}
继而修改Controller
SeckillController{
("/doSeckill"){
//判断用户是否复购,直接就从Redis中获取对应秒杀订单,若有,则不能继续秒杀
SeckillOrder o = redisTemplate.opsForValue().get
("order:"+user.getId()+":"+goodsVo.getId());
if(null != o){ //说明用户已经秒杀过了
model.addAttribute("errmsg"...);
return "secKillFail"; //返回错误页面
}
}
}