本文参考自
本文是仿12306项目实战第(二)章——项目实现 的第六篇,本篇将讲解该项目的高级部分,如何升级前后端技术将项目做成高性能高并发的抢票平台
本章目录
一、高性能余票查询的实现(前端缓存&本地缓存&分布式缓存)
1.常见的缓存有哪些
这里的Mysql应该是Mybatis,课程ppt写错
2.详解Mybatis的一级缓存
一般建议不使用Mybatis的一级缓存和二级缓存,因为都有可能造成数据不一致的问题
Mybatis一级缓存:
MyBatis 一级缓存指的是在同一个 SqlSession 中执行相同的查询时,查询的结果会被缓存在内存中。当后续再次执行相同的查询时,MyBatis 会先从缓存中查找结果,如果找到则直接返回缓存中的数据,而不需要再次去数据库查询。
一级缓存的生效条件是在同一个 SqlSession 中执行相同的查询。因为一级缓存是基于 SqlSession 的,所以只有在相同的 SqlSession 中才能生效。另外,一级缓存的默认开启状态下,如果希望在同一个 SqlSession 中禁用一级缓存,可以在执行查询前使用
sqlSession.clearCache()
方法来清空缓存。当同一个 SqlSession 的数据发生变化时(CUD操作),一级缓存会失效,需要重新查询数据库获取最新数据。
由于 SqlSession 的线程不安全性质,当多个线程共享同一个 SqlSession 时,可能会引发并发访问的问题,导致数据的不一致性。例如,如果一个线程正在执行查询操作并将结果放入了一级缓存中,此时另一个线程也执行了相同的查询操作,它会直接从一级缓存中获取数据,如果第一个线程对数据进行了修改,那么第二个线程获取到的数据就不是最新的,从而导致数据不一致。
当mybatis和spring整合后
- 在未开启事务的情况之下,每次查询,spring都会关闭旧的sqlSession而创建新的sqlSession,因此此时的一级缓存是没有起作用的
- 在开启事务的情况之下,spring使用threadLocal获取当前线程绑定的同一个sqlSession,因此此时一级缓存是有效的,当事务执行完毕,会关闭sqlSession
「当mybatis和spring整合后,未开启事务的情况下,不会有任何问题,因为一级缓存没有生效。当开启事务的情况下,可能会有问题,由于一级缓存的存在,在事务内的查询隔离级别是可重复读,即使你数据库的隔离级别设置的是提交读」
-
实现举例
加事务
com.neilxu.train.business.service.TrainService
@Transactional public List<TrainQueryResp> queryAll() { List<Train> trainList = selectAll(); LOG.info("再查一次"); trainList = selectAll(); return BeanUtil.copyToList(trainList, TrainQueryResp.class); }
此时由于同个事务里(同个sqlsession),mybatis一级缓存生效,第二次查询不会再走sql
-
如何关闭(默认是开启的)
business/src/main/resources/application.properties
# 配置为statement,即关闭一级缓存 mybatis.configuration.local-cache-scope=statement
此时虽然还是加了事务,但是第二次会再查询数据库;
当不关闭一级缓存,但是在第二次查询之前进行了CUD操作或者手动清除sqlsession的缓存,也会清空一级缓存,从而导致重新再查数据库
3.详解Mybatis的二级缓存
MyBatis 提供了二级缓存来提高数据库查询的性能。
二级缓存是在同一个 namespace(即同一个 mapper 接口或者映射文件)下的多个 SqlSession 之间共享的缓存,可以减少数据库访问次数,提升系统性能。
二级缓存的缺点: 不是线程安全的,可能会造成缓存不一致的问题,所以一般不使用mybatis二级缓存
-
默认是关闭的,如何开启
-
Train.java
实现序列化
public class Train implements Serializable
-
TrainMapper.xml
加上
<mapper namespace="com.neilxu.train.business.mapper.TrainMapper"> <cache></cache>
-
测试
com.neilxu.train.business.service.TrainService
@Transactional public List<TrainQueryResp> queryAll() { List<Train> trainList = selectAll(); // LOG.info("删除动作"); // trainMapper.deleteByPrimaryKey(1111111111L); LOG.info("再查一次"); trainList = selectAll(); return BeanUtil.copyToList(trainList, TrainQueryResp.class); }
此时我先关闭一级缓存,然后现在执行两次这个queryAll方法,如果没有二级缓存,应该会是查询4次数据库
最终结果:
会查询2次,如下图
第一次:同个sqlsession里关了一级缓存并不会找二级缓存
-
第二次:不同sqlsession,(整个过程的)第三次和第四次都走了二级缓存
若是中间加上CUD操作或者手动清空缓存(不在同个sqlsession的CUD也可以),也会导致二级缓存失效,重新查询数据库
`如果我再开启一级缓存,中间不执行CUD操作或手动清除缓存`
此时访问接口
第一次:
第一次查询未命中二级缓存,然后查询数据库,第二次查询也没命中二级缓存,但是由于一级缓存,不会查询数据库
第二次:
都走了二级缓存
4.详解SpringBoot内置缓存
一般大型项目也不会用SpringBoot内置缓存,因为和MyBatis缓存一样不适用于多节点场景,可能造成数据不一致问题
-
引入依赖
common pom文件
<!--spring内置缓存--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>
-
开启缓存
以admin查询余票 /business/admin/daily-train-ticket/query-list 接口举例
BusinessApplication.java
@EnableCaching public class BusinessApplication {
-
DailyTrainTicketQueryReq.java
重写equals和hashCode方法,目的是使得多次查询只要查询条件对象的值相等就算两次查询相同,会访问缓存;
而且注意重写需要把父类PageReq的属性也写进来
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof DailyTrainTicketQueryReq that)) return false; return Objects.equals(date, that.date) && Objects.equals(trainCode, that.trainCode) && Objects.equals(start, that.start) && Objects.equals(end, that.end) && Objects.equals(((DailyTrainTicketQueryReq) o).getPage(), that.getPage()) && Objects.equals(((DailyTrainTicketQueryReq) o).getSize(), that.getSize()); } @Override public int hashCode() { return Objects.hash(date, trainCode, start, end, getPage(), getSize()); }
-
DailyTrainTicketService.java
@Cacheable:访问进来只要有缓存就先访问缓存;
@CachePut:访问进来一定会走数据库,然后把缓存存进去(强制刷新缓存)
两者可以结合使用实现
@CachePut(value = "DailyTrainTicketService.queryList") public PageResp<DailyTrainTicketQueryResp> queryList2(DailyTrainTicketQueryReq req) { return queryList(req); } @Cacheable(value = "DailyTrainTicketService.queryList") public PageResp<DailyTrainTicketQueryResp> queryList(DailyTrainTicketQueryReq req) {
-
DailyTrainTicketAdminController.java
@GetMapping("/query-list2") public CommonResp<PageResp<DailyTrainTicketQueryResp>> queryList2(@Valid DailyTrainTicketQueryReq req) { PageResp<DailyTrainTicketQueryResp> list = dailyTrainTicketService.queryList2(req); return new CommonResp<>(list); }
-
测试
http/business-daily-train-ticket.http
GET http://localhost:8000/business/admin/daily-train-ticket/query-list?page=1&size=5&trainCode=D2&start=广州南&end=潮汕 Content-Type: application/json ### GET http://localhost:8000/business/admin/daily-train-ticket/query-list2?page=1&size=5&trainCode=D2&start=广州南&end=潮汕 Content-Type: application/json ###
执行第一个接口的第2次:
命中缓存,结果是3条
此时从数据库删掉一条,再访问第一个接口,还会读取刚刚的缓存,即是3条
需要调用第二个方法,强制刷新缓存(并返回2条),之后再访问第一个接口,返回也是2条了
5.SpringBoot整合redis缓存
-
首先安装redis
-
引入依赖
需要的服务再单独引入依赖,防止由于redis宕机而全部都启动失败
business/pom.xml
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
配置redis
business/src/main/resources/application.properties
默认不用密码,db选db0
spring.data.redis.host=localhost spring.data.redis.port=6379
-
RedisController.java
package com.neilxu.train.business.controller; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.TimeUnit; @RestController public class RedisController { private static final Logger LOG = LoggerFactory.getLogger(RedisController.class); @Resource private RedisTemplate redisTemplate; @RequestMapping("/redis/set/{key}/{value}") public String set(@PathVariable String key, @PathVariable String value) { redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS); LOG.info("key: {}, value: {}", key, value); return "success"; } @RequestMapping("/redis/get/{key}") public Object get(@PathVariable String key) { Object object = redisTemplate.opsForValue().get(key); LOG.info("key: {}, value: {}", key, object); return object; } }
-
com.neilxu.train.gateway.config.LoginMemberFilter
// 排除不需要拦截的请求 if (path.contains("/admin") || path.contains("/redis") || path.contains("/hello") || path.contains("/member/member/login") || path.contains("/member/member/send-code")) {
-
测试
http/business-redis.http
GET http://localhost:8000/business/redis/set/123/test1 Accept: application/json ### GET http://localhost:8000/business/redis/get/123 Accept: application/json ###
集成redis成功
-
将SpringBoot缓存放到redis中
将刚刚的springboot内置缓存改成用redis存储
解决:
1.提高访问速度,mysql单机QPS约为2000,redis约10万;
2.解决多节点共享缓存,机器重启也不会丢失缓存数据
-
business/src/main/resources/application.properties
spring.cache.type=redis spring.cache.redis.use-key-prefix=true spring.cache.redis.key-prefix=train_cache_ spring.cache.redis.cache-null-values=false spring.cache.redis.time-to-live=60s
-
DailyTrainTicketQueryResp.java
序列化
public class DailyTrainTicketQueryResp implements Serializable {
-
测试
访问刚刚的余票查询
GET http://localhost:8000/business/admin/daily-train-ticket/query-list?page=1&size=10&trainCode=D2&start=广州南&end=潮汕 Content-Type: application/json ###
此时会存储缓存到redis,60s内访问接口都会从redis中拿,redis中情况如下图
key是
train_cache_DailyTrainTicketService.queryList::DailyTrainTicketQueryReq(date=null, trainCode=D2, start=广州南, end=潮汕)
value是
序列化后的DailyTrainTicketQueryResp对象
“序列化”是一种****把对象的状态转化成字节流的机制****,“反序列”是其相反的过程,把序列化成的字节流用来在内存中重新创建一个实际的Java对象。这个机制被用来“持久化”对象。通过对象序列化,可以方便的实现对象的持久化储存以及在网络上的传输。
redis 序列化存储对象 比 存储JSON字符串 更快
-
6.详解缓存击穿、缓存穿透、缓存雪崩与解决方案
缓存击穿、缓存雪崩和缓存穿透都是与缓存相关的常见问题,它们可能会影响系统的性能和稳定性。以下是它们的简要解释:
- 缓存击穿(Cache Miss)
- 发生时间:当某个热门的缓存 key 在缓存失效的瞬间,此时有大量请求同时访问该 key,导致数据库负载激增,缓存无法发挥作用。
- 解决方案:使用互斥锁、热点数据永不过期、预热缓存等方法避免缓存击穿。
- 缓存雪崩(Cache Avalanche)
- 发生时间:当缓存中大量 key 同时失效,或缓存服务器宕机,导致大量请求直接访问数据库,造成数据库负载激增。
- 解决方案:设置不同的过期时间、使用多级缓存、缓存预热、高可用备份等方法避免缓存雪崩。
- 缓存穿透(Cache Penetration)
- 发生时间:恶意请求或者查询不存在的数据(即缓存和数据库中都没有的数据),导致大量请求直接访问数据库,对系统造成影响。
- 解决方案:使用布隆过滤器拦截无效请求、缓存空对象、对请求参数进行校验等方法避免缓存穿透。
为了避免这些问题的发生,需要在系统设计和缓存策略上进行合理的规划和调整,以确保缓存系统的高性能和稳定性。
--------------------来自ChatGPT的回答
结合本课程项目怎么解决?
1.怎么解决缓存击穿
-
定时刷新缓存(热点数据永不过期)
-
加分布式锁
缓存访问不到,已经走进数据库查询的时候,因为加了锁,只有抢到锁的才继续走数据库查询,否则直接快速失败,告诉用户稍后重试
2.怎么解决缓存穿透
- 如果查出来是空集合或null,也把他缓存到redis中(缓存空对象)
- 也可以加分布式锁,降低访问频率
演示因数据库本身就没数据而造成缓存穿透
使用缓存的场景,一般情况如下图,这样的问题就是可能会造成缓存穿透(因为缓存、数据库都没有数据)
-
business/src/main/resources/application.properties
修改配置允许value为null
spring.cache.redis.cache-null-values=true
-
DailyTrainTicketService.java
/queryList3 写死返回null
@Cacheable(value = "DailyTrainTicketService.queryList3") public PageResp<DailyTrainTicketQueryResp> queryList3(DailyTrainTicketQueryReq req) { LOG.info("测试缓存击穿"); return null; } @CachePut(value = "DailyTrainTicketService.queryList") public PageResp<DailyTrainTicketQueryResp> queryList2(DailyTrainTicketQueryReq req) { return queryList(req); } @Cacheable(value = "DailyTrainTicketService.queryList") public PageResp<DailyTrainTicketQueryResp> queryList(DailyTrainTicketQueryReq req) { // 去缓存里取数据,因数据库本身就没数据而造成缓存穿透 // if (有数据) { null [] // return // } else { // 去数据库取数据 // } DailyTrainTicketExample dailyTrainTicketExample = new DailyTrainTicketExample(); dailyTrainTicketExample.setOrderByClause("id desc"); DailyTrainTicketExample.Criteria criteria = dailyTrainTicketExample.createCriteria(); if (ObjUtil.isNotNull(req.getDate())) { criteria.andDateEqualTo(req.getDate()); } if (ObjUtil.isNotEmpty(req.getTrainCode())) { criteria.andTrainCodeEqualTo(req.getTrainCode()); } if (ObjUtil.isNotEmpty(req.getStart())) { criteria.andStartEqualTo(req.getStart()); } if (ObjUtil.isNotEmpty(req.getEnd())) { criteria.andEndEqualTo(req.getEnd()); } LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<DailyTrainTicket> dailyTrainTicketList = dailyTrainTicketMapper.selectByExample(dailyTrainTicketExample); PageInfo<DailyTrainTicket> pageInfo = new PageInfo<>(dailyTrainTicketList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<DailyTrainTicketQueryResp> list = BeanUtil.copyToList(dailyTrainTicketList, DailyTrainTicketQueryResp.class); PageResp<DailyTrainTicketQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; }
-
DailyTrainTicketAdminController.java
@GetMapping("/query-list3") public CommonResp<PageResp<DailyTrainTicketQueryResp>> queryList3(@Valid DailyTrainTicketQueryReq req) { PageResp<DailyTrainTicketQueryResp> list = dailyTrainTicketService.queryList3(req); return new CommonResp<>(list); }
-
http/business-daily-train-ticket.http
GET http://localhost:8000/business/admin/daily-train-ticket/query-list3?page=1&size=10&trainCode=D2&start=广州南&end=潮汕 Content-Type: application/json ###
-
测试结果
当访问返回是空对象或者空集合的时候,也都会存储到redis中,下次访问还会命中缓存
不过如果不断使用不同的非法的参数去访问,除了缓存空对象,还需要通过校验参数,拦截请求的方式去防止缓存穿透
3.怎么解决缓存雪崩
此时分布式锁不管用,因为锁是针对单个key的,缓存雪崩是多个key的场景
- 定时对热点车站刷新缓存(预热)
- 接口加限流
Redis 中有多种过期删除策略,用于处理到期过期时间(TTL)的键。以下是 Redis 中常见的过期删除策略:
- 定期删除(Eviction by Redis 定时程序,每个100ms检查):
- 定期在设置了过期时间的键空间执行删除策略。
- Redis 默认每秒检查10次,当超过一定阈值(默认5ms)的过期键时进行删除操作。
- 惰性删除(Lazy Expire):
- 当客户端请求访问某个键时,Redis 会先检查该键是否过期,如果过期则删除。
- 这种策略可能导致键过期后仍然占据内存空间一段时间,直到被访问时才会被删除。
- 定时过期(Expired by Background Thread):
- Redis 5.0 引入了新的主动过期策略,通过
ACTIVE_EXPIRE_CYCLE
定时任务来主动删除过期的键。- 定时过期策略可以更加高效地处理大量过期键,避免了惰性删除带来的一些缺陷。
Redis 使用内存淘汰(Eviction)策略来管理内存空间,以便在内存使用达到上限时决定淘汰哪些键来腾出空间。以下是 Redis 中常见的内存淘汰策略:
- LRU(Least Recently Used):最近最少使用
- 根据键的最近使用时间来淘汰最久未被访问的键,保留最近使用频繁的键。
- Redis 中的近似 LRU 实现是通过近似算法来判断哪些键最可能被淘汰,而非严格的 LRU 算法。
- LFU(Least Frequently Used):最不经常使用
- 根据键的访问频率来淘汰访问频率最低的键,保留访问频率高的键。
- Redis 中的 LFU 算法是一个近似实现,会逐渐调整键的访问频率并进行淘汰。
- Random(随机淘汰):
- 通过随机选择键来进行淘汰,这种方式无法保证淘汰的公平性,也不适合生产环境。
- TTL(Time To Live):根据键的过期时间进行淘汰
- 在设置了过期时间(TTL)的键中选择即将到期的键进行淘汰,优先淘汰剩余时间最短的键。
- FIFO(First In, First Out) : 先进先出
7.缓存在高并发场景中的生产问题分享
课程里讲解的情况是说本地缓存过多导致,新生代多且由于1分钟有效,不断转到老年代,老年代满了导致fullgc,从而短时间内大量请求失败的问题。
针对这种,同一个请求(单线程)里面多次查询同一个信息的情况,可以使用线程本地变量代替本地缓存,防止上述的问题。
这里想到了我实际工作中用过的ConcurrentHashMap实现本地缓存:【key:用户id,value:针对这个用户的数据】
经过查询资料,前面讲过的springboot本地缓存原理也是ConcurrentHashMap
Java学习之ConcurrentHashMap实现一个本地缓存_concurrenthashmap为什么可以做缓存-CSDN博客
8.前端缓存的使用
针对车次、车站这种不经常改动的数据,前端可以做缓存,缓解后端压力,课程演示了使用session-storage来缓存车次
-
admin/public/js/session-storage.js
SESSION_ALL_TRAIN = "SESSION_ALL_TRAIN"; SessionStorage = { get: function (key) { var v = sessionStorage.getItem(key); if (v && typeof(v) !== "undefined" && v !== "undefined") { return JSON.parse(v); } }, set: function (key, data) { //JSON.stringify() 是 JavaScript 中一个用于将 JavaScript 对象或值转换为 JSON 字符串的方法。 sessionStorage.setItem(key, JSON.stringify(data)); }, remove: function (key) { sessionStorage.removeItem(key); }, clearAll: function () { sessionStorage.clear(); } };
-
admin/src/components/train-select.vue
/** * 查询所有的车次,用于车次下拉框 */ const queryAllTrain = () => { let list = SessionStorage.get(SESSION_ALL_TRAIN); if (Tool.isNotEmpty(list)) { console.log("queryAllTrain 读取缓存"); trains.value = list; } else { axios.get("/business/admin/train/query-all").then((response) => { let data = response.data; if (data.success) { trains.value = data.content; console.log("queryAllTrain 保存缓存"); SessionStorage.set(SESSION_ALL_TRAIN, trains.value); } else { notification.error({description: data.message}); } }); } };
-
效果
清除浏览器后台缓存通常会包括清除浏览器存储的各种数据,其中包括
localStorage
和sessionStorage
两种 Web Storage。localStorage
:localStorage
是一种持久性存储,数据会一直保留在浏览器中,除非手动删除或者通过清除浏览器缓存来移除。清除浏览器缓存通常也会清除localStorage
中的数据,这意味着用户的本地持久性数据会被清除。sessionStorage
: 相比之下,sessionStorage
是一种会话性存储,数据只在当前会话(浏览器标签页或窗口)中有效,关闭标签页或窗口后数据就会被清除。清除浏览器缓存一般不会直接清除sessionStorage
中的数据,因为这些数据与会话相关,关闭浏览器后自动清除。
总体来说,清除浏览器后台缓存操作通常会清除
localStorage
中的数据,但不会影响sessionStorage
。如果您想要确保清除sessionStorage
中的数据,可以在代码中手动清空这部分数据,或者等待会话结束后自动清除。 ----------------来自ChatGPT的回答
已经会读取缓存
我手动清除会话存储(选中删除,如果是一般的清除浏览器缓存清除不掉,只会清除本地缓存)再刷新页面,就会保存缓存
二、集成分布式事务组件Seata
1.初识Seata和分布式事务
Seata 是一个开源的分布式事务解决方案,用于解决分布式系统中的事务一致性问题。Seata 提供了高性能和高可用性的分布式事务服务,能够确保在分布式环境下的一致性和可靠性,适用于各种微服务架构和分布式系统场景。
下面是 Seata 的主要功能和特点:
- 分布式事务管理:Seata 提供了对分布式事务的管理和协调能力,支持各种分布式事务模型,包括支持基于 SQL 等资源的本地事务、支持 TCC(Try-Confirm-Cancel)模式、支持基于消息的补偿型分布式事务。
- 全局事务一致性:Seata 通过事务协调器(Transaction Coordinator)实现全局事务的一阶段提交和二阶段提交,确保各个分支事务的一致性,从而保障全局事务的一致性。
- 高性能和高可用性:Seata 被设计为高性能和高可用的分布式事务解决方案,具有较低的性能开销和快速的事务处理能力,同时支持多种集群部署方式,保证系统的可靠性和稳定性。
- 易于集成:Seata 提供了对各种主流框架和中间件的集成支持,可以与 Spring Cloud、Dubbo、gRPC 等常见的分布式框架进行集成,方便开发者快速在项目中引入分布式事务解决方案。
- 开源社区支持:Seata 是一个开源项目,拥有活跃的开发社区和稳定的社区支持,持续不断地更新和完善功能,以满足不同场景下的分布式事务需求。
总的来说,Seata 是一个功能强大、易于集成、性能高效的开源分布式事务解决方案,为分布式系统提供了可靠的事务支持,帮助开发团队解决分布式事务一致性和可靠性的难题。
--------------------------------来自ChatGPT的回答
2.讲解Seata分布式事务的原理
**问:**一个方法里,有远程调用别的服务的接口,在这个方法上加@Transactional,对远程调用的接口的方法会生效吗?
**ChatGPT答案:**在一个方法里,你既有本地数据库操作,又有远程调用别的服务的接口,并且在这个方法上加上
@Transactional
注解。此时,你希望知道对于远程调用的接口的方法会不会受到事务的影响。对于这种情况,答案是取决于你的具体实现。如果你的远程调用使用的是基于 HTTP 或者 RPC 的远程调用方式,那么简单地在本地方法上加上
@Transactional
注解是不会影响到远程调用的接口的事务行为的。原因在于,@Transactional
注解是基于线程本地的事务代理来实现的,对于另一个服务的方法调用并不会被代理拦截,因此不会受到本地事务管理的影响。如果你想让远程调用的接口的方法也参与到本地事务管理中,你需要借助分布式事务管理框架,确保远程调用的接口也能够参与到整个事务中。这需要使用特定的分布式事务管理框架来实现,比如 Seata 等。这些框架可以确保跨服务的事务一致性,让远程调用的接口的方法也能够在整个事务范围内参与到事务管理中。
虽说多个应用(服务+数据库)目前都是部署单个节点,但是也需要用到seata这样的组件来实现多服务的整体事务
官方举例:下单,减去余额,扣减库存
此时多个服务对应多个数据库,之间使用rpc远程调用,这种场景就需要使用分布式事务框架,否则你需要另外写rpc接口去实现失败回滚的逻辑(但是也有可能会继续失败)
- Seata分布式事务的四种模块
本课程使用AT模式,实现最为简单,且效果同原来的本地事务效果一致
3.Seata分布式事务初体验
-
安装服务端
服务端、客户端依赖都使用1.6.1,与springCloud版本对应
安装地址:
Seata-Server版本历史 | Apache Seata
安装好后直接解压运行
-
对分布式事务涉及到的数据库,增加undo_log表,供seata使用
这里注意之前数据库表里有一些字段名是关键字都需要改名字,因为seata自动生成的反向sql不会主动给关键字加反引号
-
新增表
business和member都执行
CREATE TABLE `undo_log` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `branch_id` bigint(20) NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int(11) NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, `ext` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
-
-
新增依赖
加在common
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId> </dependency>
版本不需要加,父pom的springcloud alibaba已经将所有springcloud alibaba组件自动管理版本了
-
修改配置文件
business和member都加
注意端口客户端配置是服务端7091+1000,所以是8091
# 事务组名称 seata.tx-service-group=train-group # 事务组和seata集群做关联 seata.service.vgroup-mapping.train-group=default # seata集群对应的机器 seata.service.grouplist.default=127.0.0.1:8091
-
AfterConfirmOrderService.java
package com.neilxu.train.business.service; import com.neilxu.train.business.domain.ConfirmOrder; import com.neilxu.train.business.domain.DailyTrainSeat; import com.neilxu.train.business.domain.DailyTrainTicket; import com.neilxu.train.business.enums.ConfirmOrderStatusEnum; import com.neilxu.train.business.feign.MemberFeign; import com.neilxu.train.business.mapper.ConfirmOrderMapper; import com.neilxu.train.business.mapper.DailyTrainSeatMapper; import com.neilxu.train.business.mapper.cust.DailyTrainTicketMapperCust; import com.neilxu.train.business.req.ConfirmOrderTicketReq; import com.neilxu.train.common.context.LoginMemberContext; import com.neilxu.train.common.req.MemberTicketReq; import com.neilxu.train.common.resp.CommonResp; import io.seata.core.context.RootContext; import io.seata.spring.annotation.GlobalTransactional; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.Date; import java.util.List; @Service public class AfterConfirmOrderService { private static final Logger LOG = LoggerFactory.getLogger(AfterConfirmOrderService.class); @Resource private DailyTrainSeatMapper dailyTrainSeatMapper; @Resource private DailyTrainTicketMapperCust dailyTrainTicketMapperCust; @Resource private MemberFeign memberFeign; @Resource private ConfirmOrderMapper confirmOrderMapper; /** * 选中座位后事务处理: * 座位表修改售卖情况sell; * 余票详情表修改余票; * 为会员增加购票记录 * 更新确认订单为成功 */ // @Transactional @GlobalTransactional public void afterDoConfirm(DailyTrainTicket dailyTrainTicket, List<DailyTrainSeat> finalSeatList, List<ConfirmOrderTicketReq> tickets, ConfirmOrder confirmOrder) throws Exception { LOG.info("seata全局事务ID: {}", RootContext.getXID()); for (int j = 0; j < finalSeatList.size(); j++) { DailyTrainSeat dailyTrainSeat = finalSeatList.get(j); DailyTrainSeat seatForUpdate = new DailyTrainSeat(); seatForUpdate.setId(dailyTrainSeat.getId()); seatForUpdate.setSell(dailyTrainSeat.getSell()); seatForUpdate.setUpdateTime(new Date()); dailyTrainSeatMapper.updateByPrimaryKeySelective(seatForUpdate); // 计算这个站卖出去后,影响了哪些站的余票库存 // 参照2-3节 如何保证不超卖、不少卖,还要能承受极高的并发 10:30左右 // 影响的库存:本次选座之前没卖过票的,和本次购买的区间有交集的区间 // 假设10个站,本次买4~7站 // 原售:001000001 // 购买:000011100 // 新售:001011101 // 影响:XXX11111X // Integer startIndex = 4; // Integer endIndex = 7; // Integer minStartIndex = startIndex - 往前碰到的最后一个0; // Integer maxStartIndex = endIndex - 1; // Integer minEndIndex = startIndex + 1; // Integer maxEndIndex = endIndex + 往后碰到的最后一个0; Integer startIndex = dailyTrainTicket.getStartIndex(); Integer endIndex = dailyTrainTicket.getEndIndex(); char[] chars = seatForUpdate.getSell().toCharArray(); Integer maxStartIndex = endIndex - 1; Integer minEndIndex = startIndex + 1; Integer minStartIndex = 0; for (int i = startIndex - 1; i >= 0; i--) { char aChar = chars[i]; if (aChar == '1') { minStartIndex = i + 1; break; } } LOG.info("影响出发站区间:" + minStartIndex + "-" + maxStartIndex); Integer maxEndIndex = seatForUpdate.getSell().length(); for (int i = endIndex; i < seatForUpdate.getSell().length(); i++) { char aChar = chars[i]; if (aChar == '1') { maxEndIndex = i; break; } } LOG.info("影响到达站区间:" + minEndIndex + "-" + maxEndIndex); dailyTrainTicketMapperCust.updateCountBySell( dailyTrainSeat.getDate(), dailyTrainSeat.getTrainCode(), dailyTrainSeat.getSeatType(), minStartIndex, maxStartIndex, minEndIndex, maxEndIndex); // 调用会员服务接口,为会员增加一张车票 MemberTicketReq memberTicketReq = new MemberTicketReq(); memberTicketReq.setMemberId(LoginMemberContext.getId()); memberTicketReq.setPassengerId(tickets.get(j).getPassengerId()); memberTicketReq.setPassengerName(tickets.get(j).getPassengerName()); memberTicketReq.setTrainDate(dailyTrainTicket.getDate()); memberTicketReq.setTrainCode(dailyTrainTicket.getTrainCode()); memberTicketReq.setCarriageIndex(dailyTrainSeat.getCarriageIndex()); memberTicketReq.setSeatRow(dailyTrainSeat.getRow()); memberTicketReq.setSeatCol(dailyTrainSeat.getCol()); memberTicketReq.setStartStation(dailyTrainTicket.getStart()); memberTicketReq.setStartTime(dailyTrainTicket.getStartTime()); memberTicketReq.setEndStation(dailyTrainTicket.getEnd()); memberTicketReq.setEndTime(dailyTrainTicket.getEndTime()); memberTicketReq.setSeatType(dailyTrainSeat.getSeatType()); CommonResp<Object> commonResp = memberFeign.save(memberTicketReq); LOG.info("调用member接口,返回:{}", commonResp); // 更新订单状态为成功 ConfirmOrder confirmOrderForUpdate = new ConfirmOrder(); confirmOrderForUpdate.setId(confirmOrder.getId()); confirmOrderForUpdate.setUpdateTime(new Date()); confirmOrderForUpdate.setStatus(ConfirmOrderStatusEnum.SUCCESS.getCode()); confirmOrderMapper.updateByPrimaryKeySelective(confirmOrderForUpdate); // 模拟调用方出现异常 if (1 == 1) { throw new Exception("测试异常"); } } } }
-
BusinessExceptionEnum.java
CONFIRM_ORDER_EXCEPTION("服务器忙,请稍候重试"),
-
ConfirmOrderService.java
// 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); }
-
ControllerExceptionHandler.java
注意:
这里需要修改下全局异常处理,不然当远程调用发生异常,返回给调用方的还是状态200,以为是成功的,所以不会回滚事务
/** * 所有异常统一处理 * @param e * @return */ @ExceptionHandler(value = Exception.class) @ResponseBody public CommonResp exceptionHandler(Exception e) throws Exception { LOG.info("seata全局事务ID: {}", RootContext.getXID()); // 如果是在一次全局事务里出异常了,就不要包装返回值,将异常抛给调用方,让调用方回滚事务 if (StrUtil.isNotBlank(RootContext.getXID())) { throw e; } CommonResp commonResp = new CommonResp(); LOG.error("系统异常:", e); commonResp.setSuccess(false); commonResp.setMessage("系统出现异常,请联系管理员"); return commonResp; }
-
member/src/main/java/com/jiawa/train/member/service/TicketService.java
/** * 会员购买车票后新增保存 * * @param req */ public void save(MemberTicketReq req) throws Exception { LOG.info("seata全局事务ID save: {}", RootContext.getXID()); DateTime now = DateTime.now(); Ticket ticket = BeanUtil.copyProperties(req, Ticket.class); ticket.setId(SnowUtil.getSnowflakeNextId()); ticket.setCreateTime(now); ticket.setUpdateTime(now); ticketMapper.insert(ticket); // 模拟被调用方出现异常 // if (1 == 1) { // throw new Exception("测试异常11"); // } }
-
测试
会员下单购票,提示失败
看日志,已经发生了全局事务回滚
4.Seata Server配置Nacos
找到conf下的application.yml配置文件
按如下配置:
分别配置 config 配置中心 | registry 注册中心
seata:
config:
# support: nacos, consul, apollo, zk, etcd3
# type: file
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: train
group: SEATA_GROUP
username: nacos
password: nacos
context-path:
data-id: seataServer.properties
registry:
# support: nacos, eureka, redis, zk, consul, etcd3, sofa
# type: file
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
namespace: train
cluster: default
username: nacos
password: nacos
重启Seata Server
打开nacos控台,已经可以看到seata服务
接着再新增配置文件
store.mode=db
store.db.dbType=mysql
store.db.datasource=druid
# store.db.driver-class-name=com.mysql.cj.jdbc.Driver
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?characterEncoding=UTF8&autoReconnect=true&serverTimezone=Asia/Shanghai
store.db.user=seata
store.db.password=seata
这里注意需要先新建数据库,新建用户,操作同之前
数据库要运行sql脚本,文件位置如下
重启Seata Server
注意:这里发生报错,需要改下配置文件,将store.db.driver-class-name
改成store.db.driverClassName
,见上面
启动成功,也可以看到连nacos配置中心的日志了
5.Seata Client配置Nacos
-
修改客户端配置文件
business和member:
# seata注册中心 seata.registry.type=nacos seata.registry.nacos.application=seata-server seata.registry.nacos.server-addr=127.0.0.1:8848 seata.registry.nacos.group=SEATA_GROUP seata.registry.nacos.namespace=train seata.registry.nacos.username=nacos seata.registry.nacos.password=nacos # seata配置中心 seata.config.type=nacos seata.config.nacos.server-addr=127.0.0.1:8848 seata.config.nacos.group=SEATA_GROUP seata.config.nacos.namespace=train seata.config.nacos.dataId=seataServer.properties seata.config.nacos.username=nacos seata.config.nacos.password=nacos # 事务组名称,必须在nacos中有配置过:service.vgroupMapping.train-group=default seata.tx-service-group=train-group # 事务组和seata集群做关联 #seata.service.vgroup-mapping.test-group=default # seata集群对应的机器 #seata.service.grouplist.default=127.0.0.1:8091 ################################################ # 以下是nacos中的seataServer.properties的相关配置 ################################################ # # 和微服务模块的seata.tx-service-group保持一致 # service.vgroupMapping.train-group=default # service.default.grouplist=127.0.0.1:8091 # # # 和微服务模块的seata.tx-service-group保持一致 # service.vgroupMapping.test-group=default1 # service.default1.grouplist=127.0.0.1:18091 ################################################
-
修改server端配置文件
nacos配置文件
这里18091的集群并不存在,只是做演示,而且客户端配置的事务组名称为train-group,所以找的也是8091
store.mode=db store.db.dbType=mysql store.db.datasource=druid store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?characterEncoding=UTF8&autoReconnect=true&serverTimezone=Asia/Shanghai store.db.user=seata store.db.password=seata # 和微服务模块的seata.tx-service-group保持一致 service.vgroupMapping.train-group=default service.default.grouplist=127.0.0.1:8091 # 和微服务模块的seata.tx-service-group保持一致 service.vgroupMapping.test-group=default1 service.default1.grouplist=127.0.0.1:18091
-
测试
操作同上,事务正常生效
6.Seata和mysql存储演示
理解即可:这里课程演示了事务执行过程中,seata数据库表存储了数据,事务执行过后再清除的过程
三、利用分布式锁解决库存超卖问题
1.JMeter初体验
-
添加线程组,添加取样器-http请求,添加配置元件-http信息头管理器
如下图所示
- 添加监听器-查看结果树
-
启动测试
10个线程,每个线程执行一次,结果如下
2.使用JMeter对购票功能进行压测&超卖演示
-
修改Jmter配置文件编码格式
解决返回结果显示中文乱码问题
-
先关闭余票接口缓存,方便测试
com.neilxu.train.business.service.DailyTrainTicketService
// @Cacheable(value = "DailyTrainTicketService.queryList") public PageResp<DailyTrainTicketQueryResp> queryList(DailyTrainTicketQueryReq req) {
-
business-confirm-order.http
POST http://localhost:8000/business/confirm-order/do Content-Type: application/json token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYmYiOjE3MTExMjAwMjUsIm1vYmlsZSI6IjEzMDAwMDAwMDAwIiwiaWQiOjE3NjcyNDE0NDYyNTMwMDY4NDgsImV4cCI6MTcxMTIwNjQyNSwiaWF0IjoxNzExMTIwMDI1fQ.F22K3evr5fc_JBPcOotI6e0zFWeueZIMR21uwcQ-rOE { "dailyTrainTicketId":1771192104060588032, "date":"2024-04-10", "end":"潮汕", "start":"广州南", "tickets": [{ "passengerId":1768293540707831808, "passengerIdCard":"11111111111111111", "passengerName":"neil测试1", "passengerType":"1", "seatTypeCode":"1" }], "trainCode":"D2" } ###
-
配置Jmeter进行压测
此时的票:
500个线程同时访问
最终结果:直接超卖,一等票数量变成-63
3.使用synchronized是否能解决库存超卖?
-
ConfirmOrderService.java
加上线程同步关键字synchronized,此时这个方法只有一个线程能走,其他只能等待
public synchronized void doConfirm(ConfirmOrderDoReq req) {
-
测试
- 单节点:结果是不会出现超卖,但是售票效率大大减小
- 多节点:效率提高了,但是仍然会有超卖问题
4.使用Redis分布式锁是否能解决库存超卖
需要注意的是以前 stringRedisTemplate.setIfAbsent() 在服务器是由2个命令组成的 完成一个setnx时候在设置 expire 时候中间中断了,无法保证原子性。 故需要使用 4 个参数的那个重载方法,这个底层是 set key value [EX seconds] [PX milliseconds] [NX|XX] 是原子性的
浅析redis setIfAbsent的用法及在分布式锁上的应用及同步锁的缺陷 - 古兰精 - 博客园 (cnblogs.com)
-
BusinessExceptionEnum.java
CONFIRM_ORDER_LOCK_FAIL("当前抢票人数多,请稍候重试"),
-
ConfirmOrderService.java
这里本来释放锁是放在finally里的,但是这样会导致没抢到锁的也走进finally把别人的锁给释放了,导致锁失效了,优化后如下
但是这样并不是最优解,正确解决方式如下:
public void doConfirm(ConfirmOrderDoReq req) { String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode(); Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey); } else { // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); } // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过 Date date = req.getDate(); String trainCode = req.getTrainCode(); String start = req.getStart(); String end = req.getEnd(); List<ConfirmOrderTicketReq> tickets = req.getTickets(); // 保存确认订单表,状态初始 DateTime now = DateTime.now(); ConfirmOrder confirmOrder = new ConfirmOrder(); confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrder.setMemberId(LoginMemberContext.getId()); confirmOrder.setDate(date); confirmOrder.setTrainCode(trainCode); confirmOrder.setStart(start); confirmOrder.setEnd(end); confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId()); confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode()); confirmOrder.setTickets(JSON.toJSONString(tickets)); confirmOrderMapper.insert(confirmOrder); // 查出余票记录,需要得到真实的库存 DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end); LOG.info("查出余票记录:{}", dailyTrainTicket); // 预扣减余票数量,并判断余票是否足够 reduceTickets(req, dailyTrainTicket); // 最终的选座结果 List<DailyTrainSeat> finalSeatList = new ArrayList<>(); // 计算相对第一个座位的偏移值 // 比如选择的是C1,D2,则偏移值是:[0,5] // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2] ConfirmOrderTicketReq ticketReq0 = tickets.get(0); if(StrUtil.isNotBlank(ticketReq0.getSeat())) { LOG.info("本次购票有选座"); // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值 List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode()); LOG.info("本次选座的座位类型包含的列:{}", colEnumList); // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2} List<String> referSeatList = new ArrayList<>(); for (int i = 1; i <= 2; i++) { for (SeatColEnum seatColEnum : colEnumList) { referSeatList.add(seatColEnum.getCode() + i); } } LOG.info("用于作参照的两排座位:{}", referSeatList); List<Integer> offsetList = new ArrayList<>(); // 绝对偏移值,即:在参照座位列表中的位置 List<Integer> aboluteOffsetList = new ArrayList<>(); for (ConfirmOrderTicketReq ticketReq : tickets) { int index = referSeatList.indexOf(ticketReq.getSeat()); aboluteOffsetList.add(index); } LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList); for (Integer index : aboluteOffsetList) { int offset = index - aboluteOffsetList.get(0); offsetList.add(offset); } LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList); getSeat(finalSeatList, date, trainCode, ticketReq0.getSeatTypeCode(), ticketReq0.getSeat().split("")[0], // 从A1得到A offsetList, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } else { LOG.info("本次购票没有选座"); for (ConfirmOrderTicketReq ticketReq : tickets) { getSeat(finalSeatList, date, trainCode, ticketReq.getSeatTypeCode(), null, null, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } } LOG.info("最终选座:{}", finalSeatList); // 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); } LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); redisTemplate.delete(lockKey); }
-
com.neilxu.train.business.service.AfterConfirmOrderService
这里课程里的seata有问题,影响了效率,按课程操作,先去除了全局事务注解
// @Transactional // @GlobalTransactional public void afterDoConfirm(DailyTrainTicket dailyTrainTicket, List<DailyTrainSeat> finalSeatList, List<ConfirmOrderTicketReq> tickets, ConfirmOrder confirmOrder) throws Exception
**注意:**这里不加注解也会有问题,高并发场景下,如果最后添加订单失败了,但是已经减了库存,这种场景本应该回滚,但是这里没事务注解,所以不会回滚,这样就可能会导致 少卖问题
少卖也有可能是高并发下的正常问题,因为一起挤进来抢票(但只点击一次抢票的话),有的人抢到了(加锁成功),有的人挤不进来(抢不到锁)就直接抢票失败【后续引入MQ对购票请求做排队处理,保证用户体验的同时保证把票卖完】
5.使用Redisson看门狗解决锁超时的问题
上节的问题:如果业务超过锁的失效时间,那提前释放了锁,也会导致超卖问题
-
解决上述问题
我们需要有一个机制,当业务还没走完,锁会自动刷新过期时间,保证走完业务再释放锁
引入看门狗——Redisson就能实现
-
加依赖
business/pom.xml
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> </dependency>
父pom
<!--至少3.18.0版本,才支持spring boot 3--> <!--升级到3.20.0,否则打包生产会报错:Could not initialize class org.redisson.spring.data.connection.RedissonConnection--> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.21.0</version> </dependency>
-
ConfirmOrderService.java
@Autowired private StringRedisTemplate redisTemplate; @Autowired private RedissonClient redissonClient;
上面注意使用@Autowired,优先类型,不然和前面的redisTemplate冲突,会报错
public void doConfirm(ConfirmOrderDoReq req) { String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode(); // Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 60, TimeUnit.SECONDS); // if (Boolean.TRUE.equals(setIfAbsent)) { // LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey); // } else { // // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 // LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); // } RLock lock = null; try { // 使用redisson,自带看门狗 lock = redissonClient.getLock(lockKey); /** waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false leaseTime – lease time 锁时长,即n秒后自动释放锁 time unit – time unit 时间单位 */ // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗 boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗 if (tryLock) { LOG.info("恭喜,抢到锁了!"); // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果 // for (int i = 0; i < 30; i++) { // Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey); // LOG.info("锁过期时间还有:{}", expire); // Thread.sleep(1000); // } } else { // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 LOG.info("很遗憾,没抢到锁"); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); } // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过 Date date = req.getDate(); String trainCode = req.getTrainCode(); String start = req.getStart(); String end = req.getEnd(); List<ConfirmOrderTicketReq> tickets = req.getTickets(); // 保存确认订单表,状态初始 DateTime now = DateTime.now(); ConfirmOrder confirmOrder = new ConfirmOrder(); confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrder.setMemberId(LoginMemberContext.getId()); confirmOrder.setDate(date); confirmOrder.setTrainCode(trainCode); confirmOrder.setStart(start); confirmOrder.setEnd(end); confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId()); confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode()); confirmOrder.setTickets(JSON.toJSONString(tickets)); confirmOrderMapper.insert(confirmOrder); // 查出余票记录,需要得到真实的库存 DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end); LOG.info("查出余票记录:{}", dailyTrainTicket); // 预扣减余票数量,并判断余票是否足够 reduceTickets(req, dailyTrainTicket); // 最终的选座结果 List<DailyTrainSeat> finalSeatList = new ArrayList<>(); // 计算相对第一个座位的偏移值 // 比如选择的是C1,D2,则偏移值是:[0,5] // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2] ConfirmOrderTicketReq ticketReq0 = tickets.get(0); if (StrUtil.isNotBlank(ticketReq0.getSeat())) { LOG.info("本次购票有选座"); // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值 List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode()); LOG.info("本次选座的座位类型包含的列:{}", colEnumList); // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2} List<String> referSeatList = new ArrayList<>(); for (int i = 1; i <= 2; i++) { for (SeatColEnum seatColEnum : colEnumList) { referSeatList.add(seatColEnum.getCode() + i); } } LOG.info("用于作参照的两排座位:{}", referSeatList); List<Integer> offsetList = new ArrayList<>(); // 绝对偏移值,即:在参照座位列表中的位置 List<Integer> aboluteOffsetList = new ArrayList<>(); for (ConfirmOrderTicketReq ticketReq : tickets) { int index = referSeatList.indexOf(ticketReq.getSeat()); aboluteOffsetList.add(index); } LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList); for (Integer index : aboluteOffsetList) { int offset = index - aboluteOffsetList.get(0); offsetList.add(offset); } LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList); getSeat(finalSeatList, date, trainCode, ticketReq0.getSeatTypeCode(), ticketReq0.getSeat().split("")[0], // 从A1得到A offsetList, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } else { LOG.info("本次购票没有选座"); for (ConfirmOrderTicketReq ticketReq : tickets) { getSeat(finalSeatList, date, trainCode, ticketReq.getSeatTypeCode(), null, null, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } } LOG.info("最终选座:{}", finalSeatList); // 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); } // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); // redisTemplate.delete(lockKey); } catch (InterruptedException e) { LOG.error("购票异常", e); } finally { LOG.info("购票流程结束,释放锁!"); if (null != lock && lock.isHeldByCurrentThread()) { lock.unlock(); } } }
注意:finally又将释放锁加上了,因为看门狗的isHeldByCurrentThread()可以判断是否是当前线程的锁再去释放
-
测试
先清空数据库的ticket表,方便测试
Jmeter,设置200个线程
启动测试,1s完成,结果如下,全部卖完不会造成超卖【如果线程数设置少一些,有可能会有少卖的现象(属于正常情况)】
6.介绍Redis红锁(了解即可)
问题:当有多台redis(非集群)的时候,线程1从redis A中拿到锁走后面逻辑,此时如果A宕机了,然后线程2又从备用redis B中拿到锁,那就出现两个线程都拿到锁的问题了。
**解决:**使用红锁,如下,奇数redis,获取更多的redis的锁,才算真正拿到锁
7.去掉redisson和seata,影响课程录制
这里先将代码恢复,不使用redission和seata,
最后测试也是没有问题的
-
去掉seata和redisson依赖,相关配置文件也都注释掉
-
ConfirmOrderService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.EnumUtil; import cn.hutool.core.util.NumberUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.*; import com.neilxu.train.business.enums.ConfirmOrderStatusEnum; import com.neilxu.train.business.enums.SeatColEnum; import com.neilxu.train.business.enums.SeatTypeEnum; import com.neilxu.train.business.mapper.ConfirmOrderMapper; import com.neilxu.train.business.req.ConfirmOrderDoReq; import com.neilxu.train.business.req.ConfirmOrderQueryReq; import com.neilxu.train.business.req.ConfirmOrderTicketReq; import com.neilxu.train.business.resp.ConfirmOrderQueryResp; import com.neilxu.train.common.context.LoginMemberContext; import com.neilxu.train.common.exception.BusinessException; import com.neilxu.train.common.exception.BusinessExceptionEnum; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; @Service public class ConfirmOrderService { private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderService.class); @Resource private ConfirmOrderMapper confirmOrderMapper; @Resource private DailyTrainTicketService dailyTrainTicketService; @Resource private DailyTrainCarriageService dailyTrainCarriageService; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private AfterConfirmOrderService afterConfirmOrderService; @Autowired private StringRedisTemplate redisTemplate; // @Autowired // private RedissonClient redissonClient; public void save(ConfirmOrderDoReq req) { DateTime now = DateTime.now(); ConfirmOrder confirmOrder = BeanUtil.copyProperties(req, ConfirmOrder.class); if (ObjectUtil.isNull(confirmOrder.getId())) { confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrderMapper.insert(confirmOrder); } else { confirmOrder.setUpdateTime(now); confirmOrderMapper.updateByPrimaryKey(confirmOrder); } } public PageResp<ConfirmOrderQueryResp> queryList(ConfirmOrderQueryReq req) { ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample(); confirmOrderExample.setOrderByClause("id desc"); ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria(); LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<ConfirmOrder> confirmOrderList = confirmOrderMapper.selectByExample(confirmOrderExample); // List<ConfirmOrder> confirmOrderList = confirmOrderMapper.selectByExampleWithBLOBs(confirmOrderExample); PageInfo<ConfirmOrder> pageInfo = new PageInfo<>(confirmOrderList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<ConfirmOrderQueryResp> list = BeanUtil.copyToList(confirmOrderList, ConfirmOrderQueryResp.class); PageResp<ConfirmOrderQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { confirmOrderMapper.deleteByPrimaryKey(id); } public void doConfirm(ConfirmOrderDoReq req) { String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode(); // setIfAbsent就是对应redis的setnx Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey); } else { // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); } // RLock lock = null; /* 关于红锁,看16.7节: A B C D E 1: A B C D E 2: C D E 3: C */ try { // // 使用redisson,自带看门狗 // lock = redissonClient.getLock(lockKey); // // // 红锁的写法 // // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock); // // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS); // // /** // waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false // leaseTime – lease time 锁时长,即n秒后自动释放锁 // time unit – time unit 时间单位 // */ // // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗 // boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗 // if (tryLock) { // LOG.info("恭喜,抢到锁了!"); // // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果 // // for (int i = 0; i < 30; i++) { // // Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey); // // LOG.info("锁过期时间还有:{}", expire); // // Thread.sleep(1000); // // } // } else { // // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 // LOG.info("很遗憾,没抢到锁"); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); // } // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过 Date date = req.getDate(); String trainCode = req.getTrainCode(); String start = req.getStart(); String end = req.getEnd(); List<ConfirmOrderTicketReq> tickets = req.getTickets(); // 保存确认订单表,状态初始 DateTime now = DateTime.now(); ConfirmOrder confirmOrder = new ConfirmOrder(); confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrder.setMemberId(LoginMemberContext.getId()); confirmOrder.setDate(date); confirmOrder.setTrainCode(trainCode); confirmOrder.setStart(start); confirmOrder.setEnd(end); confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId()); confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode()); confirmOrder.setTickets(JSON.toJSONString(tickets)); confirmOrderMapper.insert(confirmOrder); // 查出余票记录,需要得到真实的库存 DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end); LOG.info("查出余票记录:{}", dailyTrainTicket); // 预扣减余票数量,并判断余票是否足够 reduceTickets(req, dailyTrainTicket); // 最终的选座结果 List<DailyTrainSeat> finalSeatList = new ArrayList<>(); // 计算相对第一个座位的偏移值 // 比如选择的是C1,D2,则偏移值是:[0,5] // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2] ConfirmOrderTicketReq ticketReq0 = tickets.get(0); if (StrUtil.isNotBlank(ticketReq0.getSeat())) { LOG.info("本次购票有选座"); // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值 List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode()); LOG.info("本次选座的座位类型包含的列:{}", colEnumList); // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2} List<String> referSeatList = new ArrayList<>(); for (int i = 1; i <= 2; i++) { for (SeatColEnum seatColEnum : colEnumList) { referSeatList.add(seatColEnum.getCode() + i); } } LOG.info("用于作参照的两排座位:{}", referSeatList); List<Integer> offsetList = new ArrayList<>(); // 绝对偏移值,即:在参照座位列表中的位置 List<Integer> aboluteOffsetList = new ArrayList<>(); for (ConfirmOrderTicketReq ticketReq : tickets) { int index = referSeatList.indexOf(ticketReq.getSeat()); aboluteOffsetList.add(index); } LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList); for (Integer index : aboluteOffsetList) { int offset = index - aboluteOffsetList.get(0); offsetList.add(offset); } LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList); getSeat(finalSeatList, date, trainCode, ticketReq0.getSeatTypeCode(), ticketReq0.getSeat().split("")[0], // 从A1得到A offsetList, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } else { LOG.info("本次购票没有选座"); for (ConfirmOrderTicketReq ticketReq : tickets) { getSeat(finalSeatList, date, trainCode, ticketReq.getSeatTypeCode(), null, null, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } } LOG.info("最终选座:{}", finalSeatList); // 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); } LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); redisTemplate.delete(lockKey); // } catch (InterruptedException e) { // LOG.error("购票异常", e); } finally { // LOG.info("购票流程结束,释放锁!"); // if (null != lock && lock.isHeldByCurrentThread()) { // lock.unlock(); // } } } /** * 挑座位,如果有选座,则一次性挑完,如果无选座,则一个一个挑 * @param date * @param trainCode * @param seatType * @param column * @param offsetList */ private void getSeat(List<DailyTrainSeat> finalSeatList, Date date, String trainCode, String seatType, String column, List<Integer> offsetList, Integer startIndex, Integer endIndex) { List<DailyTrainSeat> getSeatList = new ArrayList<>(); List<DailyTrainCarriage> carriageList = dailyTrainCarriageService.selectBySeatType(date, trainCode, seatType); LOG.info("共查出{}个符合条件的车厢", carriageList.size()); // 一个车箱一个车箱的获取座位数据 for (DailyTrainCarriage dailyTrainCarriage : carriageList) { LOG.info("开始从车厢{}选座", dailyTrainCarriage.getIndex()); getSeatList = new ArrayList<>(); List<DailyTrainSeat> seatList = dailyTrainSeatService.selectByCarriage(date, trainCode, dailyTrainCarriage.getIndex()); LOG.info("车厢{}的座位数:{}", dailyTrainCarriage.getIndex(), seatList.size()); for (int i = 0; i < seatList.size(); i++) { DailyTrainSeat dailyTrainSeat = seatList.get(i); Integer seatIndex = dailyTrainSeat.getCarriageSeatIndex(); String col = dailyTrainSeat.getCol(); // 判断当前座位不能被选中过 boolean alreadyChooseFlag = false; for (DailyTrainSeat finalSeat : finalSeatList){ if (finalSeat.getId().equals(dailyTrainSeat.getId())) { alreadyChooseFlag = true; break; } } if (alreadyChooseFlag) { LOG.info("座位{}被选中过,不能重复选中,继续判断下一个座位", seatIndex); continue; } // 判断column,有值的话要比对列号 if (StrUtil.isBlank(column)) { LOG.info("无选座"); } else { if (!column.equals(col)) { LOG.info("座位{}列值不对,继续判断下一个座位,当前列值:{},目标列值:{}", seatIndex, col, column); continue; } } boolean isChoose = calSell(dailyTrainSeat, startIndex, endIndex); if (isChoose) { LOG.info("选中座位"); getSeatList.add(dailyTrainSeat); } else { continue; } // 根据offset选剩下的座位 boolean isGetAllOffsetSeat = true; if (CollUtil.isNotEmpty(offsetList)) { LOG.info("有偏移值:{},校验偏移的座位是否可选", offsetList); // 从索引1开始,索引0就是当前已选中的票 for (int j = 1; j < offsetList.size(); j++) { Integer offset = offsetList.get(j); // 座位在库的索引是从1开始 // int nextIndex = seatIndex + offset - 1; int nextIndex = i + offset; // 有选座时,一定是在同一个车箱 if (nextIndex >= seatList.size()) { LOG.info("座位{}不可选,偏移后的索引超出了这个车箱的座位数", nextIndex); isGetAllOffsetSeat = false; break; } DailyTrainSeat nextDailyTrainSeat = seatList.get(nextIndex); boolean isChooseNext = calSell(nextDailyTrainSeat, startIndex, endIndex); if (isChooseNext) { LOG.info("座位{}被选中", nextDailyTrainSeat.getCarriageSeatIndex()); getSeatList.add(nextDailyTrainSeat); } else { LOG.info("座位{}不可选", nextDailyTrainSeat.getCarriageSeatIndex()); isGetAllOffsetSeat = false; break; } } } if (!isGetAllOffsetSeat) { getSeatList = new ArrayList<>(); continue; } // 保存选好的座位 finalSeatList.addAll(getSeatList); return; } } } /** * 计算某座位在区间内是否可卖 * 例:sell=10001,本次购买区间站1~4,则区间已售000 * 全部是0,表示这个区间可买;只要有1,就表示区间内已售过票 * * 选中后,要计算购票后的sell,比如原来是10001,本次购买区间站1~4 * 方案:构造本次购票造成的售卖信息01110,和原sell 10001按位与,最终得到11111 */ private boolean calSell(DailyTrainSeat dailyTrainSeat, Integer startIndex, Integer endIndex) { // 00001, 00000 String sell = dailyTrainSeat.getSell(); // 000, 000 String sellPart = sell.substring(startIndex, endIndex); if (Integer.parseInt(sellPart) > 0) { LOG.info("座位{}在本次车站区间{}~{}已售过票,不可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex); return false; } else { LOG.info("座位{}在本次车站区间{}~{}未售过票,可选中该座位", dailyTrainSeat.getCarriageSeatIndex(), startIndex, endIndex); // 111, 111 String curSell = sellPart.replace('0', '1'); // 0111, 0111 curSell = StrUtil.fillBefore(curSell, '0', endIndex); // 01110, 01110 curSell = StrUtil.fillAfter(curSell, '0', sell.length()); // 当前区间售票信息curSell 01110与库里的已售信息sell 00001按位与,即可得到该座位卖出此票后的售票详情 // 15(01111), 14(01110 = 01110|00000) int newSellInt = NumberUtil.binaryToInt(curSell) | NumberUtil.binaryToInt(sell); // 1111, 1110 String newSell = NumberUtil.getBinaryStr(newSellInt); // 01111, 01110 newSell = StrUtil.fillBefore(newSell, '0', sell.length()); LOG.info("座位{}被选中,原售票信息:{},车站区间:{}~{},即:{},最终售票信息:{}" , dailyTrainSeat.getCarriageSeatIndex(), sell, startIndex, endIndex, curSell, newSell); dailyTrainSeat.setSell(newSell); return true; } } private static void reduceTickets(ConfirmOrderDoReq req, DailyTrainTicket dailyTrainTicket) { for (ConfirmOrderTicketReq ticketReq : req.getTickets()) { String seatTypeCode = ticketReq.getSeatTypeCode(); SeatTypeEnum seatTypeEnum = EnumUtil.getBy(SeatTypeEnum::getCode, seatTypeCode); switch (seatTypeEnum) { case YDZ -> { int countLeft = dailyTrainTicket.getYdz() - 1; if (countLeft < 0) { throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR); } dailyTrainTicket.setYdz(countLeft); } case EDZ -> { int countLeft = dailyTrainTicket.getEdz() - 1; if (countLeft < 0) { throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR); } dailyTrainTicket.setEdz(countLeft); } case RW -> { int countLeft = dailyTrainTicket.getRw() - 1; if (countLeft < 0) { throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR); } dailyTrainTicket.setRw(countLeft); } case YW -> { int countLeft = dailyTrainTicket.getYw() - 1; if (countLeft < 0) { throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_TICKET_COUNT_ERROR); } dailyTrainTicket.setYw(countLeft); } } } } }
8.本章代码优化
1.Spring拦截器配置的路径,不要包含context-path
-
com.neilxu.train.common.interceptor.MemberInterceptor
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { LOG.info("MemberInterceptor开始"); //获取header的token参数 String token = request.getHeader("token"); if (StrUtil.isNotBlank(token)) { LOG.info("获取会员登录token:{}", token); JSONObject loginMember = JwtUtil.getJSONObject(token); LOG.info("当前登录会员:{}", loginMember); MemberLoginResp member = JSONUtil.toBean(loginMember, MemberLoginResp.class); LoginMemberContext.setMember(member); } LOG.info("MemberInterceptor结束"); return true; }
-
com.neilxu.train.common.config.SpringMvcConfig
将context-path去除,不然不会排除掉这些接口
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor);
registry.addInterceptor(memberInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/hello",
"/member/send-code",
"/member/login"
);
}
2.将释放锁的动作放到finally里,否则出异常时,如:余票不足时,不会释放锁。注意:try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁
com.neilxu.train.business.service.ConfirmOrderService
public void doConfirm(ConfirmOrderDoReq req) {
String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
// setIfAbsent就是对应redis的setnx
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(setIfAbsent)) {
LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey);
} else {
// 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey);
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
// RLock lock = null;
/*
关于红锁,看16.7节:
A B C D E
1: A B C D E
2: C D E
3: C
*/
try {
// // 使用redisson,自带看门狗
// lock = redissonClient.getLock(lockKey);
//
// // 红锁的写法
// // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock);
// // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS);
//
// /**
// waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false
// leaseTime – lease time 锁时长,即n秒后自动释放锁
// time unit – time unit 时间单位
// */
// // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗
// boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗
// if (tryLock) {
// LOG.info("恭喜,抢到锁了!");
// // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果
// // for (int i = 0; i < 30; i++) {
// // Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey);
// // LOG.info("锁过期时间还有:{}", expire);
// // Thread.sleep(1000);
// // }
// } else {
// // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
// LOG.info("很遗憾,没抢到锁");
// throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
// }
// 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过
Date date = req.getDate();
String trainCode = req.getTrainCode();
String start = req.getStart();
String end = req.getEnd();
List<ConfirmOrderTicketReq> tickets = req.getTickets();
// 保存确认订单表,状态初始
DateTime now = DateTime.now();
ConfirmOrder confirmOrder = new ConfirmOrder();
confirmOrder.setId(SnowUtil.getSnowflakeNextId());
confirmOrder.setCreateTime(now);
confirmOrder.setUpdateTime(now);
confirmOrder.setMemberId(LoginMemberContext.getId());
confirmOrder.setDate(date);
confirmOrder.setTrainCode(trainCode);
confirmOrder.setStart(start);
confirmOrder.setEnd(end);
confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
confirmOrder.setTickets(JSON.toJSONString(tickets));
confirmOrderMapper.insert(confirmOrder);
// 查出余票记录,需要得到真实的库存
DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
LOG.info("查出余票记录:{}", dailyTrainTicket);
// 预扣减余票数量,并判断余票是否足够
reduceTickets(req, dailyTrainTicket);
// 最终的选座结果
List<DailyTrainSeat> finalSeatList = new ArrayList<>();
// 计算相对第一个座位的偏移值
// 比如选择的是C1,D2,则偏移值是:[0,5]
// 比如选择的是A1,B1,C1,则偏移值是:[0,1,2]
ConfirmOrderTicketReq ticketReq0 = tickets.get(0);
if (StrUtil.isNotBlank(ticketReq0.getSeat())) {
LOG.info("本次购票有选座");
// 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值
List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode());
LOG.info("本次选座的座位类型包含的列:{}", colEnumList);
// 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2}
List<String> referSeatList = new ArrayList<>();
for (int i = 1; i <= 2; i++) {
for (SeatColEnum seatColEnum : colEnumList) {
referSeatList.add(seatColEnum.getCode() + i);
}
}
LOG.info("用于作参照的两排座位:{}", referSeatList);
List<Integer> offsetList = new ArrayList<>();
// 绝对偏移值,即:在参照座位列表中的位置
List<Integer> aboluteOffsetList = new ArrayList<>();
for (ConfirmOrderTicketReq ticketReq : tickets) {
int index = referSeatList.indexOf(ticketReq.getSeat());
aboluteOffsetList.add(index);
}
LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList);
for (Integer index : aboluteOffsetList) {
int offset = index - aboluteOffsetList.get(0);
offsetList.add(offset);
}
LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList);
getSeat(finalSeatList,
date,
trainCode,
ticketReq0.getSeatTypeCode(),
ticketReq0.getSeat().split("")[0], // 从A1得到A
offsetList,
dailyTrainTicket.getStartIndex(),
dailyTrainTicket.getEndIndex()
);
} else {
LOG.info("本次购票没有选座");
for (ConfirmOrderTicketReq ticketReq : tickets) {
getSeat(finalSeatList,
date,
trainCode,
ticketReq.getSeatTypeCode(),
null,
null,
dailyTrainTicket.getStartIndex(),
dailyTrainTicket.getEndIndex()
);
}
}
LOG.info("最终选座:{}", finalSeatList);
// 选中座位后事务处理:
// 座位表修改售卖情况sell;
// 余票详情表修改余票;
// 为会员增加购票记录
// 更新确认订单为成功
try {
afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder);
} catch (Exception e) {
LOG.error("保存购票信息失败", e);
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION);
}
// LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
// redisTemplate.delete(lockKey);
// } catch (InterruptedException e) {
// LOG.error("购票异常", e);
} finally {
// try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁
LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
redisTemplate.delete(lockKey);
// LOG.info("购票流程结束,释放锁!");
// if (null != lock && lock.isHeldByCurrentThread()) {
// lock.unlock();
// }
}
}
3.JMeter线程组之间传递token变量
简单来说,就是将会员登录的返回结果中的token放到一个公共变量里,然后后面购票的时候获取这个公共变量token作为请求头的token
配置后如下:
这里记得勾选,不然会并行执行两个线程组,导致出现有的线程没有token的问题
四、使用Sentinal进行限流熔断
限流降级和熔断降级都是在系统负载过高或出现故障时,为了保证系统稳定性而采取的一种手段。
限流降级通常是指限制系统的请求流量,防止系统因为过多的请求而处理能力不足,从而导致系统崩溃或者变得非常缓慢。通过在系统的各个层次设置流量限制,可以保证系统在承受能力范围内正常运行,同时避免因为过多请求而导致系统瘫痪。
熔断降级则是指在系统出现故障或异常情况时,暂时性地关闭对某些服务的访问,以保证系统的核心功能能够正常工作。熔断机制可以防止故障的扩散,保护系统免受更大范围的影响,同时也能够让系统更快地恢复正常状态。
这两种降级手段都是为了保证系统的稳定性和可用性,在复杂的分布式系统或者微服务架构中尤为重要。通过合理配置限流和熔断策略,可以有效地保护系统不受异常情况影响,提升系统的整体健壮性和可靠性。
---------------------------来自ChatGPT的回答
1.常见的限流算法
- 静态窗口限流
- 动态(滑动)窗口限流
上述两种是限制每秒的请求数,不适用于突发大流量
-
漏桶限流
有一个桶,可以突发放很多请求,但是出去的请求保持匀速
-
令牌桶限流
有一个桶,在没满之前不断放令牌进去,请求去桶里获取令牌才通过
-
令牌大闸
初始时候就判断出有多少令牌,先设定好(类似12306每天每车次的车票数是固定的这种场景)
2.初识Sentinel
3.Sentinel限流初体验
-
添加依赖
business/pom.xml
<!-- 限流熔断 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> </dependency>
-
BusinessApplication.java
package com.neilxu.train.business.config; import com.alibaba.csp.sentinel.slots.block.RuleConstant; import com.alibaba.csp.sentinel.slots.block.flow.FlowRule; import com.alibaba.csp.sentinel.slots.block.flow.FlowRuleManager; import org.mybatis.spring.annotation.MapperScan; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; import org.springframework.core.env.Environment; import java.util.ArrayList; import java.util.List; @SpringBootApplication @ComponentScan("com.neilxu") @MapperScan("com.neilxu.train.*.mapper") @EnableFeignClients("com.neilxu.train.business.feign") @EnableCaching public class BusinessApplication { private static final Logger LOG = LoggerFactory.getLogger(BusinessApplication.class); public static void main(String[] args) { SpringApplication app = new SpringApplication(BusinessApplication.class); Environment env = app.run(args).getEnvironment(); LOG.info("启动成功!!"); LOG.info("测试地址: \thttp://127.0.0.1:{}{}/hello", env.getProperty("server.port"), env.getProperty("server.servlet.context-path")); // 限流规则 initFlowRules(); LOG.info("已定义限流规则"); } private static void initFlowRules(){ List<FlowRule> rules = new ArrayList<>(); FlowRule rule = new FlowRule(); rule.setResource("doConfirm"); rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // Set limit QPS to 20. rule.setCount(1); rules.add(rule); FlowRuleManager.loadRules(rules); } }
-
ConfirmOrderService.java
@SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock") public void doConfirm(ConfirmOrderDoReq req) { String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode(); // setIfAbsent就是对应redis的setnx Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey); } else { // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); } // RLock lock = null; /* 关于红锁,看16.7节: A B C D E 1: A B C D E 2: C D E 3: C */ try { // // 使用redisson,自带看门狗 // lock = redissonClient.getLock(lockKey); // // // 红锁的写法 // // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock); // // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS); // // /** // waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false // leaseTime – lease time 锁时长,即n秒后自动释放锁 // time unit – time unit 时间单位 // */ // // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗 // boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗 // if (tryLock) { // LOG.info("恭喜,抢到锁了!"); // // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果 // // for (int i = 0; i < 30; i++) { // // Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey); // // LOG.info("锁过期时间还有:{}", expire); // // Thread.sleep(1000); // // } // } else { // // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 // LOG.info("很遗憾,没抢到锁"); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); // } // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过 Date date = req.getDate(); String trainCode = req.getTrainCode(); String start = req.getStart(); String end = req.getEnd(); List<ConfirmOrderTicketReq> tickets = req.getTickets(); // 保存确认订单表,状态初始 DateTime now = DateTime.now(); ConfirmOrder confirmOrder = new ConfirmOrder(); confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrder.setMemberId(LoginMemberContext.getId()); confirmOrder.setDate(date); confirmOrder.setTrainCode(trainCode); confirmOrder.setStart(start); confirmOrder.setEnd(end); confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId()); confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode()); confirmOrder.setTickets(JSON.toJSONString(tickets)); confirmOrderMapper.insert(confirmOrder); // 查出余票记录,需要得到真实的库存 DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end); LOG.info("查出余票记录:{}", dailyTrainTicket); // 预扣减余票数量,并判断余票是否足够 reduceTickets(req, dailyTrainTicket); // 最终的选座结果 List<DailyTrainSeat> finalSeatList = new ArrayList<>(); // 计算相对第一个座位的偏移值 // 比如选择的是C1,D2,则偏移值是:[0,5] // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2] ConfirmOrderTicketReq ticketReq0 = tickets.get(0); if (StrUtil.isNotBlank(ticketReq0.getSeat())) { LOG.info("本次购票有选座"); // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值 List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode()); LOG.info("本次选座的座位类型包含的列:{}", colEnumList); // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2} List<String> referSeatList = new ArrayList<>(); for (int i = 1; i <= 2; i++) { for (SeatColEnum seatColEnum : colEnumList) { referSeatList.add(seatColEnum.getCode() + i); } } LOG.info("用于作参照的两排座位:{}", referSeatList); List<Integer> offsetList = new ArrayList<>(); // 绝对偏移值,即:在参照座位列表中的位置 List<Integer> aboluteOffsetList = new ArrayList<>(); for (ConfirmOrderTicketReq ticketReq : tickets) { int index = referSeatList.indexOf(ticketReq.getSeat()); aboluteOffsetList.add(index); } LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList); for (Integer index : aboluteOffsetList) { int offset = index - aboluteOffsetList.get(0); offsetList.add(offset); } LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList); getSeat(finalSeatList, date, trainCode, ticketReq0.getSeatTypeCode(), ticketReq0.getSeat().split("")[0], // 从A1得到A offsetList, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } else { LOG.info("本次购票没有选座"); for (ConfirmOrderTicketReq ticketReq : tickets) { getSeat(finalSeatList, date, trainCode, ticketReq.getSeatTypeCode(), null, null, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } } LOG.info("最终选座:{}", finalSeatList); // 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); } // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); // redisTemplate.delete(lockKey); // } catch (InterruptedException e) { // LOG.error("购票异常", e); } finally { // try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁 LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); redisTemplate.delete(lockKey); // LOG.info("购票流程结束,释放锁!"); // if (null != lock && lock.isHeldByCurrentThread()) { // lock.unlock(); // } } } /** * 降级方法,需包含限流方法的所有参数和BlockException参数 * @param req * @param e */ public void doConfirmBlock(ConfirmOrderDoReq req, BlockException e) { LOG.info("购票请求被限流:{}", req); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION); }
-
com.neilxu.train.common.exception.BusinessExceptionEnum
CONFIRM_ORDER_FLOW_EXCEPTION("当前抢票人数多,请稍候重试"),
-
测试
上述配置的效果是,限制qps为1,结果就是大部分的请求都被限流了,直接返回"当前抢票人数多,请稍候重试"
我们查看余票,这次也并没卖完,8张只卖了2张
4.使用Sentinel控台监控流量
-
下载
-
启动
java -Dserver.port=18080 -Dcsp.sentinel.dashboard.server=localhost:18080 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.8.6.jar
-
访问localhost:18080,刚刚配置的地址
账号密码默认 sentinel
-
配置
business/src/main/resources/application.properties
#sentinel控台:https://github.com/alibaba/spring-cloud-alibaba/wiki/Sentinel spring.cloud.sentinel.transport.port=8719 spring.cloud.sentinel.transport.dashboard=localhost:18080
-
启动JMeter测试
访问限流接口之后,控制台就可以看到数据了
通过控制台也可以新增、编辑流控规则
5.使用Sentinel配置限流
-
BusinessApplication.java
注释掉代码配置的限流规则,改用控制台配置
public static void main(String[] args) { SpringApplication app = new SpringApplication(BusinessApplication.class); Environment env = app.run(args).getEnvironment(); LOG.info("启动成功!!"); LOG.info("测试地址: \thttp://127.0.0.1:{}{}/hello", env.getProperty("server.port"), env.getProperty("server.servlet.context-path")); // // 限流规则 // initFlowRules(); // LOG.info("已定义限流规则"); }
-
BusinessExceptionEnum.java
CONFIRM_ORDER_LOCK_FAIL("当前抢票人数过多,请稍候重试"), CONFIRM_ORDER_FLOW_EXCEPTION("当前抢票人数太多了,请稍候重试"),
-
ConfirmOrderController.java
package com.neilxu.train.business.controller; import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.alibaba.csp.sentinel.slots.block.BlockException; import com.neilxu.train.business.req.ConfirmOrderDoReq; import com.neilxu.train.business.service.ConfirmOrderService; import com.neilxu.train.common.exception.BusinessExceptionEnum; import com.neilxu.train.common.resp.CommonResp; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/confirm-order") public class ConfirmOrderController { private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderController.class); @Resource private ConfirmOrderService confirmOrderService; // 接口的资源名称不要和接口路径一致,会导致限流后走不到降级方法中 @SentinelResource(value = "confirmOrderDo", blockHandler = "doConfirmBlock") @PostMapping("/do") public CommonResp<Object> doConfirm(@Valid @RequestBody ConfirmOrderDoReq req) { confirmOrderService.doConfirm(req); return new CommonResp<>(); } /** 降级方法,需包含限流方法的所有参数和BlockException参数,且返回值要保持一致 * @param req * @param e */ public CommonResp<Object> doConfirmBlock(ConfirmOrderDoReq req, BlockException e) { LOG.info("ConfirmOrderController购票请求被限流:{}", req); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION); CommonResp<Object> commonResp = new CommonResp<>(); commonResp.setSuccess(false); commonResp.setMessage(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION.getDesc()); return commonResp; } }
-
测试
可以看到控台的数据,在控台界面上也可以直接编辑流控规则
代码中不写死限流规则,通过控台来配置,但是控台配置后,重启会清空所有的配置。接口的资源名称不要和接口路径一致,会导致限流后走不到降级方法中
6.Sentinel+Nacos实现限流规则持久化
使用nacos配置限流规则,既可以永久保留规则,重启服务也不会清空,又可以实时修改
-
新增依赖
business/pom.xml
<!-- sentinel + nacos --> <dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> </dependency>
-
一般springcloud相关配置都放在bookstrap.properies
business/src/main/resources/bootstrap.properties
#sentinel控台:https://github.com/alibaba/spring-cloud-alibaba/wiki/Sentinel spring.cloud.sentinel.transport.port=8719 spring.cloud.sentinel.transport.dashboard=localhost:18080 # sentinel + nacos spring.cloud.sentinel.datasource.nacos.nacos.serverAddr=localhost:8848 spring.cloud.sentinel.datasource.nacos.nacos.namespace=train spring.cloud.sentinel.datasource.nacos.nacos.groupId=TRAIN_GROUP spring.cloud.sentinel.datasource.nacos.nacos.dataId=sentinel spring.cloud.sentinel.datasource.nacos.nacos.ruleType=flow
-
测试
到Nacos里新建配置文件,dataId=sentinel ,groupId=TRAIN_GROUP
[{
"resource": "doConfirm",
"limitApp": "default",
"grade": 1,
"count": 100,
"strategy": 0,
"controlBehavior" : 0,
"clusterMode": false
},{
"resource": "confirmOrderDo",
"limitApp": "default",
"grade": 1,
"count": 4,
"strategy": 0,
"controlBehavior" : 0,
"clusterMode": false
}]
重启business,启动JMeter测试,此时sentinel控台显示的规则就是刚刚nacos配置的规则,并且此时在nacos修改规则会实时生效
7.Sentinel限流不同的流控效果讲解 - Warm Up&排队等待
-
Warm Up 预热
如下图,10秒内逐渐上涨达到100qps
-
排队等待
如下图,qps设置10,但是允许等待1秒,也就是比如20个请求,可以分成2秒执行完,都不会拒绝【如果是快速失败,那就会有些请求直接被限流拒绝了】
8.Sentinel限流不同的流控模式讲解
-
关联
如下图,当hello被限流后,hello1才会被限流
-
链路
如下图,只有入口是/hello1的时候,访问hello2才会被限流,从其他的入口访问hello2就不会被限流
需要设置下面的配置,使链路分开
business/src/main/resources/bootstrap.properties
# 流控模式是链路时,必须关闭这个配置,默认是true,为true时可以在控台-簇点链路界面看到所有请求都在一个链路下面
spring.cloud.sentinel.web-context-unify=false
9.Sentinel+Feign熔断初体验
演示batch服务远程访问business,由于响应速度慢,直接熔断后续11s内的请求的场景
-
batch/pom.xml
<!-- 限流熔断 --> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId> </dependency> <!-- sentinel + nacos --> <dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-datasource-nacos</artifactId> </dependency>
-
batch/src/main/resources/bootstrap.properties
# sentinel控台:https://github.com/alibaba/spring-cloud-alibaba/wiki/Sentinel spring.cloud.sentinel.transport.port=8719 spring.cloud.sentinel.transport.dashboard=localhost:18080 # sentinel + nacos spring.cloud.sentinel.datasource.degrade.nacos.serverAddr=127.0.0.1:8848 spring.cloud.sentinel.datasource.degrade.nacos.namespace=train spring.cloud.sentinel.datasource.degrade.nacos.groupId=DEFAULT_GROUP spring.cloud.sentinel.datasource.degrade.nacos.dataId=sentinel-batch-degrade spring.cloud.sentinel.datasource.degrade.nacos.ruleType=degrade # sentinel默认不监控feign,需改成true,在簇点链路界面会显示资源:GET:http://business/business/hello feign.sentinel.enabled=true # 上面改成true后,启动会报注入失败,需改成懒加载 spring.cloud.openfeign.lazy-attributes-resolution=true
-
nacos新建配置文件
[{
"resource": "GET:http://business/business/hello",
"grade": 0,
"count": 201,
"timeWindow": 11,
"minRequestAmount": 6,
"statIntervalMs": 1000,
"slowRatioThreshold": 0.3
}]
-
测试
重启batch、sentinel,
可以在控制台看到熔断规则,这里配置的意思是假如当前请求1秒内达到6个请求以上,且响应时间超过201毫秒的数量占比超0.3,就会触发熔断,熔断时长11秒【目前没做降级,熔断则会抛异常】
10.Sentinel+Feign熔断后的降级处理
-
BusinessFeignFallback
package com.neilxu.train.batch.feign; import com.neilxu.train.common.resp.CommonResp; import org.springframework.stereotype.Component; import java.util.Date; @Component public class BusinessFeignFallback implements BusinessFeign { @Override public String hello() { return "Fallback"; } @Override public CommonResp<Object> genDaily(Date date) { return null; } }
-
BusinessFeign.java
package com.neilxu.train.batch.feign; import com.neilxu.train.common.resp.CommonResp; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import java.util.Date; //@FeignClient("business") //@FeignClient(name = "business", url = "http://127.0.0.1:8002/business") @FeignClient(value = "business", fallback = BusinessFeignFallback.class) public interface BusinessFeign { @GetMapping("/business/hello") String hello(); @GetMapping("/business/admin/daily-train/gen-daily/{date}") CommonResp<Object> genDaily(@PathVariable @DateTimeFormat(pattern = "yyyy-MM-dd") Date date); }
-
测试
可以看到发生熔断,会走降级方法
11.Sentinel熔断规则配置演示
-
异常比例
如下图,意思是达到0.3比例异常,就熔断3秒(同样要满足最小请求数)
-
异常数
同上,把比例改成具体次数
五、使用令牌大闸防止机器人抢票
1.增加秒杀令牌表用以维护令牌信息
-
sql/business.sql
drop table if exists `sk_token`; create table `sk_token` ( `id` bigint not null comment 'id', `date` date not null comment '日期', `train_code` varchar(20) not null comment '车次编号', `count` int not null comment '令牌余量', `create_time` datetime(3) comment '新增时间', `update_time` datetime(3) comment '修改时间', primary key (`id`), unique key `date_train_code_unique` (`date`, `train_code`) ) engine=innodb default charset=utf8mb4 comment='秒杀令牌';
-
generator/src/main/resources/generator-config-business.xml
<!--<table tableName="station" domainObjectName="Station"/>--> <!--<table tableName="train" domainObjectName="Train"/>--> <!--<table tableName="train_station" domainObjectName="TrainStation"/>--> <!--<table tableName="train_carriage" domainObjectName="TrainCarriage"/>--> <!--<table tableName="train_seat" domainObjectName="TrainSeat"/>--> <!--<table tableName="daily_train" domainObjectName="DailyTrain"/>--> <!--<table tableName="daily_train_station" domainObjectName="DailyTrainStation"/>--> <!--<table tableName="daily_train_carriage" domainObjectName="DailyTrainCarriage"/>--> <!--<table tableName="daily_train_seat" domainObjectName="DailyTrainSeat"/>--> <!--<table tableName="daily_train_ticket" domainObjectName="DailyTrainTicket"/>--> <!--<table tableName="confirm_order" domainObjectName="ConfirmOrder"/>--> <table tableName="sk_token" domainObjectName="SkToken"/>
-
ServerGenerator.java
package com.neilxu.train.generator.gen; import com.neilxu.train.generator.util.DbUtil; import com.neilxu.train.generator.util.Field; import com.neilxu.train.generator.util.FreemarkerUtil; import freemarker.template.TemplateException; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.Node; import org.dom4j.io.SAXReader; import java.io.File; import java.io.IOException; import java.util.*; public class ServerGenerator { static boolean readOnly = false; // static boolean readOnly = true; // static String vuePath = "web/src/views/main/"; static String vuePath = "admin/src/views/main/"; static String serverPath = "[module]/src/main/java/com/neilxu/train/[module]/"; static String pomPath = "generator/pom.xml"; static String module = ""; // static { // new File(serverPath).mkdirs(); // } public static void main(String[] args) throws Exception { // 获取mybatis-generator String generatorPath = getGeneratorPath(); // 比如generator-config-member.xml,得到module = member module = generatorPath.replace("src/main/resources/generator-config-", "").replace(".xml", ""); System.out.println("module: " + module); serverPath = serverPath.replace("[module]", module); new File(serverPath).mkdirs(); System.out.println("servicePath: " + serverPath); // 读取table节点 Document document = new SAXReader().read("generator/" + generatorPath); Node table = document.selectSingleNode("//table"); System.out.println(table); Node tableName = table.selectSingleNode("@tableName"); Node domainObjectName = table.selectSingleNode("@domainObjectName"); System.out.println(tableName.getText() + "/" + domainObjectName.getText()); // 为DbUtil设置数据源 Node connectionURL = document.selectSingleNode("//@connectionURL"); Node userId = document.selectSingleNode("//@userId"); Node password = document.selectSingleNode("//@password"); System.out.println("url: " + connectionURL.getText()); System.out.println("user: " + userId.getText()); System.out.println("password: " + password.getText()); DbUtil.url = connectionURL.getText(); DbUtil.user = userId.getText(); DbUtil.password = password.getText(); // 示例:表名 neilxu_test // Domain = neilxuTest String Domain = domainObjectName.getText(); // domain = neilxuTest String domain = Domain.substring(0, 1).toLowerCase() + Domain.substring(1); // do_main = neilxu-test String do_main = tableName.getText().replaceAll("_", "-"); // 表中文名 String tableNameCn = DbUtil.getTableComment(tableName.getText()); List<Field> fieldList = DbUtil.getColumnByTableName(tableName.getText()); Set<String> typeSet = getJavaTypes(fieldList); // 组装参数 Map<String, Object> param = new HashMap<>(); param.put("module", module); param.put("Domain", Domain); param.put("domain", domain); param.put("do_main", do_main); param.put("tableNameCn", tableNameCn); param.put("fieldList", fieldList); param.put("typeSet", typeSet); param.put("readOnly", readOnly); System.out.println("组装参数:" + param); gen(Domain, param, "service", "service"); gen(Domain, param, "controller/admin", "adminController"); // gen(Domain, param, "controller", "controller"); gen(Domain, param, "req", "saveReq"); gen(Domain, param, "req", "queryReq"); gen(Domain, param, "resp", "queryResp"); genVue(do_main, param); } private static void gen(String Domain, Map<String, Object> param, String packageName, String target) throws IOException, TemplateException { FreemarkerUtil.initConfig(target + ".ftl"); String toPath = serverPath + packageName + "/"; new File(toPath).mkdirs(); String Target = target.substring(0, 1).toUpperCase() + target.substring(1); String fileName = toPath + Domain + Target + ".java"; System.out.println("开始生成:" + fileName); FreemarkerUtil.generator(fileName, param); } private static void genVue(String do_main, Map<String, Object> param) throws IOException, TemplateException { FreemarkerUtil.initConfig("vue.ftl"); new File(vuePath + module).mkdirs(); String fileName = vuePath + module + "/" + do_main + ".vue"; System.out.println("开始生成:" + fileName); FreemarkerUtil.generator(fileName, param); } private static String getGeneratorPath() throws DocumentException { SAXReader saxReader = new SAXReader(); Map<String, String> map = new HashMap<String, String>(); map.put("pom", "http://maven.apache.org/POM/4.0.0"); saxReader.getDocumentFactory().setXPathNamespaceURIs(map); Document document = saxReader.read(pomPath); Node node = document.selectSingleNode("//pom:configurationFile"); System.out.println(node.getText()); return node.getText(); } /** * 获取所有的Java类型,使用Set去重 */ private static Set<String> getJavaTypes(List<Field> fieldList) { Set<String> set = new HashSet<>(); for (int i = 0; i < fieldList.size(); i++) { Field field = fieldList.get(i); set.add(field.getJavaType()); } return set; } }
-
generator/pom.xml
<!-- <configurationFile>src/main/resources/generator-config-member.xml</configurationFile>--> <configurationFile>src/main/resources/generator-config-business.xml</configurationFile> <!-- <configurationFile>src/main/resources/generator-config-batch.xml</configurationFile>-->
-
生成前后端代码,操作同之前
-
新增路由和侧边栏(admin),操作同之前
-
测试结果
2.初始化车次信息时初始化秒杀令牌信息
-
DailyTrainSeatService.java
public int countSeat(Date date, String trainCode) { return countSeat(date, trainCode, null); } public int countSeat(Date date, String trainCode, String seatType) { DailyTrainSeatExample example = new DailyTrainSeatExample(); DailyTrainSeatExample.Criteria criteria = example.createCriteria(); criteria.andDateEqualTo(date) .andTrainCodeEqualTo(trainCode); if (StrUtil.isNotBlank(seatType)) { criteria.andSeatTypeEqualTo(seatType); } long l = dailyTrainSeatMapper.countByExample(example); if (l == 0L) { return -1; } return (int) l; }
-
DailyTrainStationService.java
/** * 按车次查询全部车站 */ public long countByTrainCode(Date date, String trainCode) { DailyTrainStationExample example = new DailyTrainStationExample(); example.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); long stationCount = dailyTrainStationMapper.countByExample(example); return stationCount; }
-
SkTokenService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.ObjectUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.SkToken; import com.neilxu.train.business.domain.SkTokenExample; import com.neilxu.train.business.mapper.SkTokenMapper; import com.neilxu.train.business.req.SkTokenQueryReq; import com.neilxu.train.business.req.SkTokenSaveReq; import com.neilxu.train.business.resp.SkTokenQueryResp; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.Date; import java.util.List; @Service public class SkTokenService { private static final Logger LOG = LoggerFactory.getLogger(SkTokenService.class); @Resource private SkTokenMapper skTokenMapper; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private DailyTrainStationService dailyTrainStationService; /** * 初始化 */ public void genDaily(Date date, String trainCode) { LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode); SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); skTokenMapper.deleteByExample(skTokenExample); DateTime now = DateTime.now(); SkToken skToken = new SkToken(); skToken.setDate(date); skToken.setTrainCode(trainCode); skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); int seatCount = dailyTrainSeatService.countSeat(date, trainCode); LOG.info("车次【{}】座位数:{}", trainCode, seatCount); long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode); LOG.info("车次【{}】到站数:{}", trainCode, stationCount); // 3/4需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * stationCount)张火车票 int count = (int) (seatCount * stationCount * 3/4); LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count); skToken.setCount(count); skTokenMapper.insert(skToken); } public void save(SkTokenSaveReq req) { DateTime now = DateTime.now(); SkToken skToken = BeanUtil.copyProperties(req, SkToken.class); if (ObjectUtil.isNull(skToken.getId())) { skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); skTokenMapper.insert(skToken); } else { skToken.setUpdateTime(now); skTokenMapper.updateByPrimaryKey(skToken); } } public PageResp<SkTokenQueryResp> queryList(SkTokenQueryReq req) { SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.setOrderByClause("id desc"); SkTokenExample.Criteria criteria = skTokenExample.createCriteria(); LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<SkToken> skTokenList = skTokenMapper.selectByExample(skTokenExample); PageInfo<SkToken> pageInfo = new PageInfo<>(skTokenList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<SkTokenQueryResp> list = BeanUtil.copyToList(skTokenList, SkTokenQueryResp.class); PageResp<SkTokenQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { skTokenMapper.deleteByPrimaryKey(id); } }
-
DailyTrainService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.ObjectUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.DailyTrain; import com.neilxu.train.business.domain.DailyTrainExample; import com.neilxu.train.business.domain.Train; import com.neilxu.train.business.mapper.DailyTrainMapper; import com.neilxu.train.business.req.DailyTrainQueryReq; import com.neilxu.train.business.req.DailyTrainSaveReq; import com.neilxu.train.business.resp.DailyTrainQueryResp; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Date; import java.util.List; @Service public class DailyTrainService { private static final Logger LOG = LoggerFactory.getLogger(DailyTrainService.class); @Resource private DailyTrainMapper dailyTrainMapper; @Resource private TrainService trainService; @Resource private DailyTrainStationService dailyTrainStationService; @Resource private DailyTrainCarriageService dailyTrainCarriageService; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private DailyTrainTicketService dailyTrainTicketService; @Resource private SkTokenService skTokenService; public void save(DailyTrainSaveReq req) { DateTime now = DateTime.now(); DailyTrain dailyTrain = BeanUtil.copyProperties(req, DailyTrain.class); if (ObjectUtil.isNull(dailyTrain.getId())) { dailyTrain.setId(SnowUtil.getSnowflakeNextId()); dailyTrain.setCreateTime(now); dailyTrain.setUpdateTime(now); dailyTrainMapper.insert(dailyTrain); } else { dailyTrain.setUpdateTime(now); dailyTrainMapper.updateByPrimaryKey(dailyTrain); } } public PageResp<DailyTrainQueryResp> queryList(DailyTrainQueryReq req) { DailyTrainExample dailyTrainExample = new DailyTrainExample(); dailyTrainExample.setOrderByClause("date desc, code asc"); DailyTrainExample.Criteria criteria = dailyTrainExample.createCriteria(); if (ObjectUtil.isNotNull(req.getDate())) { criteria.andDateEqualTo(req.getDate()); } if (ObjectUtil.isNotEmpty(req.getCode())) { criteria.andCodeEqualTo(req.getCode()); } LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<DailyTrain> dailyTrainList = dailyTrainMapper.selectByExample(dailyTrainExample); PageInfo<DailyTrain> pageInfo = new PageInfo<>(dailyTrainList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<DailyTrainQueryResp> list = BeanUtil.copyToList(dailyTrainList, DailyTrainQueryResp.class); PageResp<DailyTrainQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { dailyTrainMapper.deleteByPrimaryKey(id); } /** * 生成某日所有车次信息,包括车次、车站、车厢、座位 * @param date */ public void genDaily(Date date) { List<Train> trainList = trainService.selectAll(); if (CollUtil.isEmpty(trainList)) { LOG.info("没有车次基础数据,任务结束"); return; } for (Train train : trainList) { genDailyTrain(date, train); } } @Transactional public void genDailyTrain(Date date, Train train) { LOG.info("生成日期【{}】车次【{}】的信息开始", DateUtil.formatDate(date), train.getCode()); // 删除该车次已有的数据 DailyTrainExample dailyTrainExample = new DailyTrainExample(); dailyTrainExample.createCriteria() .andDateEqualTo(date) .andCodeEqualTo(train.getCode()); dailyTrainMapper.deleteByExample(dailyTrainExample); // 生成该车次的数据 DateTime now = DateTime.now(); DailyTrain dailyTrain = BeanUtil.copyProperties(train, DailyTrain.class); dailyTrain.setId(SnowUtil.getSnowflakeNextId()); dailyTrain.setCreateTime(now); dailyTrain.setUpdateTime(now); dailyTrain.setDate(date); dailyTrainMapper.insert(dailyTrain); // 生成该车次的车站数据 dailyTrainStationService.genDaily(date, train.getCode()); // 生成该车次的车厢数据 dailyTrainCarriageService.genDaily(date, train.getCode()); // 生成该车次的座位数据 dailyTrainSeatService.genDaily(date, train.getCode()); // 生成该车次的余票数据 dailyTrainTicketService.genDaily(dailyTrain, date, train.getCode()); // 生成令牌余量数据 skTokenService.genDaily(date, train.getCode()); LOG.info("生成日期【{}】车次【{}】的信息结束", DateUtil.formatDate(date), train.getCode()); } }
-
令牌页面只能查询和修改余量
sk-token.vue
<template> <p> <a-space> <a-button type="primary" @click="handleQuery()">刷新</a-button> <!--<a-button type="primary" @click="onAdd">新增</a-button>--> </a-space> </p> <a-table :dataSource="skTokens" :columns="columns" :pagination="pagination" @change="handleTableChange" :loading="loading"> <template #bodyCell="{ column, record }"> <template v-if="column.dataIndex === 'operation'"> <a-space> <!--<a-popconfirm--> <!-- title="删除后不可恢复,确认删除?"--> <!-- @confirm="onDelete(record)"--> <!-- ok-text="确认" cancel-text="取消">--> <!-- <a style="color: red">删除</a>--> <!--</a-popconfirm>--> <a @click="onEdit(record)">修改令牌余量</a> </a-space> </template> </template> </a-table> <a-modal v-model:visible="visible" title="秒杀令牌" @ok="handleOk" ok-text="确认" cancel-text="取消"> <a-form :model="skToken" :label-col="{span: 4}" :wrapper-col="{ span: 20 }"> <a-form-item label="日期"> <a-date-picker v-model:value="skToken.date" valueFormat="YYYY-MM-DD" placeholder="请选择日期" disabled/> </a-form-item> <a-form-item label="车次编号"> <a-input v-model:value="skToken.trainCode" disabled/> </a-form-item> <a-form-item label="令牌余量"> <a-input v-model:value="skToken.count" /> </a-form-item> </a-form> </a-modal> </template> <script> import { defineComponent, ref, onMounted } from 'vue'; import {notification} from "ant-design-vue"; import axios from "axios"; export default defineComponent({ name: "sk-token-view", setup() { const visible = ref(false); let skToken = ref({ id: undefined, date: undefined, trainCode: undefined, count: undefined, createTime: undefined, updateTime: undefined, }); const skTokens = ref([]); // 分页的三个属性名是固定的 const pagination = ref({ total: 0, current: 1, pageSize: 10, }); let loading = ref(false); const columns = [ { title: '日期', dataIndex: 'date', key: 'date', }, { title: '车次编号', dataIndex: 'trainCode', key: 'trainCode', }, { title: '令牌余量', dataIndex: 'count', key: 'count', }, { title: '操作', dataIndex: 'operation' } ]; const onAdd = () => { skToken.value = {}; visible.value = true; }; const onEdit = (record) => { skToken.value = window.Tool.copy(record); visible.value = true; }; const onDelete = (record) => { axios.delete("/business/admin/sk-token/delete/" + record.id).then((response) => { const data = response.data; if (data.success) { notification.success({description: "删除成功!"}); handleQuery({ page: pagination.value.current, size: pagination.value.pageSize, }); } else { notification.error({description: data.message}); } }); }; const handleOk = () => { axios.post("/business/admin/sk-token/save", skToken.value).then((response) => { let data = response.data; if (data.success) { notification.success({description: "保存成功!"}); visible.value = false; handleQuery({ page: pagination.value.current, size: pagination.value.pageSize }); } else { notification.error({description: data.message}); } }); }; const handleQuery = (param) => { if (!param) { param = { page: 1, size: pagination.value.pageSize }; } loading.value = true; axios.get("/business/admin/sk-token/query-list", { params: { page: param.page, size: param.size } }).then((response) => { loading.value = false; let data = response.data; if (data.success) { skTokens.value = data.content.list; // 设置分页控件的值 pagination.value.current = param.page; pagination.value.total = data.content.total; } else { notification.error({description: data.message}); } }); }; const handleTableChange = (page) => { // console.log("看看自带的分页参数都有啥:" + JSON.stringify(page)); pagination.value.pageSize = page.pageSize; handleQuery({ page: page.current, size: page.pageSize }); }; onMounted(() => { handleQuery({ page: 1, size: pagination.value.pageSize }); }); return { skToken, visible, skTokens, pagination, columns, handleTableChange, handleQuery, loading, onAdd, handleOk, onEdit, onDelete }; }, }); </script>
-
测试
一个车次卖的票最多为 座位数 * (车站数-1)
3.增加校验秒杀令牌功能
-
BusinessExceptionEnum.java
CONFIRM_ORDER_SK_TOKEN_FAIL("票已卖光"),
-
business/src/main/java/com/jiawa/train/business/mapper/cust/SkTokenMapperCust.java
package com.neilxu.train.business.mapper.cust; import org.apache.ibatis.annotations.Param; import java.util.Date; public interface SkTokenMapperCust { int decrease(@Param("date") Date date, @Param("trainCode") String trainCode); }
-
business/src/main/resources/mapper/cust/SkTokenMapperCust.xml
这里将判断余数和减余数写成一条sql,提高了性能
<?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="com.neilxu.train.business.mapper.cust.SkTokenMapperCust"> <update id="decrease"> update sk_token set `count` = if (`count` < 1, 0, `count` - 1) where `date` = #{date} and train_code = #{trainCode} and `count` > 0 </update> </mapper>
-
SkTokenService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.ObjectUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.SkToken; import com.neilxu.train.business.domain.SkTokenExample; import com.neilxu.train.business.mapper.SkTokenMapper; import com.neilxu.train.business.mapper.cust.SkTokenMapperCust; import com.neilxu.train.business.req.SkTokenQueryReq; import com.neilxu.train.business.req.SkTokenSaveReq; import com.neilxu.train.business.resp.SkTokenQueryResp; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.util.Date; import java.util.List; @Service public class SkTokenService { private static final Logger LOG = LoggerFactory.getLogger(SkTokenService.class); @Resource private SkTokenMapper skTokenMapper; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private DailyTrainStationService dailyTrainStationService; @Resource private SkTokenMapperCust skTokenMapperCust; /** * 初始化 */ public void genDaily(Date date, String trainCode) { LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode); SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); skTokenMapper.deleteByExample(skTokenExample); DateTime now = DateTime.now(); SkToken skToken = new SkToken(); skToken.setDate(date); skToken.setTrainCode(trainCode); skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); int seatCount = dailyTrainSeatService.countSeat(date, trainCode); LOG.info("车次【{}】座位数:{}", trainCode, seatCount); long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode); LOG.info("车次【{}】到站数:{}", trainCode, stationCount); // 3/4 需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * (stationCount-1))张火车票 // int count = (int) (seatCount * (stationCount-1) * 3/4); int count = (int) (seatCount * (stationCount-1)); LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count); skToken.setCount(count); skTokenMapper.insert(skToken); } public void save(SkTokenSaveReq req) { DateTime now = DateTime.now(); SkToken skToken = BeanUtil.copyProperties(req, SkToken.class); if (ObjectUtil.isNull(skToken.getId())) { skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); skTokenMapper.insert(skToken); } else { skToken.setUpdateTime(now); skTokenMapper.updateByPrimaryKey(skToken); } } public PageResp<SkTokenQueryResp> queryList(SkTokenQueryReq req) { SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.setOrderByClause("id desc"); SkTokenExample.Criteria criteria = skTokenExample.createCriteria(); LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<SkToken> skTokenList = skTokenMapper.selectByExample(skTokenExample); PageInfo<SkToken> pageInfo = new PageInfo<>(skTokenList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<SkTokenQueryResp> list = BeanUtil.copyToList(skTokenList, SkTokenQueryResp.class); PageResp<SkTokenQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { skTokenMapper.deleteByPrimaryKey(id); } /** * 获取令牌 */ public boolean validSkToken(Date date, String trainCode, Long memberId) { LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode); // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高 int updateCount = skTokenMapperCust.decrease(date, trainCode); if (updateCount > 0) { return true; } else { return false; } } }
-
ConfirmOrderService.java
@SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock") public void doConfirm(ConfirmOrderDoReq req) { // 校验令牌余量 boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId()); if (validSkToken) { LOG.info("令牌校验通过"); } else { LOG.info("令牌校验不通过"); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL); } String lockKey = DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode(); // setIfAbsent就是对应redis的setnx Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到锁了!lockKey:{}", lockKey); } else { // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 LOG.info("很遗憾,没抢到锁!lockKey:{}", lockKey); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); } // RLock lock = null; /* 关于红锁,看16.7节: A B C D E 1: A B C D E 2: C D E 3: C */ try { // // 使用redisson,自带看门狗 // lock = redissonClient.getLock(lockKey); // // // 红锁的写法 // // RedissonRedLock redissonRedLock = new RedissonRedLock(lock, lock, lock); // // boolean tryLock1 = redissonRedLock.tryLock(0, TimeUnit.SECONDS); // // /** // waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false // leaseTime – lease time 锁时长,即n秒后自动释放锁 // time unit – time unit 时间单位 // */ // // boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗 // boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗 // if (tryLock) { // LOG.info("恭喜,抢到锁了!"); // // 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果 // // for (int i = 0; i < 30; i++) { // // Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey); // // LOG.info("锁过期时间还有:{}", expire); // // Thread.sleep(1000); // // } // } else { // // 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试 // LOG.info("很遗憾,没抢到锁"); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL); // } // 省略业务数据校验,如:车次是否存在,余票是否存在,车次是否在有效期内,tickets条数>0,同乘客同车次是否已买过 Date date = req.getDate(); String trainCode = req.getTrainCode(); String start = req.getStart(); String end = req.getEnd(); List<ConfirmOrderTicketReq> tickets = req.getTickets(); // 保存确认订单表,状态初始 DateTime now = DateTime.now(); ConfirmOrder confirmOrder = new ConfirmOrder(); confirmOrder.setId(SnowUtil.getSnowflakeNextId()); confirmOrder.setCreateTime(now); confirmOrder.setUpdateTime(now); confirmOrder.setMemberId(LoginMemberContext.getId()); confirmOrder.setDate(date); confirmOrder.setTrainCode(trainCode); confirmOrder.setStart(start); confirmOrder.setEnd(end); confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId()); confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode()); confirmOrder.setTickets(JSON.toJSONString(tickets)); confirmOrderMapper.insert(confirmOrder); // 查出余票记录,需要得到真实的库存 DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end); LOG.info("查出余票记录:{}", dailyTrainTicket); // 预扣减余票数量,并判断余票是否足够 reduceTickets(req, dailyTrainTicket); // 最终的选座结果 List<DailyTrainSeat> finalSeatList = new ArrayList<>(); // 计算相对第一个座位的偏移值 // 比如选择的是C1,D2,则偏移值是:[0,5] // 比如选择的是A1,B1,C1,则偏移值是:[0,1,2] ConfirmOrderTicketReq ticketReq0 = tickets.get(0); if (StrUtil.isNotBlank(ticketReq0.getSeat())) { LOG.info("本次购票有选座"); // 查出本次选座的座位类型都有哪些列,用于计算所选座位与第一个座位的偏离值 List<SeatColEnum> colEnumList = SeatColEnum.getColsByType(ticketReq0.getSeatTypeCode()); LOG.info("本次选座的座位类型包含的列:{}", colEnumList); // 组成和前端两排选座一样的列表,用于作参照的座位列表,例:referSeatList = {A1, C1, D1, F1, A2, C2, D2, F2} List<String> referSeatList = new ArrayList<>(); for (int i = 1; i <= 2; i++) { for (SeatColEnum seatColEnum : colEnumList) { referSeatList.add(seatColEnum.getCode() + i); } } LOG.info("用于作参照的两排座位:{}", referSeatList); List<Integer> offsetList = new ArrayList<>(); // 绝对偏移值,即:在参照座位列表中的位置 List<Integer> aboluteOffsetList = new ArrayList<>(); for (ConfirmOrderTicketReq ticketReq : tickets) { int index = referSeatList.indexOf(ticketReq.getSeat()); aboluteOffsetList.add(index); } LOG.info("计算得到所有座位的绝对偏移值:{}", aboluteOffsetList); for (Integer index : aboluteOffsetList) { int offset = index - aboluteOffsetList.get(0); offsetList.add(offset); } LOG.info("计算得到所有座位的相对第一个座位的偏移值:{}", offsetList); getSeat(finalSeatList, date, trainCode, ticketReq0.getSeatTypeCode(), ticketReq0.getSeat().split("")[0], // 从A1得到A offsetList, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } else { LOG.info("本次购票没有选座"); for (ConfirmOrderTicketReq ticketReq : tickets) { getSeat(finalSeatList, date, trainCode, ticketReq.getSeatTypeCode(), null, null, dailyTrainTicket.getStartIndex(), dailyTrainTicket.getEndIndex() ); } } LOG.info("最终选座:{}", finalSeatList); // 选中座位后事务处理: // 座位表修改售卖情况sell; // 余票详情表修改余票; // 为会员增加购票记录 // 更新确认订单为成功 try { afterConfirmOrderService.afterDoConfirm(dailyTrainTicket, finalSeatList, tickets, confirmOrder); } catch (Exception e) { LOG.error("保存购票信息失败", e); throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_EXCEPTION); } // LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); // redisTemplate.delete(lockKey); // } catch (InterruptedException e) { // LOG.error("购票异常", e); } finally { // try finally不能包含加锁的那段代码,否则加锁失败会走到finally里,从而释放别的线程的锁 LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey); redisTemplate.delete(lockKey); // LOG.info("购票流程结束,释放锁!"); // if (null != lock && lock.isHeldByCurrentThread()) { // lock.unlock(); // } } }
-
测试
此时我后台修改D2的令牌数量为1
然后我买两张票,第一次成功,会将令牌数减到0,再买票第二次就失败了,因为令牌数量是0,不会修改update,影响行数是0,所以校验失败
目前的问题:
- 目前的效果只能说是提高了判断库存的效率,但是假如令牌数设置较少,且存在有人刷票,可能导致票卖的更少了。
- 目前令牌维度只是做了车次+日期,并没有针对各种座位类型来设置令牌数,可能导致大家都抢某种车票,导致其他类型车票没有拿到令牌,就直接少卖了
4.使用令牌锁防止机器人抢票
解决同一个人不断抢票(机器人)的问题,解决办法:使用redis分布式锁,将 车次 + 日期 + memberId 作为key,使这个key(锁)维持一定时间,这个时间内不能再抢票
-
SkTokenService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.ObjectUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.SkToken; import com.neilxu.train.business.domain.SkTokenExample; import com.neilxu.train.business.mapper.SkTokenMapper; import com.neilxu.train.business.mapper.cust.SkTokenMapperCust; import com.neilxu.train.business.req.SkTokenQueryReq; import com.neilxu.train.business.req.SkTokenSaveReq; import com.neilxu.train.business.resp.SkTokenQueryResp; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; @Service public class SkTokenService { private static final Logger LOG = LoggerFactory.getLogger(SkTokenService.class); @Resource private SkTokenMapper skTokenMapper; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private DailyTrainStationService dailyTrainStationService; @Resource private SkTokenMapperCust skTokenMapperCust; @Autowired private StringRedisTemplate redisTemplate; /** * 初始化 */ public void genDaily(Date date, String trainCode) { LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode); SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); skTokenMapper.deleteByExample(skTokenExample); DateTime now = DateTime.now(); SkToken skToken = new SkToken(); skToken.setDate(date); skToken.setTrainCode(trainCode); skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); int seatCount = dailyTrainSeatService.countSeat(date, trainCode); LOG.info("车次【{}】座位数:{}", trainCode, seatCount); long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode); LOG.info("车次【{}】到站数:{}", trainCode, stationCount); // 3/4 需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * (stationCount-1))张火车票 // int count = (int) (seatCount * (stationCount-1) * 3/4); int count = (int) (seatCount * (stationCount-1)); LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count); skToken.setCount(count); skTokenMapper.insert(skToken); } public void save(SkTokenSaveReq req) { DateTime now = DateTime.now(); SkToken skToken = BeanUtil.copyProperties(req, SkToken.class); if (ObjectUtil.isNull(skToken.getId())) { skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); skTokenMapper.insert(skToken); } else { skToken.setUpdateTime(now); skTokenMapper.updateByPrimaryKey(skToken); } } public PageResp<SkTokenQueryResp> queryList(SkTokenQueryReq req) { SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.setOrderByClause("id desc"); SkTokenExample.Criteria criteria = skTokenExample.createCriteria(); LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<SkToken> skTokenList = skTokenMapper.selectByExample(skTokenExample); PageInfo<SkToken> pageInfo = new PageInfo<>(skTokenList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<SkTokenQueryResp> list = BeanUtil.copyToList(skTokenList, SkTokenQueryResp.class); PageResp<SkTokenQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { skTokenMapper.deleteByPrimaryKey(id); } /** * 校验令牌 */ public boolean validSkToken(Date date, String trainCode, Long memberId) { LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode); // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证 String lockKey = DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId; Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey); } else { LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey); return false; } // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高 int updateCount = skTokenMapperCust.decrease(date, trainCode); if (updateCount > 0) { return true; } else { return false; } } }
-
com.neilxu.train.common.exception.BusinessExceptionEnum
优化提示
CONFIRM_ORDER_SK_TOKEN_FAIL("当前抢票人数过多,请5秒后重试"),
-
测试
上述代码修改后效果:每个用户对于某个车次+日期,每5秒内会被令牌锁锁住,期间访问会直接返回失败
5.优化功能
1.对放入redis的lockKey加一个前缀,避免不同的业务共用一个key
-
business/src/main/java/com/jiawa/train/business/enums/LockKeyPreEnum.java
package com.neilxu.train.business.enums; public enum RedisKeyPreEnum { CONFIRM_ORDER("LOCK_CONFIRM_ORDER", "购票锁"), SK_TOKEN("LOCK_SK_TOKEN", "令牌锁"), SK_TOKEN_COUNT("SK_TOKEN_COUNT", "令牌数"); private final String code; private final String desc; RedisKeyPreEnum(String code, String desc) { this.code = code; this.desc = desc; } public String getCode() { return code; } public String getDesc() { return desc; } }
-
ConfirmOrderService
String lockKey = RedisKeyPreEnum.CONFIRM_ORDER + "-" + DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
-
SkTokenService.java
String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId; Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS);
2.使用缓存加速令牌锁功能
使用redis缓存令牌数量,原先的判断令牌数量减去令牌数量的操作,从直接对数据库操作改成了对redis做操作,并且每5个令牌才将redis同步到数据库(操作数据库)
**问:**validSkToken这个方法里面将令牌数量从数据库中同步到缓存中的时候会不会有并发问题?
**回答:**会有的,比如两个线程都读到缓存没数据,都去数据库取数据,此时有可能造成不一致。但并发问题要分两面看,防止并发读写而加锁,可以保证数据一致性,但是需要引入额外的开销的,有可能会减少吞吐量。而令牌这个场景,对数据不是强一致性的,比如设置了2000令牌,实际发出去了2010令牌有关系吗?所以不需要加锁。
这个问题和事务类似,不是所有对数据的操作都应该加事务,比如我们的购票,假设流程上有下单和扣减库存,这时就不能加事务,不能因为扣库存不成功,而把下单也回滚了,订单是很重要的信息,一定要留在表里,不能轻易删除或回滚。
-
SkTokenMapperCust.java
package com.neilxu.train.business.mapper.cust; import org.apache.ibatis.annotations.Param; import java.util.Date; public interface SkTokenMapperCust { int decrease(@Param("date") Date date, @Param("trainCode") String trainCode,@Param("decreaseCount") int decreaseCount); }
-
SkTokenMapperCust.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="com.neilxu.train.business.mapper.cust.SkTokenMapperCust"> <update id="decrease"> update sk_token set `count` = if (`count` < #{decreaseCount}, 0, `count` - #{decreaseCount}) where `date` = #{date} and train_code = #{trainCode} and `count` > 0 </update> </mapper>
-
SkTokenService.java
package com.neilxu.train.business.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.date.DateTime; import cn.hutool.core.date.DateUtil; import cn.hutool.core.util.ObjectUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.neilxu.train.business.domain.SkToken; import com.neilxu.train.business.domain.SkTokenExample; import com.neilxu.train.business.enums.RedisKeyPreEnum; import com.neilxu.train.business.mapper.SkTokenMapper; import com.neilxu.train.business.mapper.cust.SkTokenMapperCust; import com.neilxu.train.business.req.SkTokenQueryReq; import com.neilxu.train.business.req.SkTokenSaveReq; import com.neilxu.train.business.resp.SkTokenQueryResp; import com.neilxu.train.common.resp.PageResp; import com.neilxu.train.common.util.SnowUtil; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.Date; import java.util.List; import java.util.concurrent.TimeUnit; @Service public class SkTokenService { private static final Logger LOG = LoggerFactory.getLogger(SkTokenService.class); @Resource private SkTokenMapper skTokenMapper; @Resource private DailyTrainSeatService dailyTrainSeatService; @Resource private DailyTrainStationService dailyTrainStationService; @Resource private SkTokenMapperCust skTokenMapperCust; @Autowired private StringRedisTemplate redisTemplate; /** * 初始化 */ public void genDaily(Date date, String trainCode) { LOG.info("删除日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode); SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); skTokenMapper.deleteByExample(skTokenExample); DateTime now = DateTime.now(); SkToken skToken = new SkToken(); skToken.setDate(date); skToken.setTrainCode(trainCode); skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); int seatCount = dailyTrainSeatService.countSeat(date, trainCode); LOG.info("车次【{}】座位数:{}", trainCode, seatCount); long stationCount = dailyTrainStationService.countByTrainCode(date, trainCode); LOG.info("车次【{}】到站数:{}", trainCode, stationCount); // 3/4 需要根据实际卖票比例来定,一趟火车最多可以卖(seatCount * (stationCount-1))张火车票 // int count = (int) (seatCount * (stationCount-1) * 3/4); int count = (int) (seatCount * (stationCount-1)); LOG.info("车次【{}】初始生成令牌数:{}", trainCode, count); skToken.setCount(count); skTokenMapper.insert(skToken); } public void save(SkTokenSaveReq req) { DateTime now = DateTime.now(); SkToken skToken = BeanUtil.copyProperties(req, SkToken.class); if (ObjectUtil.isNull(skToken.getId())) { skToken.setId(SnowUtil.getSnowflakeNextId()); skToken.setCreateTime(now); skToken.setUpdateTime(now); skTokenMapper.insert(skToken); } else { skToken.setUpdateTime(now); skTokenMapper.updateByPrimaryKey(skToken); } } public PageResp<SkTokenQueryResp> queryList(SkTokenQueryReq req) { SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.setOrderByClause("id desc"); SkTokenExample.Criteria criteria = skTokenExample.createCriteria(); LOG.info("查询页码:{}", req.getPage()); LOG.info("每页条数:{}", req.getSize()); PageHelper.startPage(req.getPage(), req.getSize()); List<SkToken> skTokenList = skTokenMapper.selectByExample(skTokenExample); PageInfo<SkToken> pageInfo = new PageInfo<>(skTokenList); LOG.info("总行数:{}", pageInfo.getTotal()); LOG.info("总页数:{}", pageInfo.getPages()); List<SkTokenQueryResp> list = BeanUtil.copyToList(skTokenList, SkTokenQueryResp.class); PageResp<SkTokenQueryResp> pageResp = new PageResp<>(); pageResp.setTotal(pageInfo.getTotal()); pageResp.setList(list); return pageResp; } public void delete(Long id) { skTokenMapper.deleteByPrimaryKey(id); } /** * 校验令牌 */ public boolean validSkToken(Date date, String trainCode, Long memberId) { LOG.info("会员【{}】获取日期【{}】车次【{}】的令牌开始", memberId, DateUtil.formatDate(date), trainCode); // 先获取令牌锁,再校验令牌余量,防止机器人抢票,lockKey就是令牌,用来表示【谁能做什么】的一个凭证 String lockKey = RedisKeyPreEnum.SK_TOKEN + "-" + DateUtil.formatDate(date) + "-" + trainCode + "-" + memberId; Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, lockKey, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(setIfAbsent)) { LOG.info("恭喜,抢到令牌锁了!lockKey:{}", lockKey); } else { LOG.info("很遗憾,没抢到令牌锁!lockKey:{}", lockKey); return false; } String skTokenCountKey = RedisKeyPreEnum.SK_TOKEN_COUNT + "-" + DateUtil.formatDate(date) + "-" + trainCode; Object skTokenCount = redisTemplate.opsForValue().get(skTokenCountKey); if (skTokenCount != null) { LOG.info("缓存中有该车次令牌大闸的key:{}", skTokenCountKey); Long count = redisTemplate.opsForValue().decrement(skTokenCountKey, 1); redisTemplate.expire(skTokenCountKey, 60, TimeUnit.SECONDS); if (count < 0L) { LOG.error("获取令牌失败:{}", skTokenCountKey); return false; } else { LOG.info("获取令牌后,令牌余数:{}", count); // 每获取5个令牌更新一次数据库 if (count % 5 == 0) { skTokenMapperCust.decrease(date, trainCode, 5); } return true; } } else { LOG.info("缓存中没有该车次令牌大闸的key:{}", skTokenCountKey); // 检查是否还有令牌 SkTokenExample skTokenExample = new SkTokenExample(); skTokenExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode); List<SkToken> tokenCountList = skTokenMapper.selectByExample(skTokenExample); if (CollUtil.isEmpty(tokenCountList)) { LOG.info("找不到日期【{}】车次【{}】的令牌记录", DateUtil.formatDate(date), trainCode); return false; } SkToken skToken = tokenCountList.get(0); if (skToken.getCount() <= 0) { LOG.info("日期【{}】车次【{}】的令牌余量为0", DateUtil.formatDate(date), trainCode); return false; } // 令牌还有余量 // 令牌余数-1 Integer count = skToken.getCount() - 1; skToken.setCount(count); LOG.info("将该车次令牌大闸放入缓存中,key: {}, count: {}", skTokenCountKey, count); // 不需要更新数据库,只要放缓存即可 redisTemplate.opsForValue().set(skTokenCountKey, String.valueOf(count), 60, TimeUnit.SECONDS); // skTokenMapper.updateByPrimaryKey(skToken); return true; } // 令牌约等于库存,令牌没有了,就不再卖票,不需要再进入购票主流程去判断库存,判断令牌肯定比判断库存效率高 // int updateCount = skTokenMapperCust.decrease(date, trainCode, 1); // if (updateCount > 0) { // return true; // } else { // return false; // } } }
-
测试
每下单后,redis缓存中的令牌余量是5的倍数的话,就同步数据库,效果是正确的
注意:
目前还存在问题,买票不一定是只买一张,我同时买多张票,他在目前这个场景对应的令牌仍然是1张,但是这个并不一定需要修改,令牌数和余票数并不需要保证强一致性,后续可以通过定时任务优化,使得令牌数和余票数保持一定程度的同步
3.增加验证码削弱瞬时高峰并防机器人刷票
光靠后端防止机器人刷票,不从前端阻止不停点击抢票访问后端接口的话,后端仍然会有很大的压力,这里我们再通过验证码方式来防止机器人刷票
-
business增加图验证码接口
-
新增依赖
父pom
<!-- 图形验证码 升级到JDK17后,排除掉javax.servlet-api包 --> <dependency> <groupId>com.github.penggle</groupId> <artifactId>kaptcha</artifactId> <version>2.3.2</version> <exclusions> <exclusion> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> </exclusion> </exclusions> </dependency>
business/pom.xml
<!-- 图形验证码 --> <dependency> <groupId>com.github.penggle</groupId> <artifactId>kaptcha</artifactId> </dependency>
-
com.neilxu.train.business.config.KaptchaNoBackhround
package com.neilxu.train.business.config; import com.google.code.kaptcha.BackgroundProducer; import com.google.code.kaptcha.util.Configurable; import java.awt.*; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; public class KaptchaNoBackhround extends Configurable implements BackgroundProducer { public KaptchaNoBackhround(){ } @Override public BufferedImage addBackground(BufferedImage baseImage) { int width = baseImage.getWidth(); int height = baseImage.getHeight(); BufferedImage imageWithBackground = new BufferedImage(width, height, 1); Graphics2D graph = (Graphics2D)imageWithBackground.getGraphics(); graph.fill(new Rectangle2D.Double(0.0D, 0.0D, (double)width, (double)height)); graph.drawImage(baseImage, 0, 0, null); return imageWithBackground; } }
-
com.neilxu.train.business.config.KaptchaWaterRipple
package com.neilxu.train.business.config; import com.google.code.kaptcha.GimpyEngine; import com.google.code.kaptcha.NoiseProducer; import com.google.code.kaptcha.util.Configurable; import com.jhlabs.image.RippleFilter; import java.awt.*; import java.awt.image.BufferedImage; import java.awt.image.ImageObserver; import java.util.Random; public class KaptchaWaterRipple extends Configurable implements GimpyEngine { public KaptchaWaterRipple(){} @Override public BufferedImage getDistortedImage(BufferedImage baseImage) { NoiseProducer noiseProducer = this.getConfig().getNoiseImpl(); BufferedImage distortedImage = new BufferedImage(baseImage.getWidth(), baseImage.getHeight(), 2); Graphics2D graph = (Graphics2D)distortedImage.getGraphics(); Random rand = new Random(); RippleFilter rippleFilter = new RippleFilter(); rippleFilter.setXAmplitude(7.6F); rippleFilter.setYAmplitude(rand.nextFloat() + 1.0F); rippleFilter.setEdgeAction(1); BufferedImage effectImage = rippleFilter.filter(baseImage, (BufferedImage)null); graph.drawImage(effectImage, 0, 0, (Color)null, (ImageObserver)null); graph.dispose(); noiseProducer.makeNoise(distortedImage, 0.1F, 0.1F, 0.25F, 0.25F); noiseProducer.makeNoise(distortedImage, 0.1F, 0.25F, 0.5F, 0.9F); return distortedImage; } }
-
com.neilxu.train.business.config.KaptchaConfig
package com.neilxu.train.business.config; import com.google.code.kaptcha.impl.DefaultKaptcha; import com.google.code.kaptcha.util.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Properties; @Configuration public class KaptchaConfig { @Bean public DefaultKaptcha getDefaultKaptcha() { DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); Properties properties = new Properties(); properties.setProperty("kaptcha.border", "no"); // properties.setProperty("kaptcha.border.color", "105,179,90"); properties.setProperty("kaptcha.textproducer.font.color", "blue"); properties.setProperty("kaptcha.image.width", "90"); properties.setProperty("kaptcha.image.height", "28"); properties.setProperty("kaptcha.textproducer.font.size", "20"); properties.setProperty("kaptcha.session.key", "code"); properties.setProperty("kaptcha.textproducer.char.length", "4"); properties.setProperty("kaptcha.textproducer.font.names", "Arial"); properties.setProperty("kaptcha.noise.color", "255,96,0"); properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise"); // properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple"); properties.setProperty("kaptcha.obscurificator.impl", KaptchaWaterRipple.class.getName()); properties.setProperty("kaptcha.background.impl", KaptchaNoBackhround.class.getName()); Config config = new Config(properties); defaultKaptcha.setConfig(config); return defaultKaptcha; } @Bean public DefaultKaptcha getWebKaptcha() { DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); Properties properties = new Properties(); properties.setProperty("kaptcha.border", "no"); // properties.setProperty("kaptcha.border.color", "105,179,90"); properties.setProperty("kaptcha.textproducer.font.color", "blue"); properties.setProperty("kaptcha.image.width", "90"); properties.setProperty("kaptcha.image.height", "45"); properties.setProperty("kaptcha.textproducer.font.size", "30"); properties.setProperty("kaptcha.session.key", "code"); properties.setProperty("kaptcha.textproducer.char.length", "4"); properties.setProperty("kaptcha.textproducer.font.names", "Arial"); properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.NoNoise"); properties.setProperty("kaptcha.obscurificator.impl", KaptchaWaterRipple.class.getName()); Config config = new Config(properties); defaultKaptcha.setConfig(config); return defaultKaptcha; } }
-
com.neilxu.train.business.controller.KaptchaController
package com.neilxu.train.business.controller; import com.google.code.kaptcha.impl.DefaultKaptcha; import jakarta.annotation.Resource; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.util.concurrent.TimeUnit; @RestController @RequestMapping("/kaptcha") public class KaptchaController { @Qualifier("getDefaultKaptcha") @Autowired DefaultKaptcha defaultKaptcha; @Resource public StringRedisTemplate stringRedisTemplate; @GetMapping("/image-code/{imageCodeToken}") public void imageCode(@PathVariable(value = "imageCodeToken") String imageCodeToken, HttpServletResponse httpServletResponse) throws Exception{ ByteArrayOutputStream jpegOutputStream = new ByteArrayOutputStream(); try { // 生成验证码字符串 String createText = defaultKaptcha.createText(); // 将生成的验证码放入redis缓存中,后续验证的时候用到 stringRedisTemplate.opsForValue().set(imageCodeToken, createText, 300, TimeUnit.SECONDS); // 使用验证码字符串生成验证码图片 BufferedImage challenge = defaultKaptcha.createImage(createText); ImageIO.write(challenge, "jpg", jpegOutputStream); } catch (IllegalArgumentException e) { httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND); return; } // 定义response输出类型为image/jpeg类型,使用response输出流输出图片的byte数组 byte[] captchaChallengeAsJpeg = jpegOutputStream.toByteArray(); httpServletResponse.setHeader("Cache-Control", "no-store"); httpServletResponse.setHeader("Pragma", "no-cache"); httpServletResponse.setDateHeader("Expires", 0); httpServletResponse.setContentType("image/jpeg"); ServletOutputStream responseOutputStream = httpServletResponse.getOutputStream(); responseOutputStream.write(captchaChallengeAsJpeg); responseOutputStream.flush(); responseOutputStream.close(); } }
-
com.neilxu.train.gateway.config.LoginMemberFilter
// 排除不需要拦截的请求 if (path.contains("/admin") || path.contains("/redis") || path.contains("/hello") || path.contains("/member/member/login") || path.contains("/member/member/send-code") || path.contains("/business/kaptcha")) { LOG.info("不需要登录验证:{}", path); return chain.filter(exchange); } else { LOG.info("需要登录验证:{}", path); }
-
测试
http/business-kaptcha.http
GET http://localhost:8000/business/kaptcha/image-code/123 Accept: application/json ###
-
-
购票页面,调用购票接口之前,显示验证码
web/src/views/main/order.vue
<template> <div class="order-train"> <span class="order-train-main">{{dailyTrainTicket.date}}</span> <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> <div class="order-train-ticket"> <span v-for="item in seatTypes" :key="item.type"> <span>{{item.desc}}</span>: <span class="order-train-ticket-main">{{item.price}}¥</span> <span class="order-train-ticket-main">{{item.count}}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" /> <div class="order-tickets"> <a-row class="order-tickets-header" v-if="tickets.length > 0"> <a-col :span="2">乘客</a-col> <a-col :span="6">身份证</a-col> <a-col :span="4">票种</a-col> <a-col :span="4">座位类型</a-col> </a-row> <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId"> <a-col :span="2">{{ticket.passengerName}}</a-col> <a-col :span="6">{{ticket.passengerIdCard}}</a-col> <a-col :span="4"> <a-select v-model:value="ticket.passengerType" style="width: 100%"> <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code"> {{item.desc}} </a-select-option> </a-select> </a-col> <a-col :span="4"> <a-select v-model:value="ticket.seatTypeCode" style="width: 100%"> <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code"> {{item.desc}} </a-select-option> </a-select> </a-col> </a-row> </div> <div v-if="tickets.length > 0"> <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button> </div> <a-modal v-model:visible="visible" title="请核对以下信息" style="top: 50px; width: 800px" ok-text="确认" cancel-text="取消" @ok="showImageCodeModal"> <div class="order-tickets"> <a-row class="order-tickets-header" v-if="tickets.length > 0"> <a-col :span="3">乘客</a-col> <a-col :span="15">身份证</a-col> <a-col :span="3">票种</a-col> <a-col :span="3">座位类型</a-col> </a-row> <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId"> <a-col :span="3">{{ticket.passengerName}}</a-col> <a-col :span="15">{{ticket.passengerIdCard}}</a-col> <a-col :span="3"> <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code"> <span v-if="item.code === ticket.passengerType"> {{item.desc}} </span> </span> </a-col> <a-col :span="3"> <span v-for="item in seatTypes" :key="item.code"> <span v-if="item.code === ticket.seatTypeCode"> {{item.desc}} </span> </span> </a-col> </a-row> <br/> <div v-if="chooseSeatType === 0" style="color: red;"> 您购买的车票不支持选座 <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div> <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div> </div> <div v-else style="text-align: center"> <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code" v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" /> <div v-if="tickets.length > 1"> <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code" v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" /> </div> <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div> </div> <!--<br/>--> <!--最终购票:{{tickets}}--> <!--最终选座:{{chooseSeatObj}}--> </div> </a-modal> <!-- 验证码 --> <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false" style="top: 50px; width: 400px"> <p style="text-align: center; font-weight: bold; font-size: 18px">使用验证码削弱瞬时高峰</p> <p> <a-input v-model:value="imageCode" placeholder="图片验证码"> <template #suffix> <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/> </template> </a-input> </p> <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button> </a-modal> </template> <script> import {defineComponent, ref, onMounted, watch, computed} from 'vue'; import axios from "axios"; import {notification} from "ant-design-vue"; export default defineComponent({ name: "order-view", setup() { const passengers = ref([]); const passengerOptions = ref([]); const passengerChecks = ref([]); const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {}; console.log("下单的车次信息", dailyTrainTicket); const SEAT_TYPE = window.SEAT_TYPE; console.log(SEAT_TYPE) // 本车次提供的座位类型seatTypes,含票价,余票等信息,例: // { // type: "YDZ", // code: "1", // desc: "一等座", // count: "100", // price: "50", // } // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx] const seatTypes = []; for (let KEY in SEAT_TYPE) { let key = KEY.toLowerCase(); if (dailyTrainTicket[key] >= 0) { seatTypes.push({ type: KEY, code: SEAT_TYPE[KEY]["code"], desc: SEAT_TYPE[KEY]["desc"], count: dailyTrainTicket[key], price: dailyTrainTicket[key + 'Price'], }) } } console.log("本车次提供的座位:", seatTypes) // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票 // { // passengerId: 123, // passengerType: "1", // passengerName: "张三", // passengerIdCard: "12323132132", // seatTypeCode: "1", // seat: "C1" // } const tickets = ref([]); const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY; const visible = ref(false); // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表 watch(() => passengerChecks.value, (newVal, oldVal)=>{ console.log("勾选乘客发生变化", newVal, oldVal) // 每次有变化时,把购票列表清空,重新构造列表 tickets.value = []; passengerChecks.value.forEach((item) => tickets.value.push({ passengerId: item.id, passengerType: item.type, seatTypeCode: seatTypes[0].code, passengerName: item.name, passengerIdCard: item.idCard })) }, {immediate: true}); // 0:不支持选座;1:选一等座;2:选二等座 const chooseSeatType = ref(0); // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF const SEAT_COL_ARRAY = computed(() => { return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value); }); // 选择的座位 // { // A1: false, C1: true,D1: false, F1: false, // A2: false, C2: false,D2: true, F2: false // } const chooseSeatObj = ref({}); watch(() => SEAT_COL_ARRAY.value, () => { chooseSeatObj.value = {}; for (let i = 1; i <= 2; i++) { SEAT_COL_ARRAY.value.forEach((item) => { chooseSeatObj.value[item.code + i] = false; }) } console.log("初始化两排座位,都是未选中:", chooseSeatObj.value); }, {immediate: true}); const handleQueryPassenger = () => { axios.get("/member/passenger/query-mine").then((response) => { let data = response.data; if (data.success) { passengers.value = data.content; passengers.value.forEach((item) => passengerOptions.value.push({ label: item.name, value: item })) } else { notification.error({description: data.message}); } }); }; const finishCheckPassenger = () => { console.log("购票列表:", tickets.value); if (tickets.value.length > 5) { notification.error({description: '最多只能购买5张车票'}); return; } // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足 // 前端校验不一定准,但前端校验可以减轻后端很多压力 // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存 let seatTypesTemp = Tool.copy(seatTypes); for (let i = 0; i < tickets.value.length; i++) { let ticket = tickets.value[i]; for (let j = 0; j < seatTypesTemp.length; j++) { let seatType = seatTypesTemp[j]; // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验 if (ticket.seatTypeCode === seatType.code) { seatType.count--; if (seatType.count < 0) { notification.error({description: seatType.desc + '余票不足'}); return; } } } } console.log("前端余票校验通过"); // 判断是否支持选座,只有纯一等座和纯二等座支持选座 // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2] let ticketSeatTypeCodes = []; for (let i = 0; i < tickets.value.length; i++) { let ticket = tickets.value[i]; ticketSeatTypeCodes.push(ticket.seatTypeCode); } // 为购票列表中的所有座位类型去重:[1, 2] const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes)); console.log("选好的座位类型:", ticketSeatTypeCodesSet); if (ticketSeatTypeCodesSet.length !== 1) { console.log("选了多种座位,不支持选座"); chooseSeatType.value = 0; } else { // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位) if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) { console.log("一等座选座"); chooseSeatType.value = SEAT_TYPE.YDZ.code; } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) { console.log("二等座选座"); chooseSeatType.value = SEAT_TYPE.EDZ.code; } else { console.log("不是一等座或二等座,不支持选座"); chooseSeatType.value = 0; } // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票 if (chooseSeatType.value !== 0) { for (let i = 0; i < seatTypes.length; i++) { let seatType = seatTypes[i]; // 找到同类型座位 if (ticketSeatTypeCodesSet[0] === seatType.code) { // 判断余票,小于20张就不支持选座 if (seatType.count < 20) { console.log("余票小于20张就不支持选座") chooseSeatType.value = 0; break; } } } } } // 弹出确认界面 visible.value = true; }; const handleOk = () => { if (Tool.isEmpty(imageCode.value)) { notification.error({description: '验证码不能为空'}); return; } console.log("选好的座位:", chooseSeatObj.value); // 设置每张票的座位 // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍 for (let i = 0; i < tickets.value.length; i++) { tickets.value[i].seat = null; } let i = -1; // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1) for (let key in chooseSeatObj.value) { if (chooseSeatObj.value[key]) { i++; if (i > tickets.value.length - 1) { notification.error({description: '所选座位数大于购票数'}); return; } tickets.value[i].seat = key; } } if (i > -1 && i < (tickets.value.length - 1)) { notification.error({description: '所选座位数小于购票数'}); return; } console.log("最终购票:", tickets.value); axios.post("/business/confirm-order/do", { dailyTrainTicketId: dailyTrainTicket.id, date: dailyTrainTicket.date, trainCode: dailyTrainTicket.trainCode, start: dailyTrainTicket.start, end: dailyTrainTicket.end, tickets: tickets.value }).then((response) => { let data = response.data; if (data.success) { notification.success({description: "下单成功!"}); } else { notification.error({description: data.message}); } }); } /* ------------------- 验证码 --------------------- */ const imageCodeModalVisible = ref(); const imageCodeToken = ref(); const imageCodeSrc = ref(); const imageCode = ref(); /** * 加载图形验证码 */ const loadImageCode = () => { imageCodeToken.value = Tool.uuid(8); imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value; }; const showImageCodeModal = () => { loadImageCode(); imageCodeModalVisible.value = true; }; onMounted(() => { handleQueryPassenger(); }); return { passengers, dailyTrainTicket, seatTypes, passengerOptions, passengerChecks, tickets, PASSENGER_TYPE_ARRAY, visible, finishCheckPassenger, chooseSeatType, chooseSeatObj, SEAT_COL_ARRAY, handleOk, imageCodeToken, imageCodeSrc, imageCode, showImageCodeModal, imageCodeModalVisible, loadImageCode }; }, }); </script> <style> .order-train .order-train-main { font-size: 18px; font-weight: bold; } .order-train .order-train-ticket { margin-top: 15px; } .order-train .order-train-ticket .order-train-ticket-main { color: red; font-size: 18px; } .order-tickets { margin: 10px 0; } .order-tickets .ant-col { padding: 5px 10px; } .order-tickets .order-tickets-header { background-color: cornflowerblue; border: solid 1px cornflowerblue; color: white; font-size: 16px; padding: 5px 0; } .order-tickets .order-tickets-row { border: solid 1px cornflowerblue; border-top: none; vertical-align: middle; line-height: 30px; } .order-tickets .choose-seat-item { margin: 5px 5px; } </style>
-
购票接口,增加验证码校验
-
com.neilxu.train.business.req.ConfirmOrderDoReq
package com.neilxu.train.business.req; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.Date; import java.util.List; @Data public class ConfirmOrderDoReq { /** * 会员id */ private Long memberId; /** * 日期 */ @JsonFormat(pattern = "yyyy-MM-dd",timezone = "GMT+8") @NotNull(message = "【日期】不能为空") private Date date; /** * 车次编号 */ @NotBlank(message = "【车次编号】不能为空") private String trainCode; /** * 出发站 */ @NotBlank(message = "【出发站】不能为空") private String start; /** * 到达站 */ @NotBlank(message = "【到达站】不能为空") private String end; /** * 余票ID */ @NotNull(message = "【余票ID】不能为空") private Long dailyTrainTicketId; /** * 车票 */ @NotEmpty(message = "【车票】不能为空") private List<ConfirmOrderTicketReq> tickets; /** * 验证码 */ @NotBlank(message = "【图片验证码】不能为空") private String imageCode; /** * 图片验证码token */ @NotBlank(message = "【图片验证码】参数非法") private String imageCodeToken; @Override public String toString() { return "ConfirmOrderDoReq{" + "memberId=" + memberId + ", date=" + date + ", trainCode='" + trainCode + '\'' + ", start='" + start + '\'' + ", end='" + end + '\'' + ", dailyTrainTicketId=" + dailyTrainTicketId + ", tickets=" + tickets + ", imageCode='" + imageCode + '\'' + ", imageCodeToken='" + imageCodeToken + '\'' + '}'; } }
-
ConfirmOrderController.java
import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.alibaba.csp.sentinel.slots.block.BlockException; import com.neilxu.train.business.req.ConfirmOrderDoReq; import com.neilxu.train.business.service.ConfirmOrderService; import com.neilxu.train.common.exception.BusinessExceptionEnum; import com.neilxu.train.common.resp.CommonResp; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.util.ObjectUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/confirm-order") public class ConfirmOrderController { private static final Logger LOG = LoggerFactory.getLogger(ConfirmOrderController.class); @Resource private ConfirmOrderService confirmOrderService; @Autowired private StringRedisTemplate redisTemplate; // 接口的资源名称不要和接口路径一致,会导致限流后走不到降级方法中 @SentinelResource(value = "confirmOrderDo", blockHandler = "doConfirmBlock") @PostMapping("/do") public CommonResp<Object> doConfirm(@Valid @RequestBody ConfirmOrderDoReq req) { // 图形验证码校验 String imageCodeToken = req.getImageCodeToken(); String imageCode = req.getImageCode(); String imageCodeRedis = redisTemplate.opsForValue().get(imageCodeToken); LOG.info("从redis中获取到的验证码:{}", imageCodeRedis); if (ObjectUtils.isEmpty(imageCodeRedis)) { return new CommonResp<>(false, "验证码已过期", null); } // 验证码校验,大小写忽略,提升体验,比如Oo Vv Ww容易混 if (!imageCodeRedis.equalsIgnoreCase(imageCode)) { return new CommonResp<>(false, "验证码不正确", null); } else { // 验证通过后,移除验证码 redisTemplate.delete(imageCodeToken); } confirmOrderService.doConfirm(req); return new CommonResp<>(); } /** 降级方法,需包含限流方法的所有参数和BlockException参数,且返回值要保持一致 * @param req * @param e */ public CommonResp<Object> doConfirmBlock(ConfirmOrderDoReq req, BlockException e) { LOG.info("ConfirmOrderController购票请求被限流:{}", req); // throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION); CommonResp<Object> commonResp = new CommonResp<>(); commonResp.setSuccess(false); commonResp.setMessage(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION.getDesc()); return commonResp; } }
-
order.vue
<template> <div class="order-train"> <span class="order-train-main">{{ dailyTrainTicket.date }}</span> <span class="order-train-main">{{ dailyTrainTicket.trainCode }}</span>次 <span class="order-train-main">{{ dailyTrainTicket.start }}</span>站 <span class="order-train-main">({{ dailyTrainTicket.startTime }})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{ dailyTrainTicket.end }}</span>站 <span class="order-train-main">({{ dailyTrainTicket.endTime }})</span> <div class="order-train-ticket"> <span v-for="item in seatTypes" :key="item.type"> <span>{{ item.desc }}</span>: <span class="order-train-ticket-main">{{ item.price }}¥</span> <span class="order-train-ticket-main">{{ item.count }}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions"/> <div class="order-tickets"> <a-row class="order-tickets-header" v-if="tickets.length > 0"> <a-col :span="2">乘客</a-col> <a-col :span="6">身份证</a-col> <a-col :span="4">票种</a-col> <a-col :span="4">座位类型</a-col> </a-row> <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId"> <a-col :span="2">{{ ticket.passengerName }}</a-col> <a-col :span="6">{{ ticket.passengerIdCard }}</a-col> <a-col :span="4"> <a-select v-model:value="ticket.passengerType" style="width: 100%"> <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code"> {{ item.desc }} </a-select-option> </a-select> </a-col> <a-col :span="4"> <a-select v-model:value="ticket.seatTypeCode" style="width: 100%"> <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code"> {{ item.desc }} </a-select-option> </a-select> </a-col> </a-row> </div> <div v-if="tickets.length > 0"> <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button> </div> <a-modal v-model:visible="visible" title="请核对以下信息" style="top: 50px; width: 800px" ok-text="确认" cancel-text="取消" @ok="showImageCodeModal"> <div class="order-tickets"> <a-row class="order-tickets-header" v-if="tickets.length > 0"> <a-col :span="3">乘客</a-col> <a-col :span="15">身份证</a-col> <a-col :span="3">票种</a-col> <a-col :span="3">座位类型</a-col> </a-row> <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId"> <a-col :span="3">{{ ticket.passengerName }}</a-col> <a-col :span="15">{{ ticket.passengerIdCard }}</a-col> <a-col :span="3"> <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code"> <span v-if="item.code === ticket.passengerType"> {{ item.desc }} </span> </span> </a-col> <a-col :span="3"> <span v-for="item in seatTypes" :key="item.code"> <span v-if="item.code === ticket.seatTypeCode"> {{ item.desc }} </span> </span> </a-col> </a-row> <br/> <div v-if="chooseSeatType === 0" style="color: red;"> 您购买的车票不支持选座 <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div> <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div> </div> <div v-else style="text-align: center"> <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code" v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc"/> <div v-if="tickets.length > 1"> <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code" v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc"/> </div> <div style="color: #999999">提示:您可以选择{{ tickets.length }}个座位</div> </div> <!--<br/>--> <!--最终购票:{{tickets}}--> <!--最终选座:{{chooseSeatObj}}--> </div> </a-modal> <!-- 验证码 --> <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false" style="top: 50px; width: 400px"> <p style="text-align: center; font-weight: bold; font-size: 18px">使用验证码削弱瞬时高峰</p> <p> <a-input v-model:value="imageCode" placeholder="图片验证码"> <template #suffix> <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/> </template> </a-input> </p> <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button> </a-modal> </template> <script> import {defineComponent, ref, onMounted, watch, computed} from 'vue'; import axios from "axios"; import {notification} from "ant-design-vue"; export default defineComponent({ name: "order-view", setup() { const passengers = ref([]); const passengerOptions = ref([]); const passengerChecks = ref([]); const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {}; console.log("下单的车次信息", dailyTrainTicket); const SEAT_TYPE = window.SEAT_TYPE; console.log(SEAT_TYPE) // 本车次提供的座位类型seatTypes,含票价,余票等信息,例: // { // type: "YDZ", // code: "1", // desc: "一等座", // count: "100", // price: "50", // } // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx] const seatTypes = []; for (let KEY in SEAT_TYPE) { let key = KEY.toLowerCase(); if (dailyTrainTicket[key] >= 0) { seatTypes.push({ type: KEY, code: SEAT_TYPE[KEY]["code"], desc: SEAT_TYPE[KEY]["desc"], count: dailyTrainTicket[key], price: dailyTrainTicket[key + 'Price'], }) } } console.log("本车次提供的座位:", seatTypes) // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票 // { // passengerId: 123, // passengerType: "1", // passengerName: "张三", // passengerIdCard: "12323132132", // seatTypeCode: "1", // seat: "C1" // } const tickets = ref([]); const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY; const visible = ref(false); // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表 watch(() => passengerChecks.value, (newVal, oldVal) => { console.log("勾选乘客发生变化", newVal, oldVal) // 每次有变化时,把购票列表清空,重新构造列表 tickets.value = []; passengerChecks.value.forEach((item) => tickets.value.push({ passengerId: item.id, passengerType: item.type, seatTypeCode: seatTypes[0].code, passengerName: item.name, passengerIdCard: item.idCard })) }, {immediate: true}); // 0:不支持选座;1:选一等座;2:选二等座 const chooseSeatType = ref(0); // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF const SEAT_COL_ARRAY = computed(() => { return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value); }); // 选择的座位 // { // A1: false, C1: true,D1: false, F1: false, // A2: false, C2: false,D2: true, F2: false // } const chooseSeatObj = ref({}); watch(() => SEAT_COL_ARRAY.value, () => { chooseSeatObj.value = {}; for (let i = 1; i <= 2; i++) { SEAT_COL_ARRAY.value.forEach((item) => { chooseSeatObj.value[item.code + i] = false; }) } console.log("初始化两排座位,都是未选中:", chooseSeatObj.value); }, {immediate: true}); const handleQueryPassenger = () => { axios.get("/member/passenger/query-mine").then((response) => { let data = response.data; if (data.success) { passengers.value = data.content; passengers.value.forEach((item) => passengerOptions.value.push({ label: item.name, value: item })) } else { notification.error({description: data.message}); } }); }; const finishCheckPassenger = () => { console.log("购票列表:", tickets.value); if (tickets.value.length > 5) { notification.error({description: '最多只能购买5张车票'}); return; } // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足 // 前端校验不一定准,但前端校验可以减轻后端很多压力 // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存 let seatTypesTemp = Tool.copy(seatTypes); for (let i = 0; i < tickets.value.length; i++) { let ticket = tickets.value[i]; for (let j = 0; j < seatTypesTemp.length; j++) { let seatType = seatTypesTemp[j]; // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验 if (ticket.seatTypeCode === seatType.code) { seatType.count--; if (seatType.count < 0) { notification.error({description: seatType.desc + '余票不足'}); return; } } } } console.log("前端余票校验通过"); // 判断是否支持选座,只有纯一等座和纯二等座支持选座 // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2] let ticketSeatTypeCodes = []; for (let i = 0; i < tickets.value.length; i++) { let ticket = tickets.value[i]; ticketSeatTypeCodes.push(ticket.seatTypeCode); } // 为购票列表中的所有座位类型去重:[1, 2] const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes)); console.log("选好的座位类型:", ticketSeatTypeCodesSet); if (ticketSeatTypeCodesSet.length !== 1) { console.log("选了多种座位,不支持选座"); chooseSeatType.value = 0; } else { // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位) if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) { console.log("一等座选座"); chooseSeatType.value = SEAT_TYPE.YDZ.code; } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) { console.log("二等座选座"); chooseSeatType.value = SEAT_TYPE.EDZ.code; } else { console.log("不是一等座或二等座,不支持选座"); chooseSeatType.value = 0; } // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票 if (chooseSeatType.value !== 0) { for (let i = 0; i < seatTypes.length; i++) { let seatType = seatTypes[i]; // 找到同类型座位 if (ticketSeatTypeCodesSet[0] === seatType.code) { // 判断余票,小于20张就不支持选座 if (seatType.count < 20) { console.log("余票小于20张就不支持选座") chooseSeatType.value = 0; break; } } } } } // 弹出确认界面 visible.value = true; }; const handleOk = () => { if (Tool.isEmpty(imageCode.value)) { notification.error({description: '验证码不能为空'}); return; } console.log("选好的座位:", chooseSeatObj.value); // 设置每张票的座位 // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍 for (let i = 0; i < tickets.value.length; i++) { tickets.value[i].seat = null; } let i = -1; // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1) for (let key in chooseSeatObj.value) { if (chooseSeatObj.value[key]) { i++; if (i > tickets.value.length - 1) { notification.error({description: '所选座位数大于购票数'}); return; } tickets.value[i].seat = key; } } if (i > -1 && i < (tickets.value.length - 1)) { notification.error({description: '所选座位数小于购票数'}); return; } console.log("最终购票:", tickets.value); axios.post("/business/confirm-order/do", { dailyTrainTicketId: dailyTrainTicket.id, date: dailyTrainTicket.date, trainCode: dailyTrainTicket.trainCode, start: dailyTrainTicket.start, end: dailyTrainTicket.end, tickets: tickets.value, imageCodeToken: imageCodeToken.value, imageCode: imageCode.value, }).then((response) => { let data = response.data; if (data.success) { notification.success({description: "下单成功!"}); } else { notification.error({description: data.message}); } }); } /* ------------------- 验证码 --------------------- */ const imageCodeModalVisible = ref(); const imageCodeToken = ref(); const imageCodeSrc = ref(); const imageCode = ref(); /** * 加载图形验证码 */ const loadImageCode = () => { imageCodeToken.value = Tool.uuid(8); imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value; }; const showImageCodeModal = () => { loadImageCode(); imageCodeModalVisible.value = true; }; onMounted(() => { handleQueryPassenger(); }); return { passengers, dailyTrainTicket, seatTypes, passengerOptions, passengerChecks, tickets, PASSENGER_TYPE_ARRAY, visible, finishCheckPassenger, chooseSeatType, chooseSeatObj, SEAT_COL_ARRAY, handleOk, imageCodeToken, imageCodeSrc, imageCode, showImageCodeModal, imageCodeModalVisible, loadImageCode }; }, }); </script> <style> .order-train .order-train-main { font-size: 18px; font-weight: bold; } .order-train .order-train-ticket { margin-top: 15px; } .order-train .order-train-ticket .order-train-ticket-main { color: red; font-size: 18px; } .order-tickets { margin: 10px 0; } .order-tickets .ant-col { padding: 5px 10px; } .order-tickets .order-tickets-header { background-color: cornflowerblue; border: solid 1px cornflowerblue; color: white; font-size: 16px; padding: 5px 0; } .order-tickets .order-tickets-row { border: solid 1px cornflowerblue; border-top: none; vertical-align: middle; line-height: 30px; } .order-tickets .choose-seat-item { margin: 5px 5px; } </style>
-
测试
验证码效果正常
-
**注意:**
目前的问题:
1. 同一时间会有很多人访问获取验证码接口,这里后面采用再加一层前端验证码来削峰
2. 不排除会有人恶意去输入错误的验证码不断访问后端接口来搞破坏
**我想到的解决办法:**
对接口或方法做单人限流(使用redis),课程里已经有对接口做sentinel限流了(但是不是单人限流,发生限流也会造成把别人正常的请求限流了)
4.增加第一层验证码削弱瞬时高峰,减小第二层验证码接口的压力
解决上面的问题1
order.vue
<template>
<div class="order-train">
<span class="order-train-main">{{dailyTrainTicket.date}}</span>
<span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次
<span class="order-train-main">{{dailyTrainTicket.start}}</span>站
<span class="order-train-main">({{dailyTrainTicket.startTime}})</span>
<span class="order-train-main">——</span>
<span class="order-train-main">{{dailyTrainTicket.end}}</span>站
<span class="order-train-main">({{dailyTrainTicket.endTime}})</span>
<div class="order-train-ticket">
<span v-for="item in seatTypes" :key="item.type">
<span>{{item.desc}}</span>:
<span class="order-train-ticket-main">{{item.price}}¥</span>
<span class="order-train-ticket-main">{{item.count}}</span> 张票
</span>
</div>
</div>
<a-divider></a-divider>
<b>勾选要购票的乘客:</b>
<a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
<div class="order-tickets">
<a-row class="order-tickets-header" v-if="tickets.length > 0">
<a-col :span="2">乘客</a-col>
<a-col :span="6">身份证</a-col>
<a-col :span="4">票种</a-col>
<a-col :span="4">座位类型</a-col>
</a-row>
<a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
<a-col :span="2">{{ticket.passengerName}}</a-col>
<a-col :span="6">{{ticket.passengerIdCard}}</a-col>
<a-col :span="4">
<a-select v-model:value="ticket.passengerType" style="width: 100%">
<a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
{{item.desc}}
</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
<a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
{{item.desc}}
</a-select-option>
</a-select>
</a-col>
</a-row>
</div>
<div v-if="tickets.length > 0">
<a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
</div>
<a-modal v-model:visible="visible" title="请核对以下信息"
style="top: 50px; width: 800px"
ok-text="确认" cancel-text="取消"
@ok="showFirstImageCodeModal">
<div class="order-tickets">
<a-row class="order-tickets-header" v-if="tickets.length > 0">
<a-col :span="3">乘客</a-col>
<a-col :span="15">身份证</a-col>
<a-col :span="3">票种</a-col>
<a-col :span="3">座位类型</a-col>
</a-row>
<a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
<a-col :span="3">{{ticket.passengerName}}</a-col>
<a-col :span="15">{{ticket.passengerIdCard}}</a-col>
<a-col :span="3">
<span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
<span v-if="item.code === ticket.passengerType">
{{item.desc}}
</span>
</span>
</a-col>
<a-col :span="3">
<span v-for="item in seatTypes" :key="item.code">
<span v-if="item.code === ticket.seatTypeCode">
{{item.desc}}
</span>
</span>
</a-col>
</a-row>
<br/>
<div v-if="chooseSeatType === 0" style="color: red;">
您购买的车票不支持选座
<div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
<div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
</div>
<div v-else style="text-align: center">
<a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
<div v-if="tickets.length > 1">
<a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
</div>
<div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
</div>
<!--<br/>-->
<!--最终购票:{{tickets}}-->
<!--最终选座:{{chooseSeatObj}}-->
</div>
</a-modal>
<!-- 第二层验证码 后端 -->
<a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
style="top: 50px; width: 400px">
<p style="text-align: center; font-weight: bold; font-size: 18px">
使用服务端验证码削弱瞬时高峰<br/>
防止机器人刷票
</p>
<p>
<a-input v-model:value="imageCode" placeholder="图片验证码">
<template #suffix>
<img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
</template>
</a-input>
</p>
<a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
</a-modal>
<!-- 第一层验证码 纯前端 -->
<a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
style="top: 50px; width: 400px">
<p style="text-align: center; font-weight: bold; font-size: 18px">
使用纯前端验证码削弱瞬时高峰<br/>
减小后端验证码接口的压力
</p>
<p>
<a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
<template #suffix>
{{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
</template>
</a-input>
</p>
<a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
</a-modal>
</template>
<script>
import {defineComponent, ref, onMounted, watch, computed} from 'vue';
import axios from "axios";
import {notification} from "ant-design-vue";
export default defineComponent({
name: "order-view",
setup() {
const passengers = ref([]);
const passengerOptions = ref([]);
const passengerChecks = ref([]);
const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
console.log("下单的车次信息", dailyTrainTicket);
const SEAT_TYPE = window.SEAT_TYPE;
console.log(SEAT_TYPE)
// 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
// {
// type: "YDZ",
// code: "1",
// desc: "一等座",
// count: "100",
// price: "50",
// }
// 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
const seatTypes = [];
for (let KEY in SEAT_TYPE) {
let key = KEY.toLowerCase();
if (dailyTrainTicket[key] >= 0) {
seatTypes.push({
type: KEY,
code: SEAT_TYPE[KEY]["code"],
desc: SEAT_TYPE[KEY]["desc"],
count: dailyTrainTicket[key],
price: dailyTrainTicket[key + 'Price'],
})
}
}
console.log("本车次提供的座位:", seatTypes)
// 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
// {
// passengerId: 123,
// passengerType: "1",
// passengerName: "张三",
// passengerIdCard: "12323132132",
// seatTypeCode: "1",
// seat: "C1"
// }
const tickets = ref([]);
const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
const visible = ref(false);
// 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
watch(() => passengerChecks.value, (newVal, oldVal)=>{
console.log("勾选乘客发生变化", newVal, oldVal)
// 每次有变化时,把购票列表清空,重新构造列表
tickets.value = [];
passengerChecks.value.forEach((item) => tickets.value.push({
passengerId: item.id,
passengerType: item.type,
seatTypeCode: seatTypes[0].code,
passengerName: item.name,
passengerIdCard: item.idCard
}))
}, {immediate: true});
// 0:不支持选座;1:选一等座;2:选二等座
const chooseSeatType = ref(0);
// 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
const SEAT_COL_ARRAY = computed(() => {
return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
});
// 选择的座位
// {
// A1: false, C1: true,D1: false, F1: false,
// A2: false, C2: false,D2: true, F2: false
// }
const chooseSeatObj = ref({});
watch(() => SEAT_COL_ARRAY.value, () => {
chooseSeatObj.value = {};
for (let i = 1; i <= 2; i++) {
SEAT_COL_ARRAY.value.forEach((item) => {
chooseSeatObj.value[item.code + i] = false;
})
}
console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
}, {immediate: true});
const handleQueryPassenger = () => {
axios.get("/member/passenger/query-mine").then((response) => {
let data = response.data;
if (data.success) {
passengers.value = data.content;
passengers.value.forEach((item) => passengerOptions.value.push({
label: item.name,
value: item
}))
} else {
notification.error({description: data.message});
}
});
};
const finishCheckPassenger = () => {
console.log("购票列表:", tickets.value);
if (tickets.value.length > 5) {
notification.error({description: '最多只能购买5张车票'});
return;
}
// 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
// 前端校验不一定准,但前端校验可以减轻后端很多压力
// 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
let seatTypesTemp = Tool.copy(seatTypes);
for (let i = 0; i < tickets.value.length; i++) {
let ticket = tickets.value[i];
for (let j = 0; j < seatTypesTemp.length; j++) {
let seatType = seatTypesTemp[j];
// 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
if (ticket.seatTypeCode === seatType.code) {
seatType.count--;
if (seatType.count < 0) {
notification.error({description: seatType.desc + '余票不足'});
return;
}
}
}
}
console.log("前端余票校验通过");
// 判断是否支持选座,只有纯一等座和纯二等座支持选座
// 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
let ticketSeatTypeCodes = [];
for (let i = 0; i < tickets.value.length; i++) {
let ticket = tickets.value[i];
ticketSeatTypeCodes.push(ticket.seatTypeCode);
}
// 为购票列表中的所有座位类型去重:[1, 2]
const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
console.log("选好的座位类型:", ticketSeatTypeCodesSet);
if (ticketSeatTypeCodesSet.length !== 1) {
console.log("选了多种座位,不支持选座");
chooseSeatType.value = 0;
} else {
// ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
console.log("一等座选座");
chooseSeatType.value = SEAT_TYPE.YDZ.code;
} else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
console.log("二等座选座");
chooseSeatType.value = SEAT_TYPE.EDZ.code;
} else {
console.log("不是一等座或二等座,不支持选座");
chooseSeatType.value = 0;
}
// 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
if (chooseSeatType.value !== 0) {
for (let i = 0; i < seatTypes.length; i++) {
let seatType = seatTypes[i];
// 找到同类型座位
if (ticketSeatTypeCodesSet[0] === seatType.code) {
// 判断余票,小于20张就不支持选座
if (seatType.count < 20) {
console.log("余票小于20张就不支持选座")
chooseSeatType.value = 0;
break;
}
}
}
}
}
// 弹出确认界面
visible.value = true;
};
const handleOk = () => {
if (Tool.isEmpty(imageCode.value)) {
notification.error({description: '验证码不能为空'});
return;
}
console.log("选好的座位:", chooseSeatObj.value);
// 设置每张票的座位
// 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
for (let i = 0; i < tickets.value.length; i++) {
tickets.value[i].seat = null;
}
let i = -1;
// 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
for (let key in chooseSeatObj.value) {
if (chooseSeatObj.value[key]) {
i++;
if (i > tickets.value.length - 1) {
notification.error({description: '所选座位数大于购票数'});
return;
}
tickets.value[i].seat = key;
}
}
if (i > -1 && i < (tickets.value.length - 1)) {
notification.error({description: '所选座位数小于购票数'});
return;
}
console.log("最终购票:", tickets.value);
axios.post("/business/confirm-order/do", {
dailyTrainTicketId: dailyTrainTicket.id,
date: dailyTrainTicket.date,
trainCode: dailyTrainTicket.trainCode,
start: dailyTrainTicket.start,
end: dailyTrainTicket.end,
tickets: tickets.value,
imageCodeToken: imageCodeToken.value,
imageCode: imageCode.value,
}).then((response) => {
let data = response.data;
if (data.success) {
notification.success({description: "下单成功!"});
} else {
notification.error({description: data.message});
}
});
}
/* ------------------- 第二层验证码 --------------------- */
const imageCodeModalVisible = ref();
const imageCodeToken = ref();
const imageCodeSrc = ref();
const imageCode = ref();
/**
* 加载图形验证码
*/
const loadImageCode = () => {
imageCodeToken.value = Tool.uuid(8);
imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
};
const showImageCodeModal = () => {
loadImageCode();
imageCodeModalVisible.value = true;
};
/* ------------------- 第一层验证码 --------------------- */
const firstImageCodeSourceA = ref();
const firstImageCodeSourceB = ref();
const firstImageCodeTarget = ref();
const firstImageCodeModalVisible = ref();
/**
* 加载第一层验证码
*/
const loadFirstImageCode = () => {
// 获取1~10的数:Math.floor(Math.random()*10 + 1)
firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
};
/**
* 显示第一层验证码弹出框
*/
const showFirstImageCodeModal = () => {
loadFirstImageCode();
firstImageCodeModalVisible.value = true;
};
/**
* 校验第一层验证码
*/
const validFirstImageCode = () => {
if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
// 第一层验证通过
firstImageCodeModalVisible.value = false;
showImageCodeModal();
} else {
notification.error({description: '验证码错误'});
}
};
onMounted(() => {
handleQueryPassenger();
});
return {
passengers,
dailyTrainTicket,
seatTypes,
passengerOptions,
passengerChecks,
tickets,
PASSENGER_TYPE_ARRAY,
visible,
finishCheckPassenger,
chooseSeatType,
chooseSeatObj,
SEAT_COL_ARRAY,
handleOk,
imageCodeToken,
imageCodeSrc,
imageCode,
showImageCodeModal,
imageCodeModalVisible,
loadImageCode,
firstImageCodeSourceA,
firstImageCodeSourceB,
firstImageCodeTarget,
firstImageCodeModalVisible,
showFirstImageCodeModal,
validFirstImageCode,
};
},
});
</script>
<style>
.order-train .order-train-main {
font-size: 18px;
font-weight: bold;
}
.order-train .order-train-ticket {
margin-top: 15px;
}
.order-train .order-train-ticket .order-train-ticket-main {
color: red;
font-size: 18px;
}
.order-tickets {
margin: 10px 0;
}
.order-tickets .ant-col {
padding: 5px 10px;
}
.order-tickets .order-tickets-header {
background-color: cornflowerblue;
border: solid 1px cornflowerblue;
color: white;
font-size: 16px;
padding: 5px 0;
}
.order-tickets .order-tickets-row {
border: solid 1px cornflowerblue;
border-top: none;
vertical-align: middle;
line-height: 30px;
}
.order-tickets .choose-seat-item {
margin: 5px 5px;
}
</style>
效果