Java高并发秒杀系统(一)

1 开场白

1.1 项目概述

Java高并发秒杀系统(一)_第1张图片

重点:缓存+异步+分布式+优雅的代码

1.2 项目开发环境与框架搭建

基于Spring Boot搭建项目

Spring MVC配置繁多,而Spring Boot只有一个配置文件application.properties

参考:《Spring Boot 入门教程 》

1.2.1 集成MyBatis

参考文档

@Transactional  @Mapper   @Select

1.2.2 Linux下部署Redis

参考:《Redis基础教程》   官网    官方安装文档

下载redis安装文件 http://redis.io/ redis-5.0.4.tar.gz
tar -zvxf redis-5.0.4.tar.gz
cd redis-5.0.4
make -j 4(用4个cpu加快编译)
make install

建议通读redis.conf

vi ./redis.conf

默认只有本机可以访问redis,设置为0.0.0.0,允许任意服务器访问

:/dae搜索,设为yes允许后台执行
redis-server ./redis.conf         启动服务器,先在etc/profile文件配置路径
redis-cli                                  客户端连接

安全起见,添加一个访问密码

Java高并发秒杀系统(一)_第2张图片

重启一下再访问就要输密码了

Java高并发秒杀系统(一)_第3张图片

Java高并发秒杀系统(一)_第4张图片

Java高并发秒杀系统(一)_第5张图片

cd utils    ./install_server.sh               安装成系统服务

Java高并发秒杀系统(一)_第6张图片Java高并发秒杀系统(一)_第7张图片
chkconfig –-list | grep redis    查看是否开机启动

1.2.3 项目中集成Redis

添加Jedis依赖

添加Fastjson依赖:负责将Java对象转换成Json字符串,写入Redis服务器

Java高并发秒杀系统(一)_第8张图片

不同功能模块使用不同的key前缀

public interface KeyPrefix {
		
	public int expireSeconds();
	
	public String getPrefix();
	
}
public abstract class BasePrefix implements KeyPrefix{
	
	private int expireSeconds;
	
	private String prefix;
	
	public BasePrefix(String prefix) {//0代表永不过期
		this(0, prefix);
	}
	
	public BasePrefix( int expireSeconds, String prefix) {
		this.expireSeconds = expireSeconds;
		this.prefix = prefix;
	}
	
	public int expireSeconds() {//默认0代表永不过期
		return expireSeconds;
	}

	public String getPrefix() {
		String className = getClass().getSimpleName();
		return className+":" + prefix;
	}

}
public class GoodsKey extends BasePrefix{

	private GoodsKey(int expireSeconds, String prefix) {
		super(expireSeconds, prefix);
	}
	public static GoodsKey getGoodsList = new GoodsKey(60, "gl");
	public static GoodsKey getGoodsDetail = new GoodsKey(60, "gd");
	public static GoodsKey getMiaoshaGoodsStock= new GoodsKey(0, "gs");
}
public class MiaoshaKey extends BasePrefix{

	private MiaoshaKey( int expireSeconds, String prefix) {
		super(expireSeconds, prefix);
	}
	public static MiaoshaKey isGoodsOver = new MiaoshaKey(0, "go");
	public static MiaoshaKey getMiaoshaPath = new MiaoshaKey(60, "mp");
	public static MiaoshaKey getMiaoshaVerifyCode = new MiaoshaKey(300, "vc");
}
//取缓存
String html = redisService.get(GoodsKey.getGoodsDetail, ""+goodsId, String.class);
if(!StringUtils.isEmpty(html)) {
    	return html;
}

1.2.4 项目集成Druid

数据库连接池负责分配,管理和释放连接,实际上是个生产者消费者模式,生产者是连接创建线程和连接回收线程,消费者是获取连接的线程。

Java高并发秒杀系统(一)_第9张图片

Druid是阿里巴巴的一个数据库连接池开源框架。当应用启动时,连接池初始化最小连接数MIN,当外部请求到达时,直接使用空闲连接即可。并发数达到最大MAX,则需要等待,直到超时。如果一直没拿到连接,就会抛出异常。

MIN过小,会出现过多请求排队等待获取连接

MIN过大,会造成资源浪费

MAX过小,峰值情况下仍有很多请求在等待状态

MAX过大,导致数据库连接被占满,大量请求超时,引发服务器雪崩

若数据库配置的MAX是100,一个请求10ms,则最大能够处理10000QPS。(1s=1000ms=100*10ms)

2  实现用户登录以及分布式session功能

2.1 前端校验用于登录的手机号

jQuery Validate



2.2 数据库设计

miaosha_user表

CREATE TABLE `miaosha_user` (
  `id` bigint(20) NOT NULL COMMENT '用户ID,手机号码',
  `nickname` varchar(255) NOT NULL,
  `password` varchar(32) DEFAULT NULL COMMENT 'MD5(MD5(pass明文+固定salt) + salt)',
  `salt` varchar(10) DEFAULT NULL,
  `head` varchar(128) DEFAULT NULL COMMENT '头像,云存储的ID',
  `register_date` datetime DEFAULT NULL COMMENT '注册时间',
  `last_login_date` datetime DEFAULT NULL COMMENT '上蔟登录时间',
  `login_count` int(11) DEFAULT '0' COMMENT '登录次数',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Java高并发秒杀系统(一)_第10张图片

salt存放数据库随机生成的salt

2.3 明文密码两次MD5处理

HTTP协议是明文传输,输入密码后若直接发送服务端验证,此时被截取将直接获取到明文密码,获取用户信息。

用户端(第一次MD5)

用户登录时输入明文密码,生成一个固定Salt,与该密码拼装后进行第一次MD5处理,然后传输给服务器端。

PASS = MD5(明文密码 + 固定Salt)

第一次MD5防止明文密码在网络传输时被盗

服务端(第二次MD5)

服务器端接收到进行了第一次MD5处理后的密码时,要生成一个随机的Salt,与密码进行拼装后再进行第二次MD5,最后才写入数据库。

PASS = MD5(密码 + 随机Salt)

第二次MD5防止数据库被入侵时,密码被反查

import org.apache.commons.codec.digest.DigestUtils;

public class MD5Util {
	
	public static String md5(String src) {
		return DigestUtils.md5Hex(src);
	}
	
        //固定盐值
	private static final String salt = "1a2b3c4d";
	
        //第一次MD5,明文密码+固定Salt
	public static String inputPassToFormPass(String inputPass) {
		String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
		System.out.println(str);
		return md5(str);
	}
	

        //第二次MD5,密码+随机Salt
	public static String formPassToDBPass(String formPass, String salt) {
		String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);
		return md5(str);
	}
	
        //最终调用生成密码的方法
	public static String inputPassToDbPass(String inputPass, String saltDB) {
		String formPass = inputPassToFormPass(inputPass);
		String dbPass = formPassToDBPass(formPass, saltDB);
		return dbPass;
	}
}

2.4 登录功能实现

login.html

......

......

common.js

var g_passsword_salt="1a2b3c4d"

LoginVo.java

public class LoginVo {
	
	@NotNull
	@IsMobile
	private String mobile;
	
	@NotNull
	@Length(min=32)
	private String password;
	
	public String getMobile() {
		return mobile;
	}
	public void setMobile(String mobile) {
		this.mobile = mobile;
	}
	...
	public String toString() {
		return "LoginVo [mobile=" + mobile + ", password=" + password + "]";
	}
}

MiaoshaUser.java

public class MiaoshaUser {
	private Long id;
	private String nickname;
	private String password;
	private String salt;
	private String head;
	private Date registerDate;
	private Date lastLoginDate;
	private Integer loginCount;
	public Long getId() {
		return id;
	}
	public void setId(Long id) {
		this.id = id;
	}
	......
}

LoginController.java

@Controller
@RequestMapping("/login")
public class LoginController {

    private static Logger log = LoggerFactory.getLogger(LoginController.class);
	
    @Autowired
    MiaoshaUserService miaoshaUserService ;
	
    @RequestMapping("/to_login")
    public String toLogin() {
        return "login";
    }
    
    @RequestMapping("/do_login")
    @ResponseBody
    public Result doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
        //输出日志
    	log.info(loginVo.toString());
    	//登录
    	String token = miaoshaUserService.login(response, loginVo);
    	return Result.success(token);
    }
}

MiaoshaUserService.java

@Service
public class MiaoshaUserService {
	
	public static final String COOKI_NAME_TOKEN = "token";
	
	@Autowired
	MiaoshaUserDao miaoshaUserDao;
	
	@Autowired
	RedisService redisService;
	
	...
	
	public String login(HttpServletResponse response, LoginVo loginVo) {
		if(loginVo == null) {
			throw new GlobalException(CodeMsg.SERVER_ERROR);
		}
		String mobile = loginVo.getMobile();
		String formPass = loginVo.getPassword();
		//判断手机号是否存在
		MiaoshaUser user = getById(Long.parseLong(mobile));
		if(user == null) {
			throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
		}
		//验证密码
		String dbPass = user.getPassword();
		String saltDB = user.getSalt();
		String calcPass = MD5Util.formPassToDBPass(formPass, saltDB);
		if(!calcPass.equals(dbPass)) {
			throw new GlobalException(CodeMsg.PASSWORD_ERROR);
		}
		//生成cookie
		String token	 = UUIDUtil.uuid();
		addCookie(response, token, user);
		return token;
	}
        ...
}

MiaoshaUserDao.java

@Mapper
public interface MiaoshaUserDao {
	
	@Select("select * from miaosha_user where id = #{id}")
	public MiaoshaUser getById(@Param("id")long id);

	@Update("update miaosha_user set password = #{password} where id = #{id}")
	public void update(MiaoshaUser toBeUpdate);
}

2.5 JSR303参数检验

2.5.1 引入依赖


    org.springframework.boot
    spring-boot-starter-validation
public class LoginVo {
	
	@NotNull
	@IsMobile
	private String mobile;
	
	@NotNull
	@Length(min=32)
	private String password;
	......
}
@Controller
@RequestMapping("/login")
public class LoginController {

    ...
    
    @RequestMapping("/do_login")
    @ResponseBody
    public Result doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
       ...
    }
}

2.5.2 自定义Validator

IsMobile.java

@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {IsMobileValidator.class })
public @interface  IsMobile {
	
	boolean required() default true;
	
	String message() default "手机号码格式错误";

	Class[] groups() default { };

	Class[] payload() default { };
}

IsMobileValidator.java

public class IsMobileValidator implements ConstraintValidator {

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

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

2.6 全局异常处理器

GlobalException.java

public class GlobalException extends RuntimeException{

	private static final long serialVersionUID = 1L;
	
	private CodeMsg cm;
	
	public GlobalException(CodeMsg cm) {
		super(cm.toString());
		this.cm = cm;
	}

	public CodeMsg getCm() {
		return cm;
	}
}

GlobalExceptionHandler.java

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {
	@ExceptionHandler(value=Exception.class)
	public Result exceptionHandler(HttpServletRequest request, Exception e){
		e.printStackTrace();
		if(e instanceof GlobalException) {
			GlobalException ex = (GlobalException)e;
			return Result.error(ex.getCm());
		}else if(e instanceof BindException) {
			BindException ex = (BindException)e;
			List errors = ex.getAllErrors();
			ObjectError error = errors.get(0);
			String msg = error.getDefaultMessage();
			return Result.error(CodeMsg.BIND_ERROR.fillArgs(msg));
		}else {
			return Result.error(CodeMsg.SERVER_ERROR);
		}
	}
}

2.7 分布式Session

假设一个项目部署在一个服务器集群中,而负载均衡服务SLB基于服务器集群之上,为客户端的请求分配服务器。同一个用户发送的不同请求是会落在不同的服务器上,那么不同服务器怎样识别同一个用户的session?

Java高并发秒杀系统(一)_第11张图片

方案一:进行session同步,实现起来复杂。

方案二:使用token。用户登录成功时,服务器会为用户生成一个token(使用UUID来生成),然后写入cookie中,再传递给客户端。客户端发送请求时,会在cookie中上传token,服务器端根据token获取session信息。

(由于SessionID是容器生成的,如果是多台tomcat就会有多个SessionID,所以不直接使用SessionID去实现)

MiaoshaUserService.java

@Service
public class MiaoshaUserService {
		
	public static final String COOKI_NAME_TOKEN = "token";
	
	@Autowired
	MiaoshaUserDao miaoshaUserDao;
	...
	public MiaoshaUser getByToken(HttpServletResponse response, String token) {
		if(StringUtils.isEmpty(token)) {
			return null;
		}
		MiaoshaUser user = redisService.get(MiaoshaUserKey.token, token, MiaoshaUser.class);
		//延长有效期
		if(user != null) {
			addCookie(response, token, user);
		}
		return user;
	}
	
	public String login(HttpServletResponse response, LoginVo loginVo) {
		...
		//用户登录成功后,生成一个token,写入cookie
		String token = UUIDUtil.uuid();
		addCookie(response, token, user);
		return token;
	}
	
	private void addCookie(HttpServletResponse response, String token, MiaoshaUser user) {
        //用户与token一起写入缓存
		redisService.set(MiaoshaUserKey.token, token, user);
		Cookie cookie = new Cookie(COOKI_NAME_TOKEN, token);
		cookie.setMaxAge(MiaoshaUserKey.token.expireSeconds());
		cookie.setPath("/");
		response.addCookie(cookie);
	}
}

MiaoshaUserKey.java

public class MiaoshaUserKey extends BasePrefix{

	public static final int TOKEN_EXPIRE = 3600*24 * 2;
	private String prefix ;
	private MiaoshaUserKey(int expireSeconds, String prefix) {
		super(expireSeconds, prefix);
		this.prefix = prefix;
	}
	public static MiaoshaUserKey token = new MiaoshaUserKey(TOKEN_EXPIRE, "tk");
	
	public static MiaoshaUserKey getById = new MiaoshaUserKey(0, "id");
	
	public MiaoshaUserKey withExpire(int seconds) {
		return new MiaoshaUserKey(seconds, prefix);
	}
}

RedisService.java

@Service
public class RedisService {
	
	@Autowired
	JedisPool jedisPool;
	
	/**
	 * 获取当个对象
	 * */
	public  T get(KeyPrefix prefix, String key,  Class clazz) {
		 Jedis jedis = null;
		 try {
			 jedis =  jedisPool.getResource();
			 //生成真正的key
			 String realKey  = prefix.getPrefix() + key;
			 String  str = jedis.get(realKey);
			 T t =  stringToBean(str, clazz);
			 return t;
		 }finally {
			  returnToPool(jedis);
		 }
	}
	/**
	 * 设置对象
	 * */
	public  boolean set(KeyPrefix prefix, String key,  T value) {
		 Jedis jedis = null;
		 try {
			 jedis =  jedisPool.getResource();
			 String str = beanToString(value);
			 if(str == null || str.length() <= 0) {
				 return false;
			 }
			//生成真正的key
			 String realKey  = prefix.getPrefix() + key;
			 int seconds =  prefix.expireSeconds();
			 if(seconds <= 0) {
				 jedis.set(realKey, str);
			 }else {
				 jedis.setex(realKey, seconds, str);
			 }
			 return true;
		 }finally {
			  returnToPool(jedis);
		 }
	}
}

KeyPrefix.java

public interface KeyPrefix {	
	public int expireSeconds();
	public String getPrefix();
}

3  秒杀功能开发及管理后台

3.1 数据库设计

Java高并发秒杀系统(一)_第12张图片

扩充秒杀商品表,而不直接在商品表中添加字段标识,后期有各种活动时,各种修改商品表,不便于扩展。

goods

Java高并发秒杀系统(一)_第13张图片

seckill_goods

Java高并发秒杀系统(一)_第14张图片

order_info

Java高并发秒杀系统(一)_第15张图片

seckill_order

Java高并发秒杀系统(一)_第16张图片

哪个用户秒杀了哪个商品。

数据表中的id很少用自带的自增,很容易被人从1开始遍历。一般使用snowflake算法。

3.2 商品列表页

goods_list.html




...


秒杀商品列表
商品名称商品图片商品原价秒杀价库存数量详情
详情
GoodsController.java
@RequestMapping(value="/to_list")
public String list(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user) {
    model.addAttribute("user", user);
    List goodsList = goodsService.listGoodsVo();
    model.addAttribute("goodsList", goodsList);
    return render(request, response, model, "goods_list", GoodsKey.getGoodsList, "");
}

GoodsDao.java

@Select("select g.*,mg.stock_count, mg.start_date, mg.end_date,mg.miaosha_price from miaosha_goods mg left join goods g on mg.goods_id = g.id")
public List listGoodsVo();

3.3 商品详情页

goods_detail.html




...



秒杀商品详情
您还没有登录,请登陆后再操作
没有收货地址的提示。。。
商品名称
商品图片
秒杀开始时间 秒杀倒计时: 秒杀进行中 秒杀已结束
商品原价
秒杀价
库存数量
GoodsController.java
@RequestMapping(value="/detail/{goodsId}")
@ResponseBody
public Result detail(HttpServletRequest request, HttpServletResponse response, Model model,MiaoshaUser user,
    		@PathVariable("goodsId")long goodsId) {
    	GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        //秒杀的开始时间
    	long startAt = goods.getStartDate().getTime();
        //秒杀的结束时间
    	long endAt = goods.getEndDate().getTime();
    	long now = System.currentTimeMillis();
    	int miaoshaStatus = 0;
    	int remainSeconds = 0;
    	if(now < startAt ) {//秒杀还没开始,倒计时
    		miaoshaStatus = 0;
    		remainSeconds = (int)((startAt - now )/1000);
    	}else  if(now > endAt){//秒杀已经结束
    		miaoshaStatus = 2;
    		remainSeconds = -1;
    	}else {//秒杀进行中
    		miaoshaStatus = 1;
    		remainSeconds = 0;
    	}
    	GoodsDetailVo vo = new GoodsDetailVo();
    	vo.setGoods(goods);
    	vo.setUser(user);
    	vo.setRemainSeconds(remainSeconds);
    	vo.setMiaoshaStatus(miaoshaStatus);
    	return Result.success(vo);
    }

GoodsDao.java

@Select("select g.*,mg.stock_count, mg.start_date, mg.end_date,mg.miaosha_price from miaosha_goods mg left join goods g on mg.goods_id = g.id where g.id = #{goodsId}")
public GoodsVo getGoodsVoByGoodsId(@Param("goodsId")long goodsId);

3.4 秒杀功能实现

goods_detail.html(提交要秒杀的商品的id)

MiaoshaController.java
//判断库存
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);//10个商品,req1 req2
int stock = goods.getStockCount();
if(stock <= 0) {
    return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
    return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//减库存 下订单 写入秒杀订单
OrderInfo orderInfo = miaoshaService.miaosha(user, goods);
return Result.success(orderInfo);

3.5 订单详情页

order_detail.htm




    


秒杀订单详情
商品名称
商品图片
订单价格
下单时间
订单状态
收货人 XXX 18812341234
收货地址 北京市昌平区回龙观龙博一区
OrderController.java
 @RequestMapping("/detail")
 @ResponseBody
 public Result info(Model model,MiaoshaUser user,
    		@RequestParam("orderId") long orderId) {
    	if(user == null) {
    		return Result.error(CodeMsg.SESSION_ERROR);
    	}
    	OrderInfo order = orderService.getOrderById(orderId);
    	if(order == null) {
    		return Result.error(CodeMsg.ORDER_NOT_EXIST);
    	}
    	long goodsId = order.getGoodsId();
    	GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    	OrderDetailVo vo = new OrderDetailVo();
    	vo.setOrder(order);
    	vo.setGoods(goods);
    	return Result.success(vo);
}

 

你可能感兴趣的:(Concurrent,Programming)