-
本项目主要为学习项目,模拟12306的购票和扣减库存逻辑进行设计开发(仅实现了单机部署的购票逻辑,分部署等集群部署的场景暂未实现)。功能较为简易,没有前端页面,测试通过接口进行下单。
-
项目难点:与传统商品购买不同,商品库存为单一货品,通常买完一个即扣减一个库存。而车票系统则不同,通常以座位号去售卖,但是座位号又分为多个站点到站点之间的票次。如A-B-C。如果购买了座位号1,A-B的车票,那么座位号1,A-C的票是不能购买的。
-
怎么设计座位号和库存票之间的关系。
-
在购买票据的时候怎么设计不存在超卖的情况。
- 在车次售票前,生成所有座位起点到终点的车票,即A-B-C-D则生成A-D的车票库存。
- 在购买车票时,从下往上查询车票库存,如A-B的票,先查A-B有没有票,没有则查A-C有没有票,没有则查A-D有没有票,没有则返回票据售完。
- 扣减库存,当购买A-B车票库存的时候,如果扣减的刚好是A-B的库存则直接将此车票库存状态改为已售票,如果扣减的是A-C或者A-D的票据库存,则需要生成B-C,B-D的车票库存,此情况可以异步生成车票库存,发送异步消息或者异步线程。需要保证幂等和消息不丢失。
- 退票,即再生成一张该路段的库存票即可。并删除用户票据信息。
-
以座位为单位进行售票,每次售票前都需要对该趟车次所有座位的购票状态位进行初始化为0。从而减少车票库存表的使用。
-
通过座位购票状态来进行售票,如A-B-C-D的路程信息,座位购票状态则只需要用三个bit位来表示。
-
000表示没有任何购票信息,001表示仅购买了A-B的票,010表示B-C,100表示C-D。
-
011表示A-C,110表B-D。101表示A-B和C-D的票被购买,而B-C的票未购买。
-
111表示A-D的票都被购买了。座位表是无法知道谁购买了哪个车次的票,只有查询车票信息表才能查出来。
-
-
购买车票,先查询仅剩当前路段的票。比如需够买A-B路段,则查询A-B未购买,B-D已购买的座位号,前端传入购买站的bit状态位,后端将此状态位取反查询即可。如果第一次查询未查出来,再查所有未买此路段的座位号,即判断购买状态位 和数据库中状态位进行按位与等于0的记录。
-
退票,将该座位对应路程的状态位取反即可。即购买了A-C的票(011)则取反为000即可。并删除用户票据信息。
-
亮点:在购票时候,根据座位id去购票,能将锁的粒度控制的很细,即只会锁住id哪一行的记录,即加一条记录锁,在此期间对其他座位id的更改不受影响。设置版本标识,通过数据库的cas即可实现多线程的并发问题。缺点即集群部署mysql则会生效功能。
create table train_info(
id INT auto_increment PRIMARY key,
name varchar(20),
code VARCHAR(50),
path_info varchar(100), -- 路程信息 用-隔开 北京-上海-长沙-深圳
path_size int, -- 有几个路程
time_info varchar(100), -- 时间信息 用#隔开 2024-03-06 12:00#2024-03-06 14:00#2024-03-06 16:00
carriage int, -- 车厢数
carriage_num int -- 车厢栽人数
);
create table user_info(
id INT auto_increment PRIMARY KEY,
name varchar(50),
code VARCHAR(50)
);
create table seat_info(
id INT auto_increment PRIMARY key,
num VARCHAR(50), -- 座位号 8
carriage_num int, -- 车厢号
train_id int, -- 列车id
status int, -- 座位购票情况 按二进制位,如北京-上海-深圳,则状态位只需要两位
-- 即北京-上海,上海-北京。01表示北京-上海未购票,上海-深圳已购票
state int -- 更新状态位 cas
);
create table ticket_info(
id INT auto_increment PRIMARY KEY,
code VARCHAR(50) unique, -- 票单
user_id int, -- 用户id
seat_id int, -- 座位id
train_id int, -- 火车id
path_status int -- 座位状态
);
-
前端作为流量的入口,应该做好请求频次控制,即1s内只能点击一次,不能高频次的发送请求,造成服务端请求压力。在成功发送请求后,前端页面应该进入询问等待状态,询问一定时间后,如果还是没有成功则返回“请稍后重试”等字样。
-
请求数据进行处理,如购买A-C的车票信息,则状态位预处理为011再发往后端。前端选择路程应该只能选择连续的路段。
// controller 接口
@GetMapping("/sellTicket")
public String sell(int userId,int trainId,int status,int pathSize){
String res = "";
try {
res = sellService.sellTicket(userId,trainId,status,pathSize);
}catch (Exception e){
System.out.println(e.getMessage());
res = "购票出错,请稍后重试!";
}
return res;
}
// SellService 方法
public String sellTicket(int userId,int trainId,int status,int pathSize){
List<SeatInfo> seatInfos = selectSeat(trainId, status, pathSize); // 查询能购买该路程的所有座位号。
if(seatInfos.isEmpty()){
//车票已抢完!
return "车票已抢完";
}
Random random = new Random();
int index = random.nextInt(seatInfos.size()); // 随机选择一个座位号,避免多个线程选同一个座位id,竞争数据库锁资源
SeatInfo seatInfo = seatInfos.get(index); // 得到座位信息。
boolean res = seatInfoMapper.updateStatus(seatInfo.getId(), seatInfo.getStatus() | status,seatInfo.getState()); // cas更新座位状态
if(res){
TicketMessage ticketMessage = new TicketMessage(userId, seatInfo.getId() ,status,trainId);
Message message = new Message("insertTicket", JSON.toJSONString(ticketMessage));
MyMessageQueue.sendMessage(message); // 更新成功 则发送异步消息。该消息是自己写的简易消息队列。
return "请求成功,等待出票!";
}
return "购票失败";
}
// 查询座位号方法
public List<SeatInfo> selectSeat(int trainId, int status, int pathSize){
int temp = 0;
// 查询仅剩当前路段的票 即将状态位取反,查询数据库
for(int i=0;i<pathSize-1;i++){
int flag = (status>>i&1);
if(flag == 0){
temp|=1<<i;
}
}
//查询仅剩当前路段的票
List<SeatInfo> seatInfos = seatInfoMapper.selectByStatus(trainId, temp);
if(seatInfos.isEmpty()){
//如果没有仅剩当前路段的票,则查询所有能购买当前路程的票。即按位与等于0的即可购买。
seatInfos = seatInfoMapper.selectSeat(trainId,status);
}
return seatInfos;
}
<select id="selectByStatus" resultType="com.example.bin.dao.entity.SeatInfo">
select *
from seat_info
where train_id = #{train_id} and
status = #{status}
</select>
<select id="selectSeat" resultType="com.example.bin.dao.entity.SeatInfo">
select *
from seat_info
where train_id = #{train_id} and
status & #{status} = 0 <!-- CAS 插入-->
</select>
<update id="updateStatus">
update seat_info
set `state` = #{state} + 1 , status = #{status}
where id = #{seat_id} and `state` = #{state}
</update>
@PostConstruct
public void init(){
// 往自定义消息队列中 添加topic的处理方法。该方法仅处理绑定topic的消息
MyMessageQueue.addProcessType("insertTicket",(message)->{
System.out.println("消费消息!");
TicketMessage ticketMessage = JSON.parseObject(message.getValue(), TicketMessage.class);
System.out.println("消息内容:" + message.getValue());
TicketInfo ticketInfo = new TicketInfo();
ticketInfo.setPathStatus(ticketMessage.getStatus());
ticketInfo.setUserId(ticketMessage.getUserId());
ticketInfo.setSeatId(ticketMessage.getSeatId());
ticketInfo.setTrainId(ticketMessage.getTrainId());
ticketInfo.setCode(UUID.randomUUID().toString());
int res = ticketInfoMapper.insert(ticketInfo);
if(res == 1){
System.out.println("消费成功!");
}
else{
System.out.println("消费失败!");
// TODO 发送到fail队列中,还是存到失败消息数据库表中。
}
});
}
public void handle() {
while (true) {
Message message = null;
try {
message = queue.take();
ProcessFun process = processMap.get(message.getType());
if(process == null){
System.out.println("未找到相关类型的处理方法,已存入失败队列");
failQueue.put(message);
}
else{
Message finalMessage = message;
// 使用处理线程池进行处理
threadPoolExecutor.execute(new Runnable() {
@Override
public void run() {
process.function(finalMessage);
}
});
}
} catch (Exception e) {
System.out.println("获取任务或任务执行时发生异常");
failQueue.add(message);
throw new RuntimeException(e);
}
}
}
public List<TicketDto> selectTicketInfo(int userId,int seatId){
List<TicketInfo> tickets = ticketInfoMapper.selectList(new QueryWrapper<TicketInfo>().eq("seat_id", seatId)
.eq("user_id",userId));
ArrayList<TicketDto> res = new ArrayList<>();
if(!tickets.isEmpty()){
TrainInfo trainInfo = trainInfoMapper.selectById(tickets.get(0).getTrainId());
UserInfo userInfo = userInfoMapper.selectById(userId);
if(trainInfo==null || userInfo == null){
return null;
}
String[] pathInfo = trainInfo.getPathInfo().split("-");
String[] timeInfo = trainInfo.getTimeInfo().split("#");
for(TicketInfo ticketInfo : tickets){
int status = ticketInfo.getPathStatus();//车票状态 一张票只会存在连续的1。状态在前端得控制好
int l=-1,r=-1; // 查询出起始位置的1 和末尾位置的1。
for(int i=0;i<pathInfo.length;i++){
if((status>>i&1)==1){
if(l==-1){
l=i;
}
r=i;
}else if(l!=-1){
break;
}
}
SeatInfo seatInfo = seatInfoMapper.selectById(ticketInfo.getSeatId());
// 封装返回数据!
TicketDto ticketDto = new TicketDto();
ticketDto.setUserId(userInfo.getId());
ticketDto.setUserName(userInfo.getName());
ticketDto.setPathInfo(pathInfo[l]+"-"+pathInfo[r+1]);
ticketDto.setCarriageNum(seatInfo.getCarriageNum());
ticketDto.setSeatNum(seatInfo.getNum());
ticketDto.setStartTime(timeInfo[l]);
ticketDto.setEndTime(timeInfo[r+1]);
ticketDto.setTrainName(trainInfo.getName());
res.add(ticketDto);
}
}
return res;
}
// 插入车辆信息
@Test
void train(){
TrainInfo trainInfo = new TrainInfo();
trainInfo.setId(1);
trainInfo.setCode("testCode");
trainInfo.setName("复兴号");
trainInfo.setCarriage(5);
trainInfo.setCarriageNum(128);
trainInfo.setPathInfo("北京-上海-长沙-深圳");
trainInfo.setPathSize(4);
trainInfo.setTimeInfo("2024-03-06 12:00#2024-03-06 14:00#2024-03-06 16:00#2024-03-06 18:00");
int insert = trainInfoMapper.insert(trainInfo);
System.out.println(insert);
}
// 添加所有座位信息。根据火车表来新增
public void addSeat(){
ArrayList<SeatInfo> seatInfos = new ArrayList<>();
TrainInfo trainInfo = trainInfoMapper.selectById(1);
for(int i=1;i<=trainInfo.getCarriage();i++){
for(int j=1;j<=trainInfo.getCarriageNum();j++){
SeatInfo seatInfo = new SeatInfo();
seatInfo.setNum(j);
seatInfo.setCarriageNum(i);
seatInfo.setStatus(0);
seatInfo.setTrainId(trainInfo.getId());
seatInfo.setState(0);
seatInfos.add(seatInfo);
}
}
// 批量插入
if(!seatInfos.isEmpty()){
seatInfoMapper.insertBatch(seatInfos);
}
}
// 采用springboot Test类进行测试
@Test
void sellTest() throws InterruptedException {
for(int i=0;i<=100;i++){
new Thread(new Runnable() {
@Override
public void run() {
sellTicket(); // 购票逻辑代码
}
}).start();
}
Thread.sleep(15000); // 等待消息队列消费线程消费
}
void sellTicket(){
Random random = new Random();
for(int i=0;i<1000;i++){
int num = random.nextInt(8);
if(num==0 || num ==5) // 控制非连续的购票路程
continue;
sellService.sellTicket(2, 1,num, 4);
}
}
// 测试是否有重复消费
@Test
void testTicket(){
// 查询所有的座位号
List<TicketInfo> ticketInfos = ticketInfoMapper.selectList(new QueryWrapper<>());
// 根据座位号id进行排序。
Map<Integer, List<TicketInfo>> collect = ticketInfos.stream().collect(Collectors.groupingBy(TicketInfo::getSeatId));
for(Map.Entry<Integer,List<TicketInfo>> entry : collect.entrySet()){
List<TicketInfo> value = entry.getValue();
int temp = 0;
//将当前座位号的所有记录进行按位与 如果不等于0 则出现重复购票。
for(TicketInfo ticketInfo : value){
int status = ticketInfo.getPathStatus();
if((temp&status)!=0){
System.out.println("有重复购票:"+ticketInfo.getSeatId());
return ;
}
temp|=status;
}
}
System.out.println("测试成功!");
}
@Test
void testTicket2(){
HashMap<Integer, Integer> map = new HashMap<>();
List<TicketInfo> ticketInfos = ticketInfoMapper.selectList(new QueryWrapper<>()); // 查询所有的车票信息
List<SeatInfo> seatInfos = seatInfoMapper.selectList(new QueryWrapper<>());// 查询所有的座位信息
Map<Integer, List<TicketInfo>> collect = ticketInfos.stream().collect(Collectors.groupingBy(TicketInfo::getSeatId));
for(Map.Entry<Integer,List<TicketInfo>> entry : collect.entrySet()){
Integer seatId = entry.getKey();
int len = entry.getValue().size();
map.put(seatId,len); // 根据座位id 映射 有几张票
}
for(SeatInfo seatInfo : seatInfos){
// 因目前只有购票记录,所以 只有购票成功了 才会更改座位的版本.
if(seatInfo.getState() != map.get(seatInfo.getId())){
System.out.println("座位的版本号 和 票据信息 不相符");
}
}
System.out.println("测试2成功!");
}
// 根据用户 和 座位号查询。因本测试过程都只用一个userId去测试,所以查询的时候加上了座位号。最终结果应该都为 北京 - 深圳
@Test
void queryTicket() {
List<TicketDto> ticketDtos = ticketService.selectTicketInfo(2, 9333);
for (TicketDto ticketDto : ticketDtos){
System.out.println(ticketDto.toString());
}
//TicketDto(userId=2, userName=小蒋, pathInfo=上海-深圳, trainName=复兴号, carriageNum=4, seatNum=95, startTime=2024-03-06 14:00, endTime=2024-03-06 18:00)
//TicketDto(userId=2, userName=小蒋, pathInfo=北京-上海, trainName=复兴号, carriageNum=4, seatNum=95, startTime=2024-03-06 12:00, endTime=2024-03-06 14:00)
}