从0到1构建高铁票预定系统:Python+Vue全栈实战,拆解高并发与核心业务痛点

内容分享6小时前发布
0 0 0

最近花了两个月时间从零搭建了一套完整的高铁票在线购票系统(毕设+练手项目),前端用Vue,后端试过Flask后来换成了Django,踩了不少坑——比如一开始没考虑并发导致“超卖”,爬12306车次数据被反爬,支付回调处理不及时导致订单状态混乱。这篇文章就把整个项目的技术选型、核心功能实现、难点解决方案全拆出来,不管是做毕设还是想练手全栈项目的同学,看完应该能少走一半弯路。

一、技术选型:为什么选Vue+Django,而不是Flask?

一开始后端选的是Flask,因为轻量上手快,但做到购票下单模块时发现:Flask的扩展虽然多,但需要自己拼积木(比如权限控制要装Flask-Login,ORM用SQLAlchemy),而Django自带的Admin、ORM、Auth系统太香了,尤其做后台管理和用户认证时省了大量代码。最后决定后端用Django,前端Vue,搭配MySQL+Redis(缓存余票),具体选型如下:

技术栈 选型理由
前端 Vue 3 + Vue Router + Vuex + Element Plus
后端 Django 4.2 + Django REST Framework (DRF)
数据库 MySQL + Redis
异步任务 Celery + Redis
部署 Docker + Nginx + Gunicorn

这里插一句:如果是做小Demo,Flask足够,但涉及到用户认证、权限管理、复杂业务逻辑,Django的“开箱即用”优势太明显了——比如DRF的Token认证、分页、过滤,几行配置就能搞定。

二、核心模块实现:从车次查询到购票下单

1. 车次与余票模块:数据爬取+实时缓存

首先得解决“车次数据从哪来”的问题。一开始想直接调用12306的API,但官方没开放,只能自己模拟爬取(仅用于学习,请勿商用)。用
requests
+
BeautifulSoup
爬取车次信息,再用定时任务更新到MySQL,余票则用Redis做实时缓存(因为余票查询是高频操作,直接查MySQL会慢)。

(1)Django模型设计(车次+余票)

# models.py
from django.db import models

class Train(models.Model):
    """车次信息模型"""
    train_no = models.CharField(verbose_name="车次号", max_length=20, unique=True)
    start_station = models.CharField(verbose_name="出发站", max_length=30)
    end_station = models.CharField(verbose_name="到达站", max_length=30)
    start_time = models.TimeField(verbose_name="出发时间")
    end_time = models.TimeField(verbose_name="到达时间")
    duration = models.DurationField(verbose_name="历时")
    price = models.DecimalField(verbose_name="票价", max_digits=10, decimal_places=2)
    create_time = models.DateTimeField(verbose_name="创建时间", auto_now_add=True)
    update_time = models.DateTimeField(verbose_name="更新时间", auto_now=True)

    class Meta:
        verbose_name = "车次"
        verbose_name_plural = verbose_name

class TicketStock(models.Model):
    """车票余票模型"""
    train = models.OneToOneField(Train, on_delete=models.CASCADE, verbose_name="关联车次")
    total_tickets = models.IntegerField(verbose_name="总票数", default=100)
    remaining_tickets = models.IntegerField(verbose_name="剩余票数", default=100)
    update_time = models.DateTimeField(verbose_name="更新时间", auto_now=True)

    class Meta:
        verbose_name = "车票余票"
        verbose_name_plural = verbose_name
(2)Redis缓存余票(Python端)

# utils/redis_utils.py
import redis
from django.conf import settings

redis_client = redis.Redis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB,
    decode_responses=True
)

def cache_ticket_stock(train_no, remaining_tickets):
    """缓存车次余票"""
    redis_client.set(f"ticket:stock:{train_no}", remaining_tickets)
    redis_client.expire(f"ticket:stock:{train_no}", 60)  # 1分钟过期,定时任务更新

def get_ticket_stock(train_no):
    """获取缓存的余票,无则查DB并缓存"""
    stock = redis_client.get(f"ticket:stock:{train_no}")
    if not stock:
        from apps.ticket.models import TicketStock, Train
        try:
            train = Train.objects.get(train_no=train_no)
            ticket_stock = TicketStock.objects.get(train=train)
            stock = ticket_stock.remaining_tickets
            cache_ticket_stock(train_no, stock)
        except (Train.DoesNotExist, TicketStock.DoesNotExist):
            return 0
    return int(stock)
(3)前端Vue查询组件(核心代码)

<!-- src/views/TicketQuery.vue -->
<template>
  <div class="ticket-query">
    <el-form :model="queryForm" inline @submit.prevent="queryTickets">
      <el-form-item label="出发站">
        <el-input v-model="queryForm.startStation" placeholder="如:北京西"></el-input>
      </el-form-item>
      <el-form-item label="到达站">
        <el-input v-model="queryForm.endStation" placeholder="如:上海虹桥"></el-input>
      </el-form-item>
      <el-form-item label="出发日期">
        <el-date-picker v-model="queryForm.date" type="date" placeholder="选择日期"></el-date-picker>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="queryTickets">查询车票</el-button>
      </el-form-item>
    </el-form>

    <el-table :data="ticketList" border>
      <el-table-column prop="trainNo" label="车次"></el-table-column>
      <el-table-column prop="startStation" label="出发站"></el-table-column>
      <el-table-column prop="endStation" label="到达站"></el-table-column>
      <el-table-column prop="startTime" label="出发时间"></el-table-column>
      <el-table-column prop="endTime" label="到达时间"></el-table-column>
      <el-table-column prop="price" label="票价(元)"></el-table-column>
      <el-table-column prop="remainingTickets" label="剩余票数"></el-table-column>
      <el-table-column label="操作">
        <template #default="scope">
          <el-button type="primary" size="small" @click="buyTicket(scope.row)" :disabled="scope.row.remainingTickets <=0">购票</el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import axios from '@/utils/request'

const queryForm = ref({
  startStation: '',
  endStation: '',
  date: ''
})
const ticketList = ref([])

const queryTickets = async () => {
  if (!queryForm.value.startStation || !queryForm.value.endStation || !queryForm.value.date) {
    ElMessage.warning('请填写完整查询条件')
    return
  }
  try {
    const res = await axios.get('/api/ticket/query', {
      params: queryForm.value
    })
    ticketList.value = res.data.data
  } catch (err) {
    ElMessage.error('查询失败:' + err.message)
  }
}

const buyTicket = (ticket) => {
  // 跳转到购票页面,传递车次信息
  router.push({
    path: '/buy-ticket',
    query: { trainNo: ticket.trainNo }
  })
}
</script>

2. 购票下单模块:解决“超卖”的核心——乐观锁

一开始做购票功能时,直接先扣减余票再创建订单,结果测试时开两个浏览器同时下单同一车次,出现了“余票为负”的超卖问题。后来查资料才知道,需要用乐观锁来保证并发安全(因为购票场景是“读多写少”,乐观锁比悲观锁性能好)。

(1)Django后端购票接口(带乐观锁)

# views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db import transaction
from apps.ticket.models import Train, TicketStock, Order
from apps.user.models import User
from utils.redis_utils import cache_ticket_stock

class BuyTicketView(APIView):
    permission_classes = [IsAuthenticated]  # 需登录

    @transaction.atomic  # 事务保证原子性
    def post(self, request):
        train_no = request.data.get('train_no')
        seat_type = request.data.get('seat_type', '二等座')
        user = request.user

        try:
            # 1. 获取车次和余票,加行级锁(select_for_update)
            train = Train.objects.get(train_no=train_no)
            ticket_stock = TicketStock.objects.select_for_update().get(train=train)

            # 2. 检查余票
            if ticket_stock.remaining_tickets <= 0:
                return Response({"code": 400, "msg": "余票不足"})

            # 3. 乐观锁扣减余票(对比当前余票和DB中的余票,防止并发修改)
            affected_rows = TicketStock.objects.filter(
                train=train,
                remaining_tickets=ticket_stock.remaining_tickets  # 乐观锁:确保扣减前数据未被修改
            ).update(remaining_tickets=ticket_stock.remaining_tickets - 1)

            if affected_rows == 0:
                return Response({"code": 400, "msg": "购票失败,请重试"})

            # 4. 创建订单
            order = Order.objects.create(
                order_no=f"ORDER{train_no}{user.id}{int(time.time())}",
                user=user,
                train=train,
                seat_type=seat_type,
                price=train.price,
                status="待支付"
            )

            # 5. 更新Redis缓存
            cache_ticket_stock(train_no, ticket_stock.remaining_tickets - 1)

            # 6. 启动Celery定时任务:15分钟未支付则取消订单
            from apps.order.tasks import cancel_unpaid_order
            cancel_unpaid_order.apply_async(args=[order.id], countdown=15*60)

            return Response({"code": 200, "msg": "下单成功", "data": {"order_no": order.order_no}})

        except (Train.DoesNotExist, TicketStock.DoesNotExist):
            return Response({"code": 404, "msg": "车次不存在"})
        except Exception as e:
            return Response({"code": 500, "msg": f"购票失败:{str(e)}"})
(2)Celery异步任务(取消超时订单)

# tasks.py
from celery import shared_task
from apps.order.models import Order
from apps.ticket.models import TicketStock
from utils.redis_utils import cache_ticket_stock

@shared_task
def cancel_unpaid_order(order_id):
    """取消15分钟未支付的订单"""
    try:
        order = Order.objects.get(id=order_id, status="待支付")
        # 恢复余票
        ticket_stock = TicketStock.objects.get(train=order.train)
        ticket_stock.remaining_tickets += 1
        ticket_stock.save()
        # 更新Redis缓存
        cache_ticket_stock(order.train.train_no, ticket_stock.remaining_tickets)
        # 修改订单状态
        order.status = "已取消"
        order.save()
    except Order.DoesNotExist:
        pass
    except Exception as e:
        print(f"取消订单失败:{str(e)}")

3. 支付模块:对接支付宝沙箱

支付功能用的是支付宝沙箱环境(开发测试用),流程是:前端发起支付请求→后端生成支付链接→前端跳转支付宝扫码支付→支付宝回调后端接口→后端更新订单状态。

(1)Django支付回调接口

# views.py
from alipay import AliPay
from django.conf import settings
from django.http import HttpResponse

class AlipayCallbackView(APIView):
    def post(self, request):
        # 接收支付宝回调参数
        data = request.POST.dict()
        signature = data.pop('sign')

        # 初始化支付宝对象
        alipay = AliPay(
            appid=settings.ALIPAY_APPID,
            app_notify_url=settings.ALIPAY_NOTIFY_URL,
            app_private_key_string=open(settings.ALIPAY_PRIVATE_KEY_PATH).read(),
            alipay_public_key_string=open(settings.ALIPAY_PUBLIC_KEY_PATH).read(),
            sign_type="RSA2"
        )

        # 验证签名
        success = alipay.verify(data, signature)
        if success and data["trade_status"] in ("TRADE_SUCCESS", "TRADE_FINISHED"):
            # 更新订单状态为“已支付”
            order_no = data["out_trade_no"]
            Order.objects.filter(order_no=order_no).update(status="已支付")
            return HttpResponse("success")  # 支付宝要求返回success,否则会重复回调
        return HttpResponse("fail")

三、技术难点与解决方案:我踩过的3个大坑

1. 坑1:12306爬取数据被封IP

一开始用普通requests爬取车次数据,爬几次就被12306封IP了。解决方法:


requests-proxies
加代理池(免费代理不稳定,后来用了付费的);模拟浏览器请求头(User-Agent、Referer),避免被识别为爬虫;降低爬取频率(每爬一次sleep 1-2秒)。

2. 坑2:并发下单导致超卖

前面已经说过,一开始没加锁,出现超卖。最终解决方案是:

用Django的
select_for_update()
加行级锁,保证同一车次的余票操作串行;配合乐观锁(
filter(remaining_tickets=当前值).update()
),进一步防止并发冲突。

3. 坑3:支付回调接收不到

一开始把回调地址设为localhost,支付宝肯定访问不到。解决方法:


ngrok
把本地端口映射到公网(ngrok http 8000);把支付宝沙箱的回调地址改成ngrok分配的公网地址(如https://xxxx.ngrok.io/api/order/alipay/callback)。

四、部署上线:Docker容器化

最后用Docker把项目打包部署,写了
docker-compose.yml
,一键启动所有服务:


# docker-compose.yml
version: '3'

services:
  db:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: ticket_system
    volumes:
      - ./mysql_data:/var/lib/mysql
    ports:
      - "3306:3306"

  redis:
    image: redis:7.0
    restart: always
    ports:
      - "6379:6379"

  web:
    build: ./backend
    restart: always
    depends_on:
      - db
      - redis
    environment:
      - DJANGO_SETTINGS_MODULE=settings.prod
    command: gunicorn ticket_system.wsgi:application --bind 0.0.0.0:8000

  celery:
    build: ./backend
    restart: always
    depends_on:
      - redis
    command: celery -A ticket_system worker -l info

  frontend:
    build: ./frontend
    restart: always
    ports:
      - "80:80"
    depends_on:
      - web

  nginx:
    image: nginx:1.23
    restart: always
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
    ports:
      - "8080:80"
    depends_on:
      - web
      - frontend

五、总结:从项目中收获的3个经验

技术选型要匹配业务场景:小Demo用Flask,复杂业务用Django,别为了“炫技”选不熟悉的技术;并发问题一定要提前考虑:购票、秒杀这类场景,超卖是致命问题,乐观锁/悲观锁要选对;接口设计要考虑扩展性:比如车次查询加了日期参数,后续扩展“返程票”功能就很方便。

© 版权声明

相关文章

暂无评论

none
暂无评论...