重点:缓存+异步+分布式+优雅的代码
基于Spring Boot搭建项目
Spring MVC配置繁多,而Spring Boot只有一个配置文件application.properties
参考:《Spring Boot 入门教程 》
参考文档
@Transactional @Mapper @Select
参考:《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 客户端连接
安全起见,添加一个访问密码
重启一下再访问就要输密码了
cd utils ./install_server.sh 安装成系统服务
chkconfig –-list | grep redis 查看是否开机启动
添加Jedis依赖
添加Fastjson依赖:负责将Java对象转换成Json字符串,写入Redis服务器
不同功能模块使用不同的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;
}
数据库连接池负责分配,管理和释放连接,实际上是个生产者消费者模式,生产者是连接创建线程和连接回收线程,消费者是获取连接的线程。
Druid是阿里巴巴的一个数据库连接池开源框架。当应用启动时,连接池初始化最小连接数MIN,当外部请求到达时,直接使用空闲连接即可。并发数达到最大MAX,则需要等待,直到超时。如果一直没拿到连接,就会抛出异常。
MIN过小,会出现过多请求排队等待获取连接
MIN过大,会造成资源浪费
MAX过小,峰值情况下仍有很多请求在等待状态
MAX过大,导致数据库连接被占满,大量请求超时,引发服务器雪崩
若数据库配置的MAX是100,一个请求10ms,则最大能够处理10000QPS。(1s=1000ms=100*10ms)
jQuery Validate
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;
salt存放数据库随机生成的salt
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;
}
}
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);
}
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) {
...
}
}
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 extends Payload>[] 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);
}
}
}
}
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);
}
}
}
假设一个项目部署在一个服务器集群中,而负载均衡服务SLB基于服务器集群之上,为客户端的请求分配服务器。同一个用户发送的不同请求是会落在不同的服务器上,那么不同服务器怎样识别同一个用户的session?
方案一:进行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();
}
扩充秒杀商品表,而不直接在商品表中添加字段标识,后期有各种活动时,各种修改商品表,不便于扩展。
goods
seckill_goods
order_info
seckill_order
哪个用户秒杀了哪个商品。
数据表中的id很少用自带的自增,很容易被人从1开始遍历。一般使用snowflake算法。
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();
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);
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);
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);
}