Skip to content

本项目主要为学习项目,模拟12306的购票和扣减库存逻辑进行设计开发(仅实现了单机部署的购票逻辑,分部署等集群部署的场景暂未实现)。功能较为简易,没有前端页面,测试通过接口进行下单。

Notifications You must be signed in to change notification settings

kenan131/12306learnning

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

1、项目介绍

  • 本项目主要为学习项目,模拟12306的购票和扣减库存逻辑进行设计开发(仅实现了单机部署的购票逻辑,分部署等集群部署的场景暂未实现)。功能较为简易,没有前端页面,测试通过接口进行下单。

  • 项目难点:与传统商品购买不同,商品库存为单一货品,通常买完一个即扣减一个库存。而车票系统则不同,通常以座位号去售卖,但是座位号又分为多个站点到站点之间的票次。如A-B-C。如果购买了座位号1,A-B的车票,那么座位号1,A-C的票是不能购买的。

  • 怎么设计座位号和库存票之间的关系。

  • 在购买票据的时候怎么设计不存在超卖的情况。

设计方案1:

  • 在车次售票前,生成所有座位起点到终点的车票,即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的车票库存,此情况可以异步生成车票库存,发送异步消息或者异步线程。需要保证幂等和消息不丢失。
  • 退票,即再生成一张该路段的库存票即可。并删除用户票据信息。

设计方案2(本系统采用):

  • 以座位为单位进行售票,每次售票前都需要对该趟车次所有座位的购票状态位进行初始化为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则会生效功能。

2、系统表结构设计

列车信息表

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  -- 座位状态 
);

3、系统设计

前端(未开发)

  • 前端作为流量的入口,应该做好请求频次控制,即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 &amp; #{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)
}

About

本项目主要为学习项目,模拟12306的购票和扣减库存逻辑进行设计开发(仅实现了单机部署的购票逻辑,分部署等集群部署的场景暂未实现)。功能较为简易,没有前端页面,测试通过接口进行下单。

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages