1. 项目概述
最近在帮本地一家小型影院升级他们的票务系统,老板想要一个能在线选座的解决方案。考虑到开发周期和成本,最终选择了Python技术栈,基于Django和Flask混合架构实现了这套系统。现在把整个开发过程中的技术选型、实现细节和踩坑经验分享给大家,特别适合中小型影院想要低成本实现线上选座功能的场景。
这个系统最核心的功能就是实现电影场次管理、座位可视化选择和在线支付闭环。相比传统影院管理系统,我们特别强化了实时座位的可视化展示和锁定机制,确保不会出现超卖情况。整个系统前端采用响应式设计,同时适配电脑和手机浏览器访问。
2. 技术选型与架构设计
2.1 为什么选择Python技术栈
Python在Web开发领域有成熟的生态,特别是对于快速开发中小型系统非常友好。我们对比了几种方案:
- 纯Django方案:Django Admin可以快速搭建后台,但前端交互能力有限
- 纯Flask方案:灵活性高但需要自己造很多轮子
- 混合架构:Django负责后台管理+数据持久化,Flask处理高并发的选座API
最终选择第三种方案,主要考虑:
- Django的ORM和Admin能快速搭建影院管理后台
- Flask的轻量级特性更适合处理高频的选座请求
- 两者都使用Python,团队技能栈统一
2.2 系统架构设计
整个系统分为四个主要模块:
[客户端浏览器] │ ├─ [Django模块] │ ├─ 影院管理后台 │ ├─ 影片信息管理 │ └─ 场次排期系统 │ ├─ [Flask模块] │ ├─ 实时选座API │ ├─ 座位锁定服务 │ └─ 订单处理 │ └─ [公共组件] ├─ 用户认证中心 ├─ 支付网关对接 └─ 日志监控系统数据库选用PostgreSQL,主要看中它的行锁特性对选座场景的支持。Redis用于缓存热门场次数据和临时座位锁定。
3. 核心功能实现细节
3.1 影院座位建模
座位数据模型是系统的核心,我们采用了组合模式设计:
class Screening(models.Model): movie = models.ForeignKey(Movie) hall = models.ForeignKey(Hall) start_time = models.DateTimeField() end_time = models.DateTimeField() class Hall(models.Model): name = models.CharField(max_length=50) seat_map = models.JSONField() # 影厅座位布局配置 class Seat(models.Model): STATUS_CHOICES = ( ('A', 'Available'), ('L', 'Locked'), ('S', 'Sold') ) hall = models.ForeignKey(Hall) row = models.PositiveSmallIntegerField() column = models.PositiveSmallIntegerField() seat_type = models.CharField(max_length=10) status = models.CharField(max_length=1, choices=STATUS_CHOICES)特别说明几个设计考虑:
- 影厅布局用JSON存储,方便不同影厅的个性化配置
- 座位状态机设计为三级(可用/锁定/已售)
- 行列坐标采用数字而非字母,方便后续算法处理
3.2 实时选座算法实现
选座的核心难点在于并发控制,我们的解决方案:
@flask_app.route('/api/lock_seat', methods=['POST']) def lock_seat(): seat_id = request.json.get('seat_id') user_id = get_current_user_id() # Redis分布式锁 lock_key = f"seat_lock_{seat_id}" with redis_client.lock(lock_key, timeout=5): seat = Seat.objects.get(pk=seat_id) if seat.status != 'A': return jsonify({"success": False}) # 设置15分钟临时锁定 seat.status = 'L' seat.locked_by = user_id seat.locked_until = timezone.now() + timedelta(minutes=15) seat.save() # 写入订单预处理表 OrderTemp.objects.create( user=user_id, seat=seat_id, expire_at=seat.locked_until ) return jsonify({"success": True})关键点:
- 使用Redis分布式锁避免并发问题
- 锁定状态设置超时时间(15分钟未支付自动释放)
- 采用两阶段提交模式(先锁定后支付)
3.3 座位可视化前端实现
前端使用Canvas绘制座位图,核心逻辑:
function drawSeatMap(hallData) { const canvas = document.getElementById('seat-map'); const ctx = canvas.getContext('2d'); // 绘制影厅轮廓 ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, canvas.width, canvas.height); // 绘制每个座位 hallData.seats.forEach(seat => { const [x, y] = calculatePosition(seat.row, seat.col); // 根据状态设置颜色 ctx.fillStyle = seat.status === 'A' ? '#4CAF50' : seat.status === 'L' ? '#FFC107' : '#F44336'; ctx.beginPath(); ctx.arc(x, y, SEAT_RADIUS, 0, Math.PI * 2); ctx.fill(); // 添加点击事件 canvas.addEventListener('click', (e) => { if (isInSeat(e, x, y) && seat.status === 'A') { selectSeat(seat.id); } }); }); }4. 性能优化实践
4.1 数据库查询优化
针对场次查询的热点场景,我们做了以下优化:
- 批量预加载:使用Django的select_related和prefetch_related
screenings = Screening.objects.filter( start_time__gte=now() ).select_related('movie', 'hall').prefetch_related('hall__seat_set')- 添加复合索引:
CREATE INDEX idx_screening_movie_time ON screenings (movie_id, start_time);- 热门数据缓存:
@cache_page(60 * 5) # 缓存5分钟 def get_popular_screenings(request): # ...4.2 WebSocket实时更新
为避免频繁轮询,在选座密集场景使用WebSocket:
# Flask-SocketIO实现 @socketio.on('join_room') def handle_join(data): join_room(data['screening_id']) @socketio.on('seat_update') def handle_seat_update(data): # 处理座位状态变更 seat = update_seat_status(data) # 广播给同场次的其他用户 emit('seat_changed', { 'seat_id': seat.id, 'status': seat.status }, room=data['screening_id'])5. 部署与运维经验
5.1 生产环境部署方案
我们采用的部署架构:
[Nginx] ├─ /admin/* -> Django (Gunicorn) ├─ /api/* -> Flask (uWSGI) └─ /static/* -> CDN关键配置项:
location /api/ { include uwsgi_params; uwsgi_pass flask_app:3031; # 选座API特殊超时设置 uwsgi_read_timeout 300s; } location /admin/ { proxy_pass http://django_app:8000; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }5.2 监控与告警
配置了以下监控项:
- 座位锁定成功率(<95%触发告警)
- 订单创建延迟(P99>1s触发告警)
- 支付回调成功率(<99.9%立即告警)
使用Prometheus+Grafana实现监控看板:
# prometheus配置示例 - job_name: 'flask_app' metrics_path: '/metrics' static_configs: - targets: ['flask_app:5000']6. 踩坑与解决方案
6.1 座位状态不一致问题
现象:偶尔出现座位已售但显示可选原因:缓存更新不及时解决方案:
- 采用Write-Through缓存策略
- 添加异步校验任务
@celery.task def check_seat_status(): expired_locks = Seat.objects.filter( status='L', locked_until__lt=now() ) for seat in expired_locks: seat.status = 'A' seat.save() notify_seat_released(seat.id)6.2 高并发下的性能瓶颈
压测发现:200并发时选座API延迟明显上升优化措施:
- 将座位状态检查移到Redis
- 使用连接池管理数据库连接
- 添加API限流
@flask_app.before_request def limit_seat_requests(): if request.path == '/api/lock_seat': limiter.check()7. 扩展功能实现
7.1 情侣座特殊处理
针对双人座位需要特殊逻辑:
def lock_couple_seat(seat_id): main_seat = get_seat(seat_id) adj_seat = find_adjacent_seat(main_seat) with redis_client.lock(f"couple_lock_{main_seat.id}"): if main_seat.status == 'A' and adj_seat.status == 'A': # 同时锁定两个座位 lock_seat(main_seat) lock_seat(adj_seat) return True return False7.2 退票座位释放
退票时需要处理座位状态回滚:
def process_refund(order_id): with transaction.atomic(): order = Order.objects.get(pk=order_id) for seat in order.seats.all(): seat.status = 'A' seat.save() notify_seat_released(seat.id) order.status = 'REFUNDED' order.save()这套系统上线后,影院的线上售票占比从15%提升到了60%,特别是非黄金时段的座位销售有明显改善。最大的收获是认识到即使看似简单的选座功能,在高并发场景下也有很多技术细节需要考虑。下次如果再开发类似系统,我会考虑引入更专业的分区锁方案来进一步提升性能。