您的位置:首页 > 数据库 > Redis

Springboot+mybatis+redis实现java秒杀系统

2020-01-15 10:59 1231 查看

Springboot+mybatis+redis实现java秒杀系统

1.效果图


项目结构

├── pom.xml  -- 项目依赖
└── src
├── main
│   ├── java
│   │   └── cn
│   │       └── tycoding
│   │           ├── SpringbootSeckillApplication.java  -- SpringBoot启动器
│   │           ├── controller  -- MVC的web层
│   │           ├── dto  -- 统一封装的一些结果属性,和entity类似
│   │           ├── entity  -- 实体类
│   │           ├── enums  -- 手动定义的字典枚举参数
│   │           ├── exception  -- 统一的异常结果
│   │           ├── mapper  -- Mybatis-Mapper层映射接口,或称为DAO层
│   │           ├── redis  -- redis,jedis 相关配置
│   │           └── service  -- 业务层
│   └── resources
│       ├── application.yml  -- SpringBoot核心配置
│       ├── mapper  -- Mybatis-Mapper层XML映射文件
│       ├── static  -- 存放页面静态资源,可通过浏览器直接访问
│       │   ├── css
│       │   ├── js
│       │   └── lib
│       └── templates  -- 存放Thymeleaf模板引擎所需的HTML,不能在浏览器直接访问
│           ├── page
│           └── public  -- HTML页面公共组件(头部、尾部)
└── test  -- 测试文件

Pom依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>1.3.2</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<!-- alibaba的druid数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.9</version>
</dependency>

<!-- redis客户端 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>

实体类和数据库

Seckill类

/**
* 秒杀商品表
*/
@Data
@ToString
public class Seckill implements Serializable {

private long seckillId; //商品ID
private String title; //商品标题
private String image; //商品图片
private BigDecimal price; //商品原价格
private BigDecimal costPrice; //商品秒杀价格

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime; //创建时间

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date startTime; //秒杀开始时间

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date endTime; //秒杀结束时间

private long stockCount; //剩余库存数量
}

SeckillOrder

/**
* 秒杀订单表(秒杀订单表和其他订单表不同,属于独立的模块)
*/
@Data
@ToString
public class SeckillOrder implements Serializable {

private long seckillId; //秒杀到的商品ID
private BigDecimal money; //支付金额

private long userPhone; //秒杀用户的手机号

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime; //创建时间

private boolean status; //订单状态, -1:无效 0:成功 1:已付款

private Seckill seckill; //秒杀商品,和订单是一对多的关系
}

表设计

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

DROP TABLE IF EXISTS `seckill`;
CREATE TABLE `seckill`  (
`seckill_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`title` varchar(1000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品标题',
`image` varchar(1000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品图片',
`price` decimal(10, 2) NULL DEFAULT NULL COMMENT '商品原价格',
`cost_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '商品秒杀价格',
`stock_count` bigint(20) NULL DEFAULT NULL COMMENT '剩余库存数量',
`start_time` timestamp(0) NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒杀开始时间',
`end_time` timestamp(0) NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒杀结束时间',
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`seckill_id`) USING BTREE,
INDEX `idx_start_time`(`start_time`) USING BTREE,
INDEX `idx_end_time`(`end_time`) USING BTREE,
INDEX `idx_create_time`(`end_time`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '秒杀商品表' ROW_FORMAT = Dynamic;

INSERT INTO `seckill` VALUES (1, 'Apple/苹果 iPhone 6s Plus 国行原装苹果6sp 5.5寸全网通4G手机', 'https://g-search3.alicdn.com/img/bao/uploaded/i4/i3/2249262840/O1CN011WqlHkrSuPEiHxd_!!2249262840.jpg_230x230.jpg', 2600.00, 1100.00,1, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');
INSERT INTO `seckill` VALUES (2, 'ins新款连帽毛领棉袄宽松棉衣女冬外套学生棉服', 'https://gw.alicdn.com/bao/uploaded/i3/2007932029/TB1vdlyaVzqK1RjSZFzXXXjrpXa_!!0-item_pic.jpg_180x180xz.jpg', 200.00, 150.00, 10, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');
INSERT INTO `seckill` VALUES (3, '可爱超萌兔子毛绒玩具垂耳兔公仔布娃娃睡觉抱女孩玩偶大号女生 ', 'https://g-search3.alicdn.com/img/bao/uploaded/i4/i2/3828650009/TB22CvKkeOSBuNjy0FdXXbDnVXa_!!3828650009.jpg_230x230.jpg', 160.00, 130.00, 20, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');

DROP TABLE IF EXISTS `seckill_order`;
CREATE TABLE `seckill_order`  (
`seckill_id` bigint(20) NOT NULL COMMENT '秒杀商品ID',
`money` decimal(10, 2) NULL DEFAULT NULL COMMENT '支付金额',
`user_phone` bigint(20) NOT NULL COMMENT '用户手机号',
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`state` tinyint(4) NOT NULL DEFAULT -1 COMMENT '状态:-1无效 0成功 1已付款',
PRIMARY KEY (`seckill_id`, `user_phone`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '秒杀订单表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;

Dao层

主要涉及两个操作:1.减库存(秒杀商品表);2.记录购买明细(订单表)。
SeckillMapper.java

@Mapper
@Repository
public interface SeckillMapper {

/**
* 查询所有秒杀商品的记录信息
*/
List<Seckill> findAll();

/**
* 根据主键查询当前秒杀商品的数据
* @param id
*/
Seckill findById(long id);

/**
* 减库存。
* 对于Mapper映射接口方法中存在多个参数的要加@Param()注解标识字段名称,不然Mybatis不能识别出来哪个字段相互对应
* @param seckillId 秒杀商品ID
* @param killTime  秒杀时间
* @return 返回此SQL更新的记录数,如果>=1表示更新成功
*/
int reduceStock(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);
}

SeckillOrderMapper.java

@Mapper
@Repository
public interface SeckillOrderMapper {

/**
* 插入购买订单明细
* @param seckillId 秒杀到的商品ID
* @param money     秒杀的金额
* @param userPhone 秒杀的用户
* @return 返回该SQL更新的记录数,如果>=1则更新成功
*/
int insertOrder(@Param("seckillId") long seckillId, @Param("money") BigDecimal money, @Param("userPhone") long userPhone);

/**
* 根据秒杀商品ID查询订单明细数据并得到对应秒杀商品的数据,因为我们再SeckillOrder中已经定义了一个Seckill的属性
* @param seckillId
* @param userPhone
* @return
*/
SeckillOrder findById(@Param("seckillId") long seckillId, @Param("userPhone") long userPhone);
}

XML映射sql语句

SeckillMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.sbw.mapper.SeckillMapper">

<select id="findAll" resultType="Seckill">
SELECT * FROM seckill
</select>

<select id="findById" resultType="Seckill">
SELECT * FROM seckill WHERE seckill_id = #{id}
</select>

<update id="reduceStock">
UPDATE seckill
SET stock_count = stock_count - 1
WHERE seckill_id = #{seckillId}
AND start_time &lt;= #{killTime}
AND end_time &gt;= #{killTime}
AND stock_count &gt; 0
</update>

</mapper>

SeckillOrderMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="cn.sbw.mapper.SeckillOrderMapper">

<!-- 插入订单明细。如果主键冲突会直接报错,可使用`ignore`实现主键冲突就直接返回0,表示更新失败 -->
<insert id="insertOrder">
INSERT ignore INTO seckill_order(seckill_id, money, user_phone)
VALUES (#{seckillId}, #{money}, #{userPhone})
</insert>

<!-- 查询订单明细表并得到对应秒杀商品的数据
通过`s.xxx "seckill.xxx"`的方式实现将查询到的seckill表中的数据赋值给seckillOrder实体类中的seckill属性上
-->
<select id="findById" resultType="SeckillOrder">
SELECT
so.seckill_id,
so.user_phone,
so.money,
so.create_time,
so.state,
s.seckill_id "seckill.seckill_id",
s.title "seckill.title",
s.cost_price "seckill.cost_price",
s.create_time "seckill.create_time",
s.start_time "seckill.start_time",
s.end_time "seckill.end_time",
s.stock_count "seckill.stock_count"
FROM seckill_order so
INNER JOIN seckill s ON so.seckill_id = s.seckill_id
WHERE so.seckill_id = #{seckillId} AND so.user_phone = #{userPhone}
</select>
</mapper>

Service

接口:

public interface SeckillService {

/**
* 获取所有的秒杀商品列表
*
* @return
*/
List<Seckill> findAll();

/**
* 获取某一条商品秒杀信息
*
* @param seckillId
* @return
*/
Seckill findById(long seckillId);

/**
* 秒杀开始时输出暴露秒杀的地址
* 否者输出系统时间和秒杀时间
*
* @param seckillId
*/
Exposer exportSeckillUrl(long seckillId);

/**
* 执行秒杀的操作
*
* @param seckillId
* @param userPhone
* @param money
* @param md5
*/
SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException;
}

service实现:

@Service
public class SeckillServiceImpl implements SeckillService {

private Logger logger = LoggerFactory.getLogger(getClass());

//设置盐值字符串,随便定义,用于混淆MD5值
private final String salt = "sjajaspu-i-2jrfm;sd";
//设置秒杀redis缓存的key
private final String key = "seckill";

@Autowired
private SeckillMapper seckillMapper;

@Autowired
private SeckillOrderMapper seckillOrderMapper;

@Autowired
private RedisTemplate redisTemplate;

@Override
public List<Seckill> findAll() {
List<Seckill> seckillList = redisTemplate.boundHashOps("seckill").values();
if (seckillList == null || seckillList.size() == 0) {
//说明缓存中没有秒杀列表数据
//查询数据库中秒杀列表数据,并将列表数据循环放入redis缓存中
seckillList = seckillMapper.findAll();
for (Seckill seckill : seckillList) {
//将秒杀列表数据依次放入redis缓存中,key:秒杀表的ID值;value:秒杀商品数据
redisTemplate.boundHashOps(key).put(seckill.getSeckillId(), seckill);
logger.info("findAll -> 从数据库中读取放入缓存中");
}
} else {
logger.info("findAll -> 从缓存中读取");
}
return seckillList;
}

@Override
public Seckill findById(long seckillId) {
return seckillMapper.findById(seckillId);
}

@Override
public Exposer exportSeckillUrl(long seckillId) {
Seckill seckill = (Seckill) redisTemplate.boundHashOps(key).get(seckillId);
if (seckill == null) {
//说明redis缓存中没有此key对应的value
//查询数据库,并将数据放入缓存中
seckill = seckillMapper.findById(seckillId);
if (seckill == null) {
//说明没有查询到
return new Exposer(false, seckillId);
} else {
//查询到了,存入redis缓存中。 key:秒杀表的ID值; value:秒杀表数据
redisTemplate.boundHashOps(key).put(seckill.getSeckillId(), seckill);
logger.info("RedisTemplate -> 从数据库中读取并放入缓存中");
}
} else {
logger.info("RedisTemplate -> 从缓存中读取");
}
Date startTime = seckill.getStartTime();
Date endTime = seckill.getEndTime();
//获取系统时间
Date nowTime = new Date();
if (nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()) {
return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime());
}
//转换特定字符串的过程,不可逆的算法
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}

//生成MD5值
private String getMD5(Long seckillId) {
String base = seckillId + "/" + salt;
String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
return md5;
}

/**
* 使用注解式事务方法的有优点:开发团队达成了一致约定,明确标注事务方法的编程风格
* 使用事务控制需要注意:
* 1.保证事务方法的执行时间尽可能短,不要穿插其他网络操作PRC/HTTP请求(可以将这些请求剥离出来)
* 2.不是所有的方法都需要事务控制,如只有一条修改的操作、只读操作等是不需要进行事务控制的
* <p>
* Spring默认只对运行期异常进行事务的回滚操作,对于编译异常Spring是不进行回滚的,所以对于需要进行事务控制的方法尽可能将可能抛出的异常都转换成运行期异常
*/
@Override
@Transactional
public SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
throw new SeckillException("seckill data rewrite");
}
//执行秒杀逻辑:1.减库存;2.储存秒杀订单
Date nowTime = new Date();

try {
//记录秒杀订单信息
int insertCount = seckillOrderMapper.insertOrder(seckillId, money, userPhone);
//唯一性:seckillId,userPhone,保证一个用户只能秒杀一件商品
if (insertCount <= 0) {
//重复秒杀
throw new RepeatKillException("seckill repeated");
} else {
//减库存
int updateCount = seckillMapper.reduceStock(seckillId, nowTime);
if (updateCount <= 0) {
//没有更新记录,秒杀结束
throw new SeckillCloseException("seckill is closed");
} else {
//秒杀成功
SeckillOrder seckillOrder = seckillOrderMapper.findById(seckillId, userPhone);

//更新缓存(更新库存数量)
Seckill seckill = (Seckill) redisTemplate.boundHashOps(key).get(seckillId);
seckill.setStockCount(seckill.getStockCount() - 1);
redisTemplate.boundHashOps(key).put(seckillId, seckill);

return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, seckillOrder);
}
}
} catch (SeckillCloseException e) {
throw e;
} catch (RepeatKillException e) {
throw e;
} catch (Exception e) {
logger.error(e.getMessage(), e);
//所有编译期异常,转换为运行期异常
throw new SeckillException("seckill inner error:" + e.getMessage());
}
}
}

1.为什么要单独创建一个方法来获取秒杀地址?
在之前我们做的后端项目中,跳转到某个详情页一般都是:根据ID查询该详情数据,然后将页面跳转到详情页并将数据直接渲染到页面上。但是秒杀系统不同,它也不能就这样简单的定义,要知道秒杀技术的难点就是如何应对高并发?同一件商品,比如瞬间有十万的用户访问,而还存在各种黄牛,有各种工具去抢购这个商品,那么此时肯定不止10万的访问量的,并且开发者要尽量的保证每个用户抢购的公平性,也就是不能让一个用户抢购一堆数量的此商品。
这就是我们常说的接口防刷问题。因此单独定义一个获取秒杀接口的方法是有必要的。

2.如何做到接口防刷?
接口方法:Exposer exportSeckillUrl(long seckillId);从参数列表中很易明白:就是根据该商品的ID获取到这个商品的秒杀url地址;但是返回值类型Exposer是什么呢?
思考一下如何做到接口防刷?
首先要保证该商品处于秒杀状态。也就是1.秒杀开始时间要<当前时间;2.秒杀截止时间要>当前时间。
要保证一个用户只能抢购到一件该商品,应做到商品秒杀接口对应同一用户只能有唯一的一个URL秒杀地址,不同用户间秒杀地址应是不同的,且配合订单表seckill_order中联合主键的配置实现。
针对上面的两条分析,我们给出Exposer的设计(要注意此类定义在/dto/路径下表明此类是我们手动封装的结果属性,它类似JavaBean但又不属于,仅用来封装秒杀状态的结果,目的是提高代码的重用率)

异常处理类:

public enum SeckillStatEnum {
SUCCESS(1, "秒杀成功"),
END(0, "秒杀结束"),
REPEAT_KILL(-1, "重复秒杀"),
INNER_ERROR(-2, "系统异常"),
DATA_REWRITE(-3, "数据串改");

private int state;
private String stateInfo;

SeckillStatEnum(int state, String stateInfo) {
this.state = state;
this.stateInfo = stateInfo;
}

public int getState() {
return state;
}

public String getStateInfo() {
return stateInfo;
}

public static SeckillStatEnum stateOf(int index) {
for (SeckillStatEnum state : values()) {
if (state.getState() == index) {
return state;
}
}
return null;
}
}

Controller层

/**
* 秒杀商品的控制层
*/
@Controller
@RequestMapping("/seckill")
public class SeckillController {

@Autowired
private SeckillService seckillService;

private final Logger logger = LoggerFactory.getLogger(getClass());

@RequestMapping("/list")
public String findSeckillList(Model model) {
List<Seckill> list = seckillService.findAll();
model.addAttribute("list", list);
return "page/seckill";
}

@ResponseBody
@RequestMapping("/findById")
public Seckill findById(@RequestParam("id") Long id) {
return seckillService.findById(id);
}

@RequestMapping("/{seckillId}/detail")
public String detail(@PathVariable("seckillId") Long seckillId, Model model) {
if (seckillId == null) {
return "page/seckill";
}
Seckill seckill = seckillService.findById(seckillId);
model.addAttribute("seckill", seckill);
if (seckill == null) {
return "page/seckill";
}
return "page/seckill_detail";
}

@ResponseBody
@RequestMapping(value = "/{seckillId}/exposer",
method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId) {
SeckillResult<Exposer> result;
try {
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
result = new SeckillResult<Exposer>(true, exposer);
} catch (Exception e) {
logger.error(e.getMessage(), e);
result = new SeckillResult<Exposer>(false, e.getMessage());
}
return result;
}

@RequestMapping(value = "/{seckillId}/{md5}/execution",
method = RequestMethod.POST,
produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId,
@PathVariable("md5") String md5,
@RequestParam("money") BigDecimal money,
@CookieValue(value = "killPhone", required = false) Long userPhone) {
if (userPhone == null) {
return new SeckillResult<SeckillExecution>(false, "未注册");
}
try {
SeckillExecution execution = seckillService.executeSeckill(seckillId, money, userPhone, md5);
return new SeckillResult<SeckillExecution>(true, execution);
} catch (RepeatKillException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
} catch (SeckillCloseException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.END);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
} catch (SeckillException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
}
}

@ResponseBody
@GetMapping(value = "/time/now")
public SeckillResult<Long> time() {
Date now = new Date();
return new SeckillResult(true, now.getTime());
}
}

其余代码可以参考:秒杀系统

  • 点赞
  • 收藏
  • 分享
  • 文章举报
会打碟的程序员 发布了7 篇原创文章 · 获赞 6 · 访问量 915 私信 关注
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐