写在前面:本项目以学习为主,目的是记录学习过程。完整源代码已上传至guihub。

项目介绍:该项目模拟了高并发场景的商城系统,主要包括用户注册登录、商品列表、商品详情、秒杀功能以及安全优化模块。解决了用户安全登录问题,用户重复下单、超卖超买、限流防刷问题。(使用redis缓存,并使用页面静态化技术将静态资源缓存在CDN中,降低服务器的压力,加快了用户的访问速度。还使用异步消息队列机制对系统的交易性能进行了优化。在秒杀接口使用计数器来防止脚本对秒杀接口的不断刷新,增强了服务器的稳定性。最后还使用验证码技术不仅起到削峰的作用,还能防止恶意刷访问。)

一、分布式Session

该模块主要是实现基本的登陆注册功能,以及如何实现分布式Session,如何进行参数校验等。

1、用户登录 

通过两次MD5加密来保证用户安全,第一次实在前端进行一次加密;第二次是在后端接收到之后再次加密,与数据库中存放的密码进行校验来实现登录功能。

首先添加依赖

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

<!--    Springboot依赖-->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.5</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.example</groupId>
    <artifactId>seckill_shop</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>seckill_shop</name>
    <description>seckill_shop</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
<!--        thymeleaf组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
<!--        web组件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
<!--        mysql驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!--mybatis-plus 依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.0</version>
        </dependency>
<!--        lombook-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
<!--        test-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- md5 依赖 -->
        <dependency>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

编写MD5工具类进行MD5加密

package com.example.seckill_shop.utils;

import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.stereotype.Component;

@Component
public class MD5Util {

    public static String md5(String src){
        return DigestUtils.md5Hex(src);
    }

    private static final String salt = "1a2b3c4d";

    public static String inputPassToFromPass(String inputPass){
        String str = "" + salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }

    public static String fromPassToDBPass(String fromPass,String salt){
        String str = "" + salt.charAt(0) + salt.charAt(2) + fromPass + salt.charAt(5) + salt.charAt(4);
        return md5(str);
    }

    public static String inputPassToDBPass(String inputPass,String salt){
        String fromPass = inputPassToFromPass(inputPass);
        String dbPass = fromPassToDBPass(fromPass,salt);
        return dbPass;
    }

    public static void main(String[] args){
        //        d3b1294a61a07da9b49b6e22b2cbd7f9
        System.out.println(inputPassToFromPass("123456"));
//        b7797cce01b4b131b433b6acf4add449
        System.out.println(inputPassToDBPass("123456",salt));
    }

}

在Controller层中实现页面跳转到登录页

package com.example.seckill_shop.controller;

import com.example.seckill_shop.service.UserService;
import com.example.seckill_shop.vo.LoginVo;
import com.example.seckill_shop.vo.RespBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;

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

    @Autowired
    private UserService userService;

    //跳转登陆页面
    @RequestMapping("/toLogin")
    public String toLogin(){
        return "login";
    }

    @RequestMapping("/doLogin")
    @ResponseBody
    public RespBean doLogin(@Valid LoginVo loginVo, HttpServletRequest request, HttpServletResponse response){
        return userService.doLogin(loginVo,request,response);
    }

}

 在Service层中UserService接口的实现类中编写登录逻辑。写一个LoginVo类来接收前端ajax传来的用户输入的账号和经过一次MD5加密后的密码。如果密码输入正确则进入Mapper层(Dao层),在数据库中查询到User对象。

package com.example.seckill_shop.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.seckill_shop.exception.GlobalException;
import com.example.seckill_shop.pojo.User;
import com.example.seckill_shop.mapper.UserMapper;
import com.example.seckill_shop.service.UserService;
import com.example.seckill_shop.utils.CookieUtil;
import com.example.seckill_shop.utils.MD5Util;
import com.example.seckill_shop.utils.UUIDUtil;
import com.example.seckill_shop.vo.LoginVo;
import com.example.seckill_shop.vo.RespBean;
import com.example.seckill_shop.vo.RespBeanEnum;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author tyz
 * @since 2022-10-28
 */
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public RespBean doLogin(LoginVo loginVo) {
        String mobile = loginVo.getMobile();
        String password = loginVo.getPassword();
        if(StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password)){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if(!ValidatorUtil.isMobile(mobile)){
            return RespBean.error(RespBeanEnum.MOBILE_ERROR);
        }
        User user = userMapper.selectById(mobile);
        if(user == null){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        if(!MD5Util.fromPassToDBPass(password,user.getSalt()).equals(user.getPassword())){
            return RespBean.error(RespBeanEnum.LOGIN_ERROR);
        }
        return RespBean.success();
    }

}

 编写状态码枚举类

package com.example.seckill_shop.vo;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.ToString;

@ToString
@AllArgsConstructor
@Getter
public enum RespBeanEnum {

    //通用
    SUCCESS(200,"登陆成功"),
    ERROR(500,"服务端异常"),
    //登录
    LOGIN_ERROR(530,"用户名或密码错误"),
    MOBILE_ERROR(531,"手机号码格式不正确"),
    BIND_ERROR(543,"参数校验异常");

    private final Integer code;
    private final String message;

}
package com.example.seckill_shop.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class RespBean {

    private long code;
    private String message;
    private Object obj;

    public static RespBean success(){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),null);
    }

    public static RespBean success(Object obj){
        return new RespBean(RespBeanEnum.SUCCESS.getCode(),RespBeanEnum.SUCCESS.getMessage(),obj);
    }

    public static RespBean error(RespBeanEnum respBeanEnum){
        return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),null);
    }

    public static RespBean error(RespBeanEnum respBeanEnum,Object obj){
        return new RespBean(respBeanEnum.getCode(),respBeanEnum.getMessage(),obj);
    }

}

2、分布式Session

涉及的问题:当Nginx分发给多个tomcat时,如果用户只在其中一个tomcat登陆后,后续如果留言页面会会检验用户是否登录,在页面跳转后,可能会分配给另一个tomcat,而此tomcat中无用户登录的信息。

解决方法:后端集中存储,在pom.xml中引入redis依赖。

将用户的登录信息统一存储在redis中。编写redis配置类

package com.example.seckill_shop.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
 * 配置redis序列化与反序列化
 *
 * @author: tyz
 * @date 2022/3/8 5:24 下午
 * @ClassName: RedisConfig
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
        //key序列化
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        //value序列化
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        //hash类型key序列化
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        //hash类型value序列化
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());

        //注入连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }

    @Bean
    public DefaultRedisScript<Long> script(){
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        //lock.lua脚本位置和application.yml同级目录
        redisScript.setLocation(new ClassPathResource("stock.lua"));
        redisScript.setResultType(Long.class);
        return redisScript;
    }

}

 (因为redis中只能存储二进制字节码文件,所以需要进行序列化,后续读取redis中数据,需要反序列化。)

在用户第一次登录后,通过UUID工具类生成cookie后,以cookie作为key,user对象作为value,传进redis中,并设置失效时间。在后续用户页面跳转中,校验用户是否登录,只需要在redis中查询是否存在cookie值即可。

二、功能开发

1、商品页面

package com.example.seckill_shop.controller;

import com.example.seckill_shop.pojo.User;
import com.example.seckill_shop.service.GoodsService;
import com.example.seckill_shop.vo.DetailVo;
import com.example.seckill_shop.vo.GoodsVo;
import com.example.seckill_shop.vo.RespBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.thymeleaf.context.WebContext;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.concurrent.TimeUnit;

/**
 * 商品控制层 主要负责提供商品List,以及每件商品的详情信息
 *
 * @author: tyz
 * @date 2022/3/8 5:24 下午
 * @ClassName: GoodsController
 */
@Controller
@RequestMapping("goods")
public class GoodsController {

    @Autowired
    private GoodsService goodsService;
    @Autowired
    private RedisTemplate redisTemplate;
    @Autowired
    private ThymeleafViewResolver thymeleafViewResolver;

    /**
     * 跳转到商品页面
     * 压测数据2286.6/sec
     */
    @RequestMapping(value = "/toList",produces = "text/html;character=utf8",method = RequestMethod.GET)
    @ResponseBody
    public String toList(Model model, HttpServletRequest request, HttpServletResponse response, User user){
        if(user == null){
            return "login";
        }
        //序列化
        ValueOperations valueOperations = redisTemplate.opsForValue();
        String html = (String) valueOperations.get("goodsList");
        if(!StringUtils.isEmpty(html)){
            return html;
        }
        model.addAttribute("user",user);
        model.addAttribute("goodsList",goodsService.findGoodsVo());
        //如果为空,手动渲染,存入redis中
        WebContext context = new WebContext(request,response,request.getServletContext(),request.getLocale(),model.asMap());
        html = thymeleafViewResolver.getTemplateEngine().process("goodsList",context);
        if(!StringUtils.isEmpty(html)){
            valueOperations.set("goodsList",html,60, TimeUnit.SECONDS);
        }
        return html;
    }

    //跳转商品详情页
    @RequestMapping(value = "/detail/{goodsId}",method = RequestMethod.GET)
    @ResponseBody
    public RespBean toDetail(User user, @PathVariable Long goodsId){
        GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
        Date startDate = goodsVo.getStartDate();
        Date endDate = goodsVo.getEndDate();
        Date nowDate = new Date();
        //秒杀状态
        int seckillStatus = 0;
        //秒杀倒计时
        int remainSeconds = 0;
        //秒杀还未开始
        if(nowDate.before(startDate)){
            remainSeconds = ((int) ((startDate.getTime() - nowDate.getTime()) / 1000));
        } else if (nowDate.after(endDate)) {
            //秒杀结束
            seckillStatus = 2;
            remainSeconds = -1;
        } else {
            seckillStatus = 1;
            remainSeconds = 0;
        }
        DetailVo detailVo = new DetailVo();
        detailVo.setUser(user);
        detailVo.setGoodsVo(goodsVo);
        detailVo.setSeckillStatus(seckillStatus);
        detailVo.setRemainSeconds(remainSeconds);
        return RespBean.success(detailVo);
    }

}

通过model.addAttribute将数据传给前端。

2、设计秒杀

1、在Controller层的toDetail中,通过GoodsService层中findGoodsVoByGoodsId方法在mapper层中映射查询到商品信息,获取秒杀商品的开始时间、结束时间用来判断是否开启秒杀。

2、秒杀倒计时:通过model传给前端,在前端设计定时器,来计算。

3、实现秒杀,需要做两个判断:库存是否足够:获取数据库中商品的库存做判断。

                                                     用户重复下单:SeckillOrderService判断每个用户只能买一件商品(通过继承MyBatis-plus的IService接口的getOne方法,若该数据库已有,该userId与goodsId,则认为用户已购买过该商品,返回失败;若数据库没有,则下单成功)。