秒杀项目总结

秒杀项目总结

redis部分

1. 搭建Redis服务器

网上有很多相关资料,搭建一个redis集群一般而言都会搭建3主3从。这里只搭建了一个单机版。

Redis 集群搭建详细指南

2. 基于JedisPool实现一个自己的Redis操作类

选择实现一个自己的Redis操作类主要是基于以下考虑,在团队里面写代码操作的都是一个公共的redis服务器,如果大家都在里面随便取变量的名字很有可能会造成混淆,所以需要一个Redis操作类能够给真实的key加上每个模块特有的前缀。这样能将各自所需要使用的变量分割开来。

具体做法:

  • 定义一个RedisService类,将JedisPool注入进来,在里面基于JedisPool写我们自己的实现。需要从jedisPool工厂方法中生产出一个jedis对象,使用完毕后再关闭连接。在get和put的时候,因为redis中存的是字符串,所以需要实现一对字符串和对象的转换函数辅助操作。

    @Service
    public class RedisService {
    @Autowired
    private JedisPool jedisPool;
    
    /**
     * get方法
     * @param key
     * @param clazz
     * @param <T>
     * @return
     */
    public <T> T get(KeyPrefix prefix, String key, Class<T> clazz){
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            String realKey = prefix.getPrefix() + key;
            String value = jedis.get(realKey);
            T t = stringToBean(value,clazz);
            return t;
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    public <T> boolean set(KeyPrefix prefix, String key, T value){
        Jedis jedis = null;
        try{
            jedis = jedisPool.getResource();
            String val = beanToString(value);
            if(val == null || val.length() == 0)
                return false;
            String realKey = prefix.getPrefix() + key;
            jedis.set(realKey,val);
            return true;
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    public <T> boolean setNXEX(final KeyPrefix prefix, final String key, final T req) {
        if(req == null){
            return false;
        }
        int expireSeconds = prefix.expireSeconds();
        if(expireSeconds <= 0) {
            throw new RuntimeException("[SET EX NX]必须设置超时时间");
        }
        String realKey = prefix.getPrefix() + key;
        String value = beanToString(req);
        Jedis jc = null;
        try {
            jc = jedisPool.getResource();
            String ret =  jc.set(realKey, value, "nx", "ex", expireSeconds);
            return "OK".equals(ret);
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        } finally {
            closeJedisPool(jc);
        }
    }
    
    /**
     * 判断key是否存在
     * */
    public <T> boolean exists(KeyPrefix prefix, String key) {
        Jedis jedis = null;
        try {
            jedis =  jedisPool.getResource();
            //生成真正的key
            String realKey  = prefix.getPrefix() + key;
            return  jedis.exists(realKey);
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    /**
     * 删除
     * */
    public boolean delete(KeyPrefix prefix, String key) {
        Jedis jedis = null;
        try {
            jedis =  jedisPool.getResource();
            //生成真正的key
            String realKey  = prefix.getPrefix() + key;
            long ret =  jedis.del(realKey);
            return ret > 0;
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    /**
     * 增加值
     * */
    public <T> Long incr(KeyPrefix prefix, String key) {
        Jedis jedis = null;
        try {
            jedis =  jedisPool.getResource();
            //生成真正的key
            String realKey  = prefix.getPrefix() + key;
            return  jedis.incr(realKey);
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    /**
     * 减少值
     * */
    public <T> Long decr(KeyPrefix prefix, String key) {
        Jedis jedis = null;
        try {
            jedis =  jedisPool.getResource();
            //生成真正的key
            String realKey  = prefix.getPrefix() + key;
            return  jedis.decr(realKey);
        }finally {
            closeJedisPool(jedis);
        }
    }
    
    /**
     * 删除所有key
     * @param prefix
     * @return
     */
    public boolean delete(KeyPrefix prefix) {
        if(prefix == null) {
            return false;
        }
        List<String> keys = scanKeys(prefix.getPrefix());
        if(keys==null || keys.size() <= 0) {
            return true;
        }
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            jedis.del(keys.toArray(new String[0]));
            return true;
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        } finally {
            if(jedis != null) {
                jedis.close();
            }
        }
    }
    
    /**
     * 扫描key
     * @param key
     * @return
     */
    public List<String> scanKeys(String key) {
        Jedis jedis = null;
        try {
            jedis = jedisPool.getResource();
            List<String> keys = new ArrayList<String>();
            String cursor = "0";
            ScanParams sp = new ScanParams();
            sp.match("*"+key+"*");
            sp.count(100);
            do{
                ScanResult<String> ret = jedis.scan(cursor, sp);
                List<String> result = ret.getResult();
                if(result!=null && result.size() > 0){
                    keys.addAll(result);
                }
                //再处理cursor
                cursor = ret.getStringCursor();
            }while(!cursor.equals("0"));
            return keys;
        } finally {
            if (jedis != null) {
                jedis.close();
            }
        }
    }
    
    /**
     * 关闭连接池
     * @param jedis
     */
    public void closeJedisPool(Jedis jedis){
        if(jedis != null){
            jedis.close();
        }
    }
    
    /**
     * 字符串转对象方法
     * @param val
     * @return
     */
    public static <T> T stringToBean(String val, Class<T> clazz) {
        if(val == null || val.length() == 0 || clazz == null)
            return null;
        if(clazz == int.class || clazz == Integer.class){
            return (T)Integer.valueOf(val);
        }else if(clazz == String.class){
            return (T)val;
        }else if(clazz == long.class || clazz == Long.class){
            return (T)Long.valueOf(val);
        }else{
            return JSONObject.toJavaObject(JSON.parseObject(val),clazz);
        }
    }
    
    /**
     * 对象转字符串方法
     * @param value
     * @return
     */
    public static <T> String beanToString(T value){
        if(value == null)
            return null;
        Class<?> clazz = value.getClass();
        if(clazz == int.class){
            return "" + value;
        }else if(clazz == String.class){
            return (String)value;
        }else if(clazz == long.class){
            return "" + value;
        }else{
            return JSONObject.toJSONString(value);
        }
    }
    

    }

  • 定义一个前缀类,每次操作的时候将这个前缀类作为参数传进去,其他操作类需要继承这个前缀类并定义自己的前缀和过期时间。

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){
        this(0,prefix);
    }

    public BasePrefix(int expireSeconds, String prefix){
        this.expireSeconds = expireSeconds;
        this.prefix = prefix;
    }

    @Override
    public int expireSeconds() {
        //0代表永不过期
        return expireSeconds;
    }

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

实现类
public class GoodsKey extends BasePrefix{

public GoodsKey(int expireSeconds,String prefix){
    super(expireSeconds,prefix);
}

public static GoodsKey getGoodsList = new GoodsKey(60,"gl");
public static GoodsKey getGoodsDetail = new GoodsKey(60,"gt");
public static GoodsKey getMiaoshaGoodsStock = new GoodsKey(0,"gs");

}


@ControllerAdvice + @ExceptionHandler 全局处理 Controller 层异常

在Controller层进行数据校验的过程中,经常会有和数据库不一致或者参数校验错误引起的异常,如果每一处异常我们都用try-catch语块去捕获的话会带来许多冗余代码,影响代码的美观度。通过自定义全局异常+消息体格式的方式将全局的异常都管理起来。

1. ControllerAdvice注解定义全局异常处理类

@ControllerAdvice
@ResponseBody
public class GlobalExceptionHandler {

    /**
     * ControllerAdvice定义Controller层的异常处理方法,凡是Controller层有异常需要抛出的地方,
     * 都会在这儿处理
     * @param request
     * @param e
     * @return
     */
    @ExceptionHandler(value=Exception.class)
    public Result<String> exceptionHandler(HttpServletRequest request, Exception e){
        e.printStackTrace();
        //如果类型是GlobalException,就做如下处理
        if(e instanceof GlobalException) {
            GlobalException ex = (GlobalException)e;
            return Result.error(ex.getCm());
        }else if(e instanceof BindException) {  //如果是BindException就做如下处理
            BindException ex = (BindException)e;
            List<ObjectError> 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. 定义GlobalException类

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;
    }
}

3. 处理Service上的异常

在Controller层有一个登录模块,登录过程需要从数据库校验数据。

Controller

@RequestMapping("/do_login")
@ResponseBody
public Result<String> doLogin(HttpServletResponse response, @Valid LoginVo loginVo) {
    log.info(loginVo.toString());
    String token = miaoshaUserService.login(response, loginVo);
    return Result.success(token);
}

这里对于Service层传回来的逻辑没有做任何的判断,原因在于我们已经声明了异常处理类去处理返回来的异常。

Service

    public String login(HttpServletResponse response, LoginVo loginVo){
    //如果登录信息为空,抛出一个自定义的服务端异常
    if(null == loginVo){
        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);
    }
    String token = UUIDUtil.generateUUID();
    addCookie(response,token,user);
    return token;
}
代码解析

在这里我们用直接抛出异常的方式对校验情况构造了我们自己定义的异常,而且在GlobalException异常类中传入了消息错误描述。
@ControllerAdvice,是Spring3.2提供的新注解,从名字上可以看出大体意思是控制器通知,和AOP有关。


数据库设计

一个秒杀系统至少会有两个步骤:减库存和下订单,要完成这两个步骤我们至少需要设计三个表:商品表、用户表、订单表。但是如果在一个真实的电商系统中开展一次秒杀活动,直接使用原有的表可能不仅无法满足我们的需求还会对现有的业务造成一定的影响,我们还需要设计专门存储秒杀信息的表,所以这里设计了五张表:用户表、商品表、秒杀商品表、订单表、秒杀订单表。

用户表
用户表

商品表
商品表

秒杀商品表
秒杀商品表

订单表
订单表

秒杀订单表
秒杀订单表

为什么要建立商品表和秒杀商品表

答: 秒杀商品的数量和价格与商品表不同,如果不建立需要在原来的商品表增设新的字段,不仅会影响原有模块的代码,还会增加表的冗余。

为什么设计秒杀订单表

答: 原因同上述一样,避免表的冗余性。


功能设计

秒杀

其实秒杀系统的逻辑并不复杂,后台接收到一个秒杀请求后就执行减库存和下订单的逻辑,直到库存为0,标志着秒杀结束,但是整个过程中会遇到很多问题,比如超卖问题、Redis和DB的一致性问题。


秒杀系统如何做性能优化

为了对抗高并发,一个秒杀系统被层层优化。

秒杀系统优化策略

面对高并发大流量冲击,秒杀系统能否支撑的住还需要做一系列优化。总的思路是让响应结果在越前面的位置返回越好,减少请求。然后从前端向底层进行优化。

页面缓存

前端请求传到Controller,通常会经过渲染然后将页面返回到前端。如果页面中的内容不经常更新就可以将页面静态化,将渲染后的页面保存起来并放入缓存,下次请求的时候直接从缓存中取出页面然后返回即可。

对象缓存

在前端和数据库之间加一层redis缓存,内存的数据请求速度是非常快的,如果将数据放到缓存下次请求就可以直接从内存中取这条数据而不需要再访问数据库。但是要注意数据库和缓存的数据一致性,例如更新数据的时候一定要先更新数据库再更新缓存,否则会造成数据库和缓存的数据不一致。

页面静态化

秒杀页面访问量大,如果每个人进来都需要动态的从后台去请求数据那么势必带来大量的并发冲垮页面,而秒杀的商品一般不会有信息上的改动,所以我们通过将页面静态化可以极大的提高我们的速度。

静态资源优化(合并js和css请求)

通过Nginx配置将静态资源合并传输

redis预减库存

系统启动时就将商品库存加载到Redis中,这样在请求过来时就可以通过Redis判断商品是否还有剩余,如果没有剩余就直接返回秒杀失败,有剩余就进行接下来的操作,并且把缓存中的库存数目减1.

使用本地标识截断请求

这个本地标识可以在负载均衡层做也可以在内存做,前者就是在负载均衡维持一个布尔变量,标记秒杀是否结束,如果结束直接返回秒杀结束的响应结果就好,而不用继续接下来的流程。如果放在内存做,就是在代码中维持一个布尔类型的全局变量即可,用来标记秒杀是否结束。

利用消息队列异步下单

应对大流量,使用mq来做削峰是一个很常见的手段,将来不及处理的消息先入队,库存和订单模块再去mq中拉取消息来处理。