☰
Django+Python影城售票系统:从数据建模到并发锁座的完整实战
2026/10/11 14:11:20 网站建设 项目流程

做这个项目的人我见过不少,各种课设、毕设、还有想转行拿来做作品集的,但说实话,十个里有八个做的只是“换皮图书管理系统”——能增删改查电影和场次,再让用户注册登录,然后就没然后了。前阵子我帮人改造一个类似的模拟项目X,改到订单状态和座位锁定时才真正体会到:影城售票系统最值钱的部分从来不是CRUD,而是那套业务规则和流程约束。

所以这篇我想聊透的是:如果你真的打算用Django + Python从零写一个能跑的影城售票管理系统,到底该把劲往哪使。我会按我实际改造项目的顺序来拆——业务边界、数据建模、售票主流程、权限设计、部署上线,把真正会翻车的细节一个个摆出来。适合有一定Python基础、想系统掌握Django MTV架构的人,也适合正在用这个题目做课设但不想只交个玩具上去的同学。

1. 影城售票系统真正难在哪:先盘清业务边界

1.1 为什么“售票”比“图书管理”复杂一个量级

很多新手拿到这个题目,第一反应是:不就是电影、场次、订单三个表吗?我告诉你,这个想法就已经埋雷了。图书管理系统的核心是库存数量,你减一个数字就行;售票系统不一样,它卖的是“某一个影厅里、某一个场次、某一个具体座位”的座位状态,这是三维的。

再叠加几个特性:订单牵扯钱,不能乱改;座位有并发抢占,用户同时选同一个座位的场景必须处理;订单有生命周期,待支付、已支付、已取消、已退票,状态之间不能乱跳。这三个特性叠加起来,天然比图书管理高一个难度等级。所以第一步不是写代码,而是把系统到底要做成什么样、边界划在哪,想清楚。

1.2 核心模块拆分与取舍

我帮人改造时习惯先列一张模块清单,确定哪些是主链路、哪些是附属,避免后面越写越乱。一个基础能跑的影城售票系统,最少要有六块:

模块核心职责缺了它会怎样
用户模块注册、登录、身份角色没法区分普通观众和员工
电影模块影片信息维护、上映状态场次没有载体
影厅与座位模块影厅布局、座位物理归属无法定义“你在几排几座”
场次模块某影厅某时间放某电影无法形成可售票资源
订单与票模块锁定座位、生成订单、门票核心卖票链路断裂
后台管理排片、定价、核销、数据查看运营没法使用系统

至于会员积分、优惠券、退改签、自动取票机对接,这些都属于扩展项。我做基础版时建议先砍掉,等主链路彻底跑通了再往上加。优先级不排好,很容易写着写着就把自己绕进了一个优惠分摊的计算逻辑里,核心的并发处理反而没时间打磨。

1.3 角色与权限粗模型

影城售票系统不是只有用户和Admin两级。真实场景里至少有四类角色:普通观众负责买票;前台员工负责现场售票、退票、入场核销;运营人员负责排片和调价;系统管理员负责用户和基础数据。Django自带User和Group,天然适合做这个事,后面我会专门讲权限怎么落地。现在你只要记住:需求阶段就把角色区分开,别等代码写完了再回来补。

2. 数据建模是命根子:Django模型设计的取舍与反例

2.1 核心模型逐个拆解

数据模型决定了系统能走多远,我见过太多项目因为表设计问题推倒重来。下面是经过实际验证的一套模型方案,每个字段我都写清楚为什么这么定。

from django.db import models from django.contrib.auth.models import AbstractUser from django.utils import timezone class User(AbstractUser): phone = models.CharField(max_length=20, blank=True) # 不额外建Profile表,直接扩展User是小型项目最省事的方式 class Movie(models.Model): title = models.CharField(max_length=200, verbose_name="片名") poster = models.ImageField(upload_to="posters/", blank=True, verbose_name="海报") duration = models.PositiveIntegerField(verbose_name="时长(分钟)") release_date = models.DateField(verbose_name="上映日期") language = models.CharField(max_length=50, blank=True) version = models.CharField( max_length=20, choices=[("2d", "2D"), ("3d", "3D"), ("imax", "IMAX")], default="2d" ) description = models.TextField(blank=True) def __str__(self): return self.title class Hall(models.Model): name = models.CharField(max_length=50, verbose_name="影厅名") rows = models.PositiveIntegerField(verbose_name="排数") cols = models.PositiveIntegerField(verbose_name="列数") def __str__(self): return self.name class Seat(models.Model): hall = models.ForeignKey(Hall, on_delete=models.CASCADE, related_name="seats") row = models.CharField(max_length=5, verbose_name="排号") col = models.PositiveIntegerField(verbose_name="列号") class Meta: unique_together = ("hall", "row", "col") def __str__(self): return f"{self.hall.name}-{self.row}排{self.col}座" class Session(models.Model): movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name="sessions") hall = models.ForeignKey(Hall, on_delete=models.CASCADE, related_name="sessions") start_time = models.DateTimeField(verbose_name="开场时间") price = models.DecimalField(max_digits=6, decimal_places=2, verbose_name="票价") class Meta: unique_together = ("hall", "start_time") def __str__(self): return f"{self.movie.title} {self.start_time:%Y-%m-%d %H:%M}" class SessionSeat(models.Model): AVAILABLE = 0 LOCKED = 1 SOLD = 2 STATUS_CHOICES = [ (AVAILABLE, "可售"), (LOCKED, "锁定"), (SOLD, "已售"), ] session = models.ForeignKey(Session, on_delete=models.CASCADE, related_name="seat_status") seat = models.ForeignKey(Seat, on_delete=models.CASCADE) status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=AVAILABLE) class Meta: unique_together = ("session", "seat") indexes = [ models.Index(fields=["session", "status"]), ] def __str__(self): return f"{self.seat} - {self.get_status_display()}" class Order(models.Model): PENDING = 0 PAID = 1 CANCELLED = 2 REFUNDED = 3 STATUS_CHOICES = [ (PENDING, "待支付"), (PAID, "已支付"), (CANCELLED, "已取消"), (REFUNDED, "已退款"), ] order_no = models.CharField(max_length=32, unique=True, verbose_name="订单号") user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="orders") session = models.ForeignKey(Session, on_delete=models.PROTECT) amount = models.DecimalField(max_digits=8, decimal_places=2) status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=PENDING) created_at = models.DateTimeField(auto_now_add=True) paid_at = models.DateTimeField(null=True, blank=True) class Ticket(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="tickets") session_seat = models.OneToOneField(SessionSeat, on_delete=models.PROTECT) check_code = models.CharField(max_length=20, unique=True)

这套模型里有几个地方是反复踩坑后才确定的,下面单独讲。

2.2 座位设计的两种方案,为什么我选了中间表

座位的物理归属是个容易争议的点。一种做法是把Seat直接挂在Session下,每个场次复制一套座位;另一种是把Seat挂在Hall下,场次需要卖票时再动态判断。

第一种的缺点是:影厅的座位布局一旦调整,历史所有场次数据全乱;第二种好一些,但你没法直接记录“这个场次这个座位被谁锁了”。所以我最终选了第三种变体:Seat挂在Hall表示物理座位,SessionSeat作为场次和座位之间一张中间表,专门记录每个场次下每个座位的售卖状态。加一场排片时,自动为这场生成Hall下所有座位的SessionSeat记录;订票时只改SessionSeat的status,Seat本身不动。这样影厅座位改一次,所有场次跟着生效,历史数据也不会歪。

2.3 订单状态为什么要用IntegerField而不是一堆布尔字段

我见过有人用is_paid、is_cancelled、is_refunded三个布尔字段表示订单状态,看起来直观,实际用起来全是坑。典型问题:一张订单即is_paid=True又is_cancelled=True,这种数据在系统里到底算什么?非法状态就这么轻轻松松产生了。

订单状态是典型的状态机,用IntegerField + choices枚举才是正经做法。Django的choices约束虽然只是“限制输入值”,不是数据库级别的严格约束,但配合代码约定和定期巡检,完全够用。更重要的是,在代码里凡是涉及状态变更都走同一个方法,逻辑就统一了。我还建议把状态变更记录到一张单独的OrderLog表里,这样出现问题时能追溯,运营和运维都会感谢你。

2.4 外键与索引,哪些会影响真实查询性能

平时写着玩你可能感觉不到索引的存在,但一旦一个场次的SessionSeat达到两三百条记录、订单量上万,查询性能就藏不住了。我在改造时至少加了三个关键点:第一,SessionSeat的unique_together=("session", "seat"),保证同样的场次座位不重复,天然防插入脏数据;第二,为SessionSeat加了一个包含session+status的联合索引,因为“查某场次哪些座位可售”是所有售票场景最高频的查询;第三,Order的order_no字段必须唯一,这是业务层面找订单的唯一凭据,不要用主键id当订单号给用户看。

外键的on_delete参数也值得认真选。普通业务数据建议用PROTECT或者CASCADE根据语义来,但订单和电影、用户的关系我特意用了PROTECT——宁可让你删不了,也不能一声delete下去把历史订单连带删光,这种事故出了就是灾难。

3. 售票主流程实现:选座、锁定、下单、支付回调整链路

3.1 为什么不能“下单后再锁座”

这是整个系统最核心的一个设计决策。很多新手写售票流程是:用户选座 -> 创建订单 -> 支付完成 -> 更新座位。这套流程在演示时没问题,但在真实并发下必出超卖——两个用户同时选同一个座位,都下单了,其中一个支付成功后才发现座位已经卖给别人。

正确逻辑是把“锁座”放在下单前:用户在界面上选中座位后,先尝试锁定目标座位;锁定成功才创建待支付订单;支付成功才把座位状态从锁定改为已售;超时未支付则释放座位。这样座位被锁后其他人就选不了,超卖问题被扼杀在最前端。

3.2 锁定座位在Django里的正确写法

实现锁座不是简单的if判断,要用数据库行锁配合事务。Django里核心是select_for_update(),它会在事务内对查询出的行加锁,其他人再查这些行就得等当前事务结束。下面这段是我实际在项目中验证过的核心逻辑:

from django.db import transaction def lock_seats_and_create_order(user, session_id, seat_ids): with transaction.atomic(): session = Session.objects.select_for_update().get(pk=session_id) session_seats = list( SessionSeat.objects .select_for_update() .filter(session_id=session_id, seat_id__in=seat_ids) .order_by("id") # 固定锁顺序,避免死锁 ) for seat in session_seats: if seat.status != SessionSeat.AVAILABLE: raise SeatLockedError(f"座位 {seat.seat} 已被占用") order_no = generate_order_no() order = Order.objects.create( order_no=order_no, user=user, session=session, amount=session.price * len(session_seats), status=Order.PENDING, ) for session_seat in session_seats: session_seat.status = SessionSeat.LOCKED session_seat.save() Ticket.objects.create( order=order, session_seat=session_seat, check_code=generate_check_code(), ) return order

几个细节必须提醒你。第一,select_for_update()只有在事务内才生效,所以一定要用transaction.atomic()包住,这是我第一次踩坑的地方——在外面select_for_update()后去更新,锁根本没起作用。第二,锁定多条座位时,order_by("id")是必要的,它让所有并发请求按相同的顺序加锁,避免两个请求各持一部分锁然后互相等待导致的死锁。第三,这一整套逻辑在SQLite上有个大坑:SQLite对select_for_update是不支持的,它只是静默忽略,也就是说本地开发一切正常,一上生产换成PostgreSQL才触发真实锁行为。如果你用的是SQLite做开发和测试,并发场景一定要放到生产数据库环境去验一遍。

3.3 超时未支付订单怎么处理

座位锁了但用户迟迟不付款,座位就一直占着?所以要有超时释放机制。最简单的做法是写一个Django management command,扫描超时未支付订单,回滚对应座位状态:

# app/management/commands/release_expired_orders.py from django.core.management.base import BaseCommand from django.utils import timezone from datetime import timedelta from django.db import transaction class Command(BaseCommand): help = "释放超时未支付订单占用的座位" def handle(self, *args, **options): expire_before = timezone.now() - timedelta(minutes=15) expired_orders = Order.objects.filter( status=Order.PENDING, created_at__lt=expire_before, ) for order in expired_orders.iterator(): with transaction.atomic(): # 用select_for_update锁订单,防止和支付回调竞争 locked_order = Order.objects.select_for_update().get(pk=order.pk) if locked_order.status != Order.PENDING: continue locked_order.status = Order.CANCELLED locked_order.save() locked_order.tickets.select_related("session_seat") for ticket in locked_order.tickets.all(): ticket.session_seat.status = SessionSeat.AVAILABLE ticket.session_seat.save()

这个命令挂在系统cron里每5分钟跑一次就行。如果你用了Celery,也可以用shared_task加beat_schedule定期执行;如果项目没上Celery,系统cron反而是最稳妥的选择。有一点特别容易忽略:超时任务和用户支付回调可能同时发生——用户在最后一秒点了支付。所以处理超时时也要用select_for_update锁订单,并再次判断状态,否则会出现“订单被取消,支付又成功”这种账单问题。

3.4 支付回调幂等:回调重复到达是常态

模拟支付环节很多人直接在前端写死“支付成功”,这会让整个订单链路失去真实感。正规做法是后端提供支付回调接口。所谓幂等,就是同一个支付结果被通知两次、三次,系统最终状态都一致,不会把订单状态改出花来。

def payment_callback(request): data = json.loads(request.body) order_no = data.get("order_no") with transaction.atomic(): order = Order.objects.select_for_update().get(order_no=order_no) if order.status == Order.PAID: return JsonResponse({"code": 0, "msg": "duplicate"}) if order.status == Order.CANCELLED: # 理论上是异常流,但要记录下来人工对账 send_alert_to_admin(order_no) return JsonResponse({"code": 0, "msg": "cancelled but paid"}) order.status = Order.PAID order.paid_at = timezone.now() order.save() order.tickets.select_related("session_seat") for ticket in order.tickets.all(): ticket.session_seat.status = SessionSeat.SOLD ticket.session_seat.save() return JsonResponse({"code": 0, "msg": "ok"})

判断order.status == Order.PAID时直接返回,就是幂等。如果不加这个判断,回调来两次,座位状态更新逻辑就会执行两次,虽然结果可能一样,但如果你在后面接上“给用户发短信”“生成发票”这类副作用操作,重复执行就是事故。

4. 权限体系与后台管理:把Django Admin调教成运营工具

4.1 自定义User模型,越早做越好

Django官方文档的一句话很多人没注意:如果你打算自定义User模型,应该在第一次迁移之前就做。一旦数据表建好,再改User模型会非常痛苦。

class User(AbstractUser): phone = models.CharField(max_length=20, blank=True) class Meta: verbose_name = "用户" verbose_name_plural = "用户"

然后在settings.py里指明:AUTH_USER_MODEL = 'appname.User'。为什么非要自定义?因为你很可能在后续加角色、加手机号、加昵称、加头像。用默认User再挂一个Profile表虽说也能实现,但查询和Admin集成就多绕一层。反正项目刚起步时改成本最低,一次性自定义是最划算的选择。

4.2 用Group做角色,用Permission做权限

Django的Group天然就是角色。我给这个项目至少准备三个组:观众、前台员工、运营。管理员不需要放进组里,is_staff和is_superuser字段已经区分了。

具体到权限点,除了Django自带的add/change/delete权限,我建议你在模型Meta里自定义业务权限:

class Session(models.Model): # ... class Meta: permissions = [ ("can_publish_session", "可以发布场次"), ("can_check_ticket", "可以核销门票"), ]

然后用user.has_perm("app.can_check_ticket")判断一个前台能不能执行核销操作。视图层用@permission_required装饰器拦住未授权访问,前端模板里用{% if perms.app.can_check_ticket %}控制按钮显隐。后端校验永远比前端隐藏重要,前端只是体验优化,真正的安全边界在后端。在真实项目中,前台员工现场售票应该只能修改订单状态,不该有改电影信息的权限;运营能排片和调价,但不能修改用户数据——这些靠Group和Permission就能梳理得明明白白。

4.3 Admin定制,让运营愿意用而不是骂它

默认的Admin其实已经够用,但你要微调,否则运营在一个满是英文的表单里手动填日期和价格,一定会弃用。我在改造项目时主要定制了这几块:

@admin.register(Movie) class MovieAdmin(admin.ModelAdmin): list_display = ("title", "version", "duration", "release_date") list_filter = ("version", "release_date") search_fields = ("title",) @admin.register(Session) class SessionAdmin(admin.ModelAdmin): list_display = ("movie", "hall", "start_time", "price", "seat_booked_count") list_filter = ("start_time", "hall") search_fields = ("movie__title",) autocomplete_fields = ("movie", "hall") def seat_booked_count(self, obj): return obj.seat_status.exclude(status=SessionSeat.AVAILABLE).count() seat_booked_count.short_description = "已占座位数" class TicketInline(admin.TabularInline): model = Ticket extra = 0 @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ("order_no", "user", "session", "amount", "status", "created_at") list_filter = ("status", "created_at") search_fields = ("order_no", "user__username") readonly_fields = ("order_no", "amount", "created_at") inlines = [TicketInline]

运营最关心的是:今天哪些场次快满了、哪些票还没卖出去、某个订单到底是什么状态。list_filter和list_display配好之后,这些信息一眼就能看到。readonly_fields很重要——金额和订单号绝对不能让人在后台随手改,要改只能走退款流程,这是财务上的基本要求。

5. 部署上线时最容易翻车的三个地方

5.1 settings按环境拆分

开发环境本地SQLite、DEBUG=True,生产环境PostgreSQL、DEBUG=False,这两套配置混在一起迟早出事。我的做法是拆成settings/base.py、settings/dev.py、settings/prod.py。base.py放公共项,dev.py开DEBUG,prod.py用环境变量读取数据库配置和SECRET_KEY。SECRET_KEY绝不能写死在代码里提交到仓库,用环境变量或部署平台的密钥管理服务都行。

# settings/prod.py import os DEBUG = False ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",") SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["DB_NAME"], "USER": os.environ["DB_USER"], "PASSWORD": os.environ["DB_PASSWORD"], "HOST": os.environ["DB_HOST"], "PORT": os.environ["DB_PORT"], } }

5.2 静态文件和媒体文件的处理

海报上传这类ImageField字段涉及的media文件是另一个高频翻车点。开发时Django自己就能处理,但生产环境千万别让Django处理静态文件。我建议:Nginx直接代理/static/和/media/两个路径,Django只负责动态请求。如果用单机部署不想单独配Nginx,也可以用WhiteNoise这个库托管静态文件,但media还是建议独立出来放OSS或云存储,否则服务器存储和备份都会成问题。

5.3 数据库迁移和并发测试别在本地草草了事

最后一定要提醒:如果你的开发环境一直是SQLite,而线上是PostgreSQL,那你至少要在上线前把整套流程在PostgreSQL上完整跑一遍业务测试。原因前面提过,select_for_update在SQLite上不生效,你在本地测不出并发问题;此外PostgreSQL的字段约束、事务隔离级别、字符排序都和SQLite有差异。当初我帮人排查一个“本地测得好好的上线就漏单”的问题,最后根因就是SQLite下并发锁形同虚设。

部署时还有个小坑:collectstatic是必须跑的,漏了就会看到后台页面样式全部丢失。跑完之后检查一下STATIC_ROOT目录内容是否完整,这个几秒钟的动作能省不少排查时间。

我在实际做过这一整套之后最深的体会是:这个题目看起来平淡,其实是一个很好的“业务系统练兵场”。如果你能老老实实地把座位锁定、订单状态机、支付幂等、权限隔离这几块硬骨头都啃下来,你收获的就不是一个课设,而是一套能迁移到很多真实业务里的工程意识。下一次再遇到类似的项目,你会发现难点都不在写代码,而在想清楚每一步的状态和边界。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询