1. 为什么一个预约系统要同时挂两个Web框架如果你去翻开源社区里的Python项目会发现一个有趣的现象大部分Web应用要么用Django要么用Flask很少有项目把两者放在一起用。所以当这个django-flask基于python羽毛球馆预约管理系统出现在我面前时第一反应是——这绝对不是为了炫技而是被实际业务逼出来的。事情还得从某高校体育中心的真实需求说起。羽毛球馆一到周末就是满场状态线下排队登记、电话预约经常出错A同学订了晚上7点的场地结果6点40到场发现上一场还没打完两边差点吵起来。运营方提出一个很朴素的需求做一个能在线看场地状态、在线预约、到点自动释放的系统。当时给我的约束条件有三个第一预算有限不能上太重的商业SaaS方案第二管理员需要一套完整的数据后台能看营收、看坪效、看时段热度第三后续可能要给第三方平台开放场地查询接口但主站的开发节奏不能被拖累。于是就有了这个双框架方案Django承担主站的全部核心业务——用户注册登录、场地管理、场次排布、订单状态机、支付回调、管理后台Flask则作为独立的轻量服务专门跑对外数据接口和定时统计任务。为什么不让Flask干更多活因为Flask的灵活是双刃剑项目一大了ORM、表单、Admin这些都得自己拼维护成本反而高。Django恰恰把管理后台、ORM、迁移、认证这些全家桶都内置了适合做重的部分。而Flask的轻量又让它特别适合做那种只暴露几个接口的微服务启动快、内存占用低、和主站完全解耦挂一个独立的端口运行互相不干扰。你可以把这个架构理解成一个综合商场里既有大而全的物业管理处Django负责核心业务也有几个专门窗口负责对外合作Flask负责接口服务。两者不抢地盘但数据完全打通。下面我按实际开发顺序从数据模型到接口实现再到部署排障把整个系统的每一个关键决策讲清楚。2. 数据建模场馆预约系统的核心是时间片和状态机2.1 场地、时段、订单三张表是怎么串起来的任何一个预约系统的地基都是时间片。羽毛球场馆管理的本质不是管场地这个物理实体而是管某块场地在某个时间点能不能被预定。所以我在设计表结构时把物理场地和可预约时段拆成了两层class Court(models.Model): name models.CharField(max_length50, verbose_name场地编号) venue models.ForeignKey(Venue, on_deletemodels.CASCADE, related_namecourts) court_type models.CharField(max_length20, choices[(clay, 塑胶), (wood, 木地板)], defaultclay) is_active models.BooleanField(defaultTrue, verbose_name是否开放预约) class CourtSlot(models.Model): court models.ForeignKey(Court, on_deletemodels.CASCADE, related_nameslots) start_time models.TimeField(verbose_name开始时间) end_time models.TimeField(verbose_name结束时间) date models.DateField(verbose_name可约日期) price models.DecimalField(max_digits6, decimal_places2, verbose_name时段价格) status models.CharField(max_length10, choices[ (available, 可约), (locked, 锁场中), (booked, 已预约), (closed, 暂停开放) ], defaultavailable, db_indexTrue) class Meta: unique_together (court, date, start_time, end_time)可能有人会问为什么不直接给Court加一个is_booked的布尔字段非要多一张CourtSlot表这是我在第一版放过的一个错误。当时图省事在Court上直接记录当天是否被订结果发现根本没法回答明天下午3点到5点谁在用这种基础问题。把时间片拆成独立的一张表每块场地每天按营业时间生成固定数量的slot每个slot记录自己的状态业务逻辑就变得非常清晰用户选场地本质上是在选一个具体的slot。这里有一个非常关键的细节——场地状态不能用布尔值。我见过很多半成品项目用is_availableTrue/False表示场地是否可约这在预约系统里完全不够用。因为从用户点击预约到最终支付完成之间有一个时间窗口这个窗口里场地既不能算空闲也不能算已占用它需要一个中间态叫锁定中。如果没有这个状态要么有人占着坑不付款导致场地闲置要么高并发时一块场地被同时卖出去。所以上面表里我专门用了四态字段available可约、locked锁场中、booked已预约、closed暂停开放。整个系统的核心其实就是把这四个状态按照一定的规则做流转。2.2 订单状态机的流转规则用户下了单但没付款这个订单算什么用户付款成功但活动还没开始订单算什么活动已经结束三个月了订单状态需不需要归档所有这些问题都要靠一张严谨的订单表来回答。class Order(models.Model): order_no models.CharField(max_length32, uniqueTrue, verbose_name订单号) user models.ForeignKey(User, on_deletemodels.PROTECT, related_nameorders) slot models.ForeignKey(CourtSlot, on_deletemodels.PROTECT, related_nameorders) amount models.DecimalField(max_digits6, decimal_places2, verbose_name实付金额) status models.CharField(max_length20, choices[ (pending, 待支付), (canceled, 已取消), (paid, 已支付), (refunding, 退款中), (refunded, 已退款), (completed, 已完成), ], defaultpending, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue) paid_at models.DateTimeField(nullTrue, blankTrue) cancel_reason models.CharField(max_length200, blankTrue, verbose_name取消原因)我给订单设计了六个状态核心逻辑是用户选中某个slot系统先把slot状态改为locked同时生成一个pending状态的订单如果用户在15分钟内完成支付slot变booked订单变paid如果15分钟内没支付定时任务把slot释放回available订单标记为canceled并记录取消原因为超时未支付用户主动取消时分两种情况活动开始前2小时以上取消直接退款2小时内取消按约定不退费订单转refunded或保持paid并备注。这个状态机看起来简单但它直接决定了后面并发防超卖的方案能不能落地。因为slot状态的变化必须和订单状态的变化放在同一个事务里否则就会出现系统崩溃时场地被锁死、订单却已取消的脏数据。2.3 为什么要用Tinyint不用DateTime来区分场次我第一次建表时直接把start_time设计成了DateTimeField后来发现这是一个非常别扭的设计。比如运营方想把每晚8点场的价格统一调整用DateTime字段就要跨日期改几十条数据再比如用户查看本周二晚上有哪些场地可约用DateTime虽然也能查但每一次查询都要夹带日期和时间的双重过滤条件语句写得又长又慢。最终采用的方案是日期和时间分离date用DateFieldstart_time和end_time用TimeField再加上unique_together约束保证同一块场地在同一个日期同一个时间段不会生成两条记录。这样做还有另一个好处——按天批量生成slot非常方便。场馆每天营业时间是9点到22点以30分钟为一个粒度那么一块场地一天就是26个slot10块场地就是260条记录。用一个for循环加get_or_create就能把一周的数据全部预生成好运营方还能单独把某天某个时段的status改成closed来临时闭馆不用动其他数据。2.4 用户表与管理后台的设计取舍用户表用的Django内置的AbstractUser做扩展增加balance余额字段和membership_type会员类型。有人会问预约系统为什么要做余额羽毛球馆的实际运营里很多场馆其实是先充值后扣费的模式或者至少允许用户预充值每次预约从余额里冻结扣款这样能显著降低支付频率和支付失败率。我在系统里做了两种付款路径一是微信/支付宝在线支付二是余额冻结支付。前者走异步回调后者走事务直接扣减两种方式在订单表里都是殊途同归——最终落一笔paid状态的订单。Django自带Admin后台在初期帮了很大忙运营人员可以直接在后台里改场地状态、给用户退款、看订单明细。但用了一段时间后发现原生Admin太裸了查询一个用户的历史订单要翻半天导出报表也没有。后来我用django-admin-exporter做了一个简单的Excel导出功能Admin里增加一个导出本月订单的按钮运营方的满意度立刻上升了一个档次。这也是选择Django的隐形红利——后台系统几乎不用从零搭。3. 在线选场与防超卖核心接口的并发控制3.1 选场页面背后的两个关键接口用户进入预约页面后前端会向我方Django主站请求两个接口一个是获取某天所有场地的可用slot列表另一个是创建订单锁定场地。这两个接口是整个系统里最核心也最容易出问题的部分。第一个接口返回的数据结构大概是场地编号、时间段、状态、价格。实现上就是一个按照date过滤、按court和start_time排序的查询加上status字段做筛选这部分没有难度。真正有难度的是第二个接口——创建订单时的原子性保障。第一次上线测试时我用Postman同时发起10个请求预约同一块场地的同一个slot结果是灾难性的有7个请求都返回了下单成功。原因显而易见我先用了查状态available再改为locked这种两步操作在高并发下这两个操作之间有一个空隙多个请求同时读到的是同样的旧状态然后一起执行更新操作超卖就发生了。这类问题在并发编程里有个通俗的名字叫check-then-act。解决思路也明确把检查状态和更新状态合并成一个原子操作。Django里可以有几种做法我最终选的是select_for_update()配合事务原理是给这行记录加上行级锁让其他事务必须等前一个事务提交后才能读。from django.db import transaction transaction.atomic def create_booking(user_id, slot_id, pay_method): slot CourtSlot.objects.select_for_update().get(idslot_id) if slot.status ! available: raise SlotUnavailable(该时段已被锁定或预订) order Order.objects.create( order_nogenerate_order_no(), user_iduser_id, slotslot, amountslot.price, statuspending ) slot.status locked slot.save(update_fields[status]) return order3.2 定时释放超时未付订单Celery任务的实际参数有pending状态就得有超时处理。用户可能下单后切出去聊天忘了付款场地就这么被锁着其他想订的人订不了。所以必须有一个定时任务负责把超时未付的pending订单取消并释放对应的slot。我用的是Celery的beat定时任务每5分钟扫描一次所有pending状态的订单把创建时间超过15分钟的先标记canceled再释放slot。这里有一个小细节释放slot时不能直接改成available要顺手判断一下当前时间——如果用户预定的slot已经开始超过10分钟了那这块时段就不该被释放给其他用户了应该直接标记closed。类似这种业务边界只靠定时任务里的单条SQL是照顾不到的shared_task def release_expired_pending_orders(): deadline timezone.now() - timedelta(minutes15) expired_orders Order.objects.filter(statuspending, created_at__ltdeadline) for order in expired_orders: order.status canceled order.cancel_reason 超时未支付系统自动取消 order.save() slot order.slot if slot.date timezone.localdate() and slot.start_time.hour - timezone.localtime().hour 0: slot.status closed else: slot.status available slot.save(update_fields[status])3.3 动态定价最便宜但最有效的营收杠杆场馆预约系统里其实藏着一个小型的动态定价需求。羽毛球馆的潮汐现象极其明显——工作日晚高峰19:00-21:00和周末上午是黄金时段几乎场场爆满工作日下午2点到5点则惨淡得很可能全馆只有两三个人。我做了一个很朴素的定价策略不搞复杂的实时竞价而是让运营方在后台为每个时段的slot设置一个基础价格系统在生成slot时自动套用高峰系数。比如基础价是每小时50元工作日晚高峰系数1.4周末下午1.6凌晨或工作日白天0.8。运营方只需要维护一张价格策略表不用天天手动去改每条slot的价。这个策略上线后工作日下午场的使用率提升了大概四成因为便宜了很多退休职工和学生开始约下午场。class PricingRule(models.Model): venue models.ForeignKey(Venue, on_deletemodels.CASCADE) weekday models.IntegerField(choices[(i, f周{i}) for i in range(7)]) start_time models.TimeField() end_time models.TimeField() factor models.DecimalField(max_digits3, decimal_places2, default1.0)生成slot时根据星期和时间段去匹配对应的PricingRule用基础价乘以系数得到最终价格。这样系统里虽然存了几千条slot记录但价格更新只需要改几十条规则运营压力小很多。3.4 预约取消的2小时规则怎么落地一个重要业务规则是活动开始前2小时以上允许免费取消2小时以内取消不退费。这个规则的落地其实不是技术难点难在边界情况的处理。比如用户10点半下的单买了晚上7点的场12点半来取消此时距离开始还有6.5小时应该允许但要是下午5点来取消距离开始只剩2小时就需要拦截。我直接在前端和后端各做了一层校验。前端负责友好提示后端负责强校验。后端判断逻辑就一条slot.start_time拼上当天的日期减去当前时间如果小于等于2小时直接拒绝用户的取消请求。这个逻辑没有放到订单表里而是写在了视图函数的业务层保证无论从哪里发起取消请求网页端H5、后台管理员手动取消、定时任务都要走同一套取消逻辑。4. Flask微服务怎么和Django主站协同4.1 独立端口、独立进程Flask服务要管哪些事主站的Django程序跑在8000端口Flask这个轻量服务跑在5001端口。这种物理隔离让两个服务的发布互不影响——改Flask的接口不会触发Django的重启Django发新版也不会让接口服务断线。那Flask到底承担了哪些工作我在这个系统里给它分配了三类活第一类是对外的只读查询接口。第三方的场馆聚合平台、小程序合作方需要查询某场馆某天还剩哪些场次这类接口并发大、逻辑简单、不涉及写操作放Django里做显得臃肿放Flask里反而清爽。第二类是数据报表导出。Django的Admin后台虽然能看数据但生成图表、导出Excel这种操作会占进程把报表服务独立出去避免拖慢主站的接口响应。第三类是跑一些非主链路的小逻辑比如每日凌晨统计前一天的营收、场地利用率、打满率的聚合计算结果写进一张独立的报表表里。from flask import Flask, jsonify, request from redis import Redis from db import get_db_connection app Flask(__name__) redis_client Redis(host127.0.0.1, port6379, db1) app.route(/api/v1/court-status, methods[GET]) def court_status(): venue_id request.args.get(venue_id, typeint) date request.args.get(date, typestr) if not venue_id or not date: return jsonify({code: 400, msg: 参数缺失}), 400 cache_key fcourt_status:{venue_id}:{date} cached redis_client.get(cache_key) if cached: return jsonify({code: 0, data: json.loads(cached)}) slots query_available_slots(venue_id, date) redis_client.setex(cache_key, 60, json.dumps(slots)) return jsonify({code: 0, data: slots})4.2 双框架之间的数据共享方案两个框架跑在两个进程里用户和订单数据存在MySQL中但MySQL的密码、Redis的连接串、第三方支付密钥这些敏感配置两边都要用。怎么共享最忌讳的做法是把配置写死在各自的配置文件里改一处漏一处。我这边的做法是把公共配置统一放在一个config.yaml里两个项目启动时各自加载。MySQL和Redis是数据连接的底层公共设施Django用自己的一套ORMFlask直接用SQLAlchemy连接MySQL两边不共享连接池但数据是同一份。热点数据如当天各场地状态用Redis做缓存Django写的时候会顺便更新RedisFlask读接口优先走Redis这样两边都能拿到一致且最新的数据。另一个需要共享的是用户鉴权信息。Flask接口服务如果要查询这个用户是不是会员总不能去调用Django的session表吧。我采用的是内部签名方案Django主站调用Flask接口时在Header里带上X-Internal-Token和一个基于时间戳的签名Flask服务验证签名通过后放行不需要做完整的OAuth。这样省去了很多不必要的鉴权流程。4.3 Flask代码量控制越少越稳定有人会把Flask这个服务越写越大最后又变成一个小Django。我给自己定过一个规矩Flask项目里的函数不允许超过30行每个文件不允许超过200行。因为Flask的服务定位就是薄薄的一层接口壳子逻辑越简单越好。一旦发现某个接口的逻辑开始变复杂就应该把它拆成函数或者干脆把这段业务挪回Django里去处理。比如订单催付短信的发送、给用户推送预约成功模板消息这些功能我在Flask里只做触发实际的模板渲染和第三方API调用还是通过消息队列丢给Django的异步任务去执行。Flask不持有任何业务状态它只是一个转发器和查询器这是它稳定运行的关键。5. 部署、压测与上线后的性能优化5.1 上线初期的部署架构整个系统部署在一台4核8G的云服务器上操作系统是Ubuntu 20.04。部署工具用的是Supervisor管理常驻进程Nginx做反向代理和静态资源处理。架构图简化下来就是Nginx监听80/443端口按路径转发到两个上游——/admin、/api/order等Django主站路径走Gunicorn的8000端口/api/v1/court-status这类对外查询接口走Flask的5001端口。数据库用MySQL 8.0Redis用来做缓存和分布式锁。Django这边的部署选的是Gunicorn4个worker进程配合--threads 4Celery独立跑一个worker进程处理异步任务。Flask那边因为接口简单用Gunicorn跑2个worker就够了多了反而浪费内存。5.2 压测数据与两个性能瓶颈上线前我用Locust模拟了真实场景压测同时在线选场、提交订单、模拟支付回调。第一轮压测结果很扎心在200并发下下单接口的TP99响应时间达到了8.1秒数据库连接直接被打满大量请求报MySQL Too many connections错误。排查下来有两个核心瓶颈。第一个瓶颈是N1查询。订单列表接口里每查一个订单就会去查一次关联slot和场地信息等于把一次本该是三条SQL的查询放大成了几十条。解决办法很简单用Django的select_related一次性把外键关联的表JOIN出来。orders (Order.objects .select_related(slot__court, user) .filter(created_at__range(start, end)))第二个瓶颈是MySQL连接数不够。Gunicorn4个worker每个worker开4个线程算上Celery和Flask的DB连接不满载也有20多个连接。压测时瞬间创建大量新连接把MySQL默认的151个连接数打爆了。我做了两件事一是把MySQL的max_connections调到500二是在Django的数据库配置里启用CONN_MAX_AGE60让连接可以复用减少频繁创建销毁的开销。做完这两轮优化重新压测500并发下单接口TP99降到了1.2秒数据库连接稳定在80个左右没有报错。这个数据对于羽毛球馆这种最高峰几百人同时在线的场景已经是三倍冗余了。5.3 Redis缓存策略哪些数据值得缓存预约系统里不是所有数据都适合缓存。我一开始图快把场地slot列表整个放进了Redis缓存时间为5分钟。结果运营方反馈用户在手机端看到某块场地显示可约点进去却提示已被锁定体验非常差。这是因为slot状态变化频率高缓存时间稍微长一点就会产生数据不一致。所以后来我调整了缓存策略只对不可变或极少变的数据做缓存。比如场馆的基本信息、价格策略、场地的静态列表这些数据缓存5分钟完全没问题而slot的实时状态一律不缓存直接查数据库。例如Flask那个/api/v1/court-status接口Redis里只缓存30秒即使短暂过期回源数据库压力也可控。这个调整让我意识到一个事儿缓存是性能优化手段不是银弹。用错地方反而会产生线上事故缓存的过期时间一定要根据业务容忍度来设计。6. 踩过的坑与完整的定位排查过程6.1 并发下场地被抢问题的完整排查链路现象上线后第三天运营在后台发现同一时间段同一块场地出现了两笔已支付订单。用户A和用户B都收到了预约成功的短信到了现场才知道场地撞车了。排查第一步我先查订单表确认这两笔订单对应的slot确实是同一个且状态都是paid。这说明超卖已经发生。排查第二步复现现场我用脚本模拟100个并发请求抢同一个slot查看logs果然有多个请求走完了创建订单的逻辑。排查第三步定位代码发现创建订单的视图函数里我虽然在前面写了slot.status available才允许创建但这个检查没有放在transaction.atomic里也没有加锁。随后我又做了一个更深入的测试把select_for_update()加上去再跑同样的100并发结果只剩1个请求能成功。根因很清晰check-then-act之间隔了一条路火车从铁轨上过去了后面的车不知道。修复方案就是前面代码里展示的select_for_update()加事务。这个坑让我明白Django的ORM默认不是一个高并发安全的工具涉及检查再更新这种逻辑必须显式加锁。6.2 支付回调与取消操作打架的问题现象用户付款刚好在取消动作的瞬间到达结果订单先被标记了canceled随后支付回调又把订单改成paid用户钱扣了、场地没了。这类问题的本质是并发操作同一行订单数据。我的应对策略分两步第一步在支付回调入口处加锁避免同一个订单的多次回调并发执行第二步加一个状态机校验函数定义清楚订单状态之间允许的流转方向从canceled不能再跳回paid从paid也绝不能变成pending凡是不符合状态机方向的更新一律拒绝。ORDER_STATUS_TRANSITIONS { pending: {paid, canceled}, paid: {refunding, completed}, canceled: set(), refunding: {refunded}, refunded: set(), completed: set(), } def transition_order(order, to_status): allowed ORDER_STATUS_TRANSITIONS.get(order.status, set()) if to_status not in allowed: raise InvalidStatusTransition(f{order.status} - {to_status} 不允许) order.status to_status order.save(update_fields[status, updated_at])6.3 时区问题导致场地释放时间错乱现象有几天凌晨1点多系统自动取消了大量下午场的订单场地被误释放用户收到您的预约已自动取消的短信。排查发现问题出在timezone.now()和本地时间的差异上。服务器设置的时区是UTC而业务时间是中国标准时间。我在Celery定时任务里判断slot开始时间是否已过时直接用slot.start_time和timezone.now()比较忽略了start_time是不带日期的。修复方法是在任务里显式构造一个完整的本地datetime再进行比较datetime.combine(order.slot.date, order.slot.start_time)并加上pytz.timezone(Asia/Shanghai)做时区转换。6.4 管理后台导出Excel乱码现象运营用Excel打开从Admin导出的订单表中文全部显示成锟斤拷。原因是CSV/Excel文件的编码问题。Django的StreamingHttpResponse输出CSV时默认用系统编码而Excel对中文环境需要utf-8-sig格式带BOM头。于是我导出的代码里增加了BOMresponse HttpResponse(content_typetext/csv; charsetutf-8-sig) response[Content-Disposition] attachment; filenameorders.csv response.write(\ufeff)加了BOM后Excel打开中文正常再也没有被运营追着问怎么全是乱码了。7. 这个项目还能往哪些方向扩展系统上线稳定运行后我其实一直在想扩展的问题。从场馆方的角度看最想要的是会员储值卡和次卡用户充值后每次预约自动扣余额这比每次在线支付体验好得多也能牢牢锁住用户的预存款。技术上只需要在Payment表里增加一笔balance_payment类型支付回调逻辑等价替换成余额扣减加日志。从用户角度看想约场地之前通常会关心有没有球友组队。可以在系统里加一个拼场功能用户可以选择只约半场并公开找搭子另外一人加入后拼成一个完整预约单费用自动分摊。这个功能的核心是拼场状态的聚合——两个或多个订单共享同一个slot需要多一个BookingGroup模型。还有一块很大价值但容易被忽略的是场馆运营数据看板。把slot的成约率、各时段的用户爽约率、场地翻台率做成可视化图表运营方就能知道该不该在下午茶时段推低价券、周末上午要不要加开场地甚至能预测未来一周的热门时段去提前定价。目前这一块我们只是用Flask定时任务生成统计报表后续完全可以引入一套更完善的数据工具。我个人的建议是如果你的业务规模还在单场馆阶段这套DjangoFlask的双框架架构确实够用不必为了统一技术栈去把Flask重写成Django接口也没必要为了追求微服务去引入一大套服务治理组件。两个框架协同工作各管一块、各守边界在实际运维中是非常顺手的一种状态。真到了需要增加第二个馆、第三个馆的时候再考虑拆出统一的服务层也不迟。
阅读完成 · 觉得有帮助?