ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Django的滑雪场售票系统:数据模型、并发防超卖与部署实践

基于Django的滑雪场售票系统:数据模型、并发防超卖与部署实践 1. 滑雪场售票系统的业务特性与系统边界为什么不能直接拿通用票务系统改一改先交代一下背景。我去年接了一个滑雪场的售票系统项目需求方一开始给的说法很轻巧就做个网上卖票的跟景区那种差不多。真把需求理完才发现滑雪场售票系统跟普通景区票务系统完全不是一回事硬套通用方案后面全是坑。滑雪场业务有几个非常鲜明的特点直接决定了系统设计方向。季节性强流量峰值极其集中。滑雪场一年就靠冬季几个月吃饭节假日和周末的客流量是平日的十几倍甚至几十倍。这就意味着系统平时可能一天就几百单但元旦、春节那几天瞬时并发请求会直接拉满。如果按平均流量设计系统高峰期必崩按峰值设计平时资源又大量闲置。这种极端流量模型下系统的架构取舍、数据库连接池大小、缓存策略、库存扣减方式全都要围绕高峰扛得住来设计而不是平均够用。票种结构复杂不是简单的成人票/儿童票两档。滑雪场除了入场门票还有雪具租赁雪板、雪鞋、雪杖单板双板要分开算、滑雪教练一对一、一对多按小时计费、不同雪道的缆车票初级道、中级道、高级道价格不一样、储物柜租赁、餐饮套餐等等。这些还不是孤立的客户经常是门票滑雪装备教练组合购买订单里既有商品又有服务既有按人计价的票又有按小时计费的项目。数据模型如果设计得不灵活每加一个票种就要改一次表结构维护成本极高。价格策略动态多变。工作日和周末价格不一样日场和夜场价格不一样提前预订有早鸟价当天购买是门市价老客户有会员折扣团队客户有协议价。最麻烦的是雪季中后期会根据雪况、天气、客流量临时调价。价格体系如果写死在代码里运营人员每次调价都得找开发这不现实。核销入口多样。线上买的票到场要能核销现场窗口卖的票是纸质票还有一部分是渠道商旅行社、酒店、团购平台分销出去的票需要对接第三方系统的验票凭证。这意味着系统从第一天起就要考虑多渠道订单的汇聚和统一的核销逻辑而不是只做一个简单的自营在线购票。所以说这套系统的核心难点不在于某个功能多复杂而在于业务模型要足够灵活能覆盖上述所有变化同时在关键链路上把并发和一致性做扎实。技术栈用Python Django是很务实的选择Django的ORM、Admin后台、Session管理、中间件机制都成熟稳定开发效率高对付这种业务逻辑密集、管理后台需求重的系统非常合适。下面我把整套系统的设计思路和实现细节完整过一遍。2. 数据模型设计把滑雪场的票务业务拆成这七个核心模型数据模型是整个系统的地基这块设计好了后面所有功能都是顺水推舟设计不好后期每个迭代都在填坑。我最终把业务拆成了七个核心模型它们之间的关联关系几乎覆盖了滑雪场所有售票场景。2.1 票种模型用基础信息规则替代一堆固定的票票种TicketType是整个系统的核心。每个人对票的理解不一样产品经理眼里的票是周末全天滑雪票运营眼里的票是含雪具的4小时票财务眼里的票是收入科目A。如果直接在代码里为每一种票建一张表系统就废了。我的做法是class TicketType(models.Model): name models.CharField(票种名称, max_length100) category models.CharField(票种分类, max_length50, choices[ (admission, 入场门票), (equipment, 雪具租赁), (instructor, 教练服务), (cableway, 缆车票), (locker, 储物柜), (package, 组合套票), ]) valid_days models.IntegerField(有效天数, default1) base_price models.DecimalField(基础价格, max_digits10, decimal_places2) refundable models.BooleanField(是否支持退款, defaultTrue) description models.TextField(票种说明, blankTrue)但仅靠这个还不够。滑雪场票务的最大痛点是同一种票在不同时段价格不同所以我单独设计了价格规则模型class PriceRule(models.Model): ticket_type models.ForeignKey(TicketType, on_deletemodels.CASCADE, related_nameprice_rules) weekday models.CharField(适用星期, max_length20) # 如 1,2,3,4,5 或 6,7 session models.CharField(场次, max_length20, choices[ (day, 日场), (night, 夜场), (all, 全天) ]) price models.DecimalField(执行价格, max_digits10, decimal_places2) start_date models.DateField(生效日期) end_date models.DateField(失效日期)有了这两个模型运营人员就可以在后台配置周末日场全天票380元工作日夜场票180元这类规则而不是每次改价格都改代码。价格查询的逻辑是先根据票种找到所有规则再按日期星期几、场次、生效区间筛出匹配的那条。这里有一个经验之谈价格规则一定要有生效起止日期不要只配星期。因为滑雪场经常有雪季初期特惠元旦涨价这种临时策略生效区间可以让你提前把未来一个月的价格都配好到点自动切换。2.2 场次与库存模型滑雪票跟电影票不一样不能全场通用滑雪场售票里最容易被人忽视的就是场次与库存。很多人以为卖票就是卖了就减库存但在滑雪场库存不是一个简单的数字它受限于雪道承载量、缆车运力、雪具数量多个因素。我设计了场次Session模型来管理每天的营业时段class Session(models.Model): date models.DateField(营业日期) session_type models.CharField(场次类型, max_length20, choices[ (day, 日场), (night, 夜场) ]) start_time models.TimeField(开始时间) end_time models.TimeField(结束时间) max_tickets models.IntegerField(最大售票量) sold_tickets models.IntegerField(已售票量, default0)库存扣减不要直接在sold_tickets上做加减而是通过独立、带锁的事务操作完成。后面第4章我会详细讲并发防超卖的实现这里先记住一个原则库存字段只允许在事务内被修改不允许在业务代码里随意自增自减。2.3 客户、订单与订单项模型三张表承载所有交易链路客户、订单、订单项是所有电商系统的铁三角滑雪场售票系统也逃不开这个套路。但有两点需要特别设计。第一客户模型要支持游客临时买票的场景。滑雪场有很大一部分客户是线上购票后到场直接刷码进入的他们没有注册登录的意愿。如果强制要求注册才能买票转化率会掉一大截。我的方案是订单可以关联客户但客户模型允许匿名游客状态——只要有一个手机号就能创建订单手机号即账号。等游客下次再用同一个手机号买票时系统自动把历史订单归并到这个客户名下。class Customer(models.Model): phone models.CharField(手机号, max_length20, uniqueTrue) name models.CharField(姓名, max_length50, blankTrue) level models.CharField(会员等级, max_length20, defaultnormal) created_at models.DateTimeField(创建时间, auto_now_addTrue)第二订单必须有状态机。订单状态看起来很基础但很多系统的状态流转是混乱的比如已支付的订单还能被取消。我为订单定义了完整的状态机待支付pending→ 已支付paid→ 已使用used/ 已退款refunded/ 已过期expired并且用Django的IntegerField加choices实现而不是直接用字符串。class Order(models.Model): order_no models.CharField(订单号, max_length32, uniqueTrue) customer models.ForeignKey(Customer, on_deletemodels.PROTECT, related_nameorders) total_amount models.DecimalField(订单总金额, max_digits10, decimal_places2) status models.IntegerField(订单状态, choices[ (0, 待支付), (1, 已支付), (2, 已使用), (3, 已退款), (4, 已过期) ]) created_at models.DateTimeField(下单时间, auto_now_addTrue) paid_at models.DateTimeField(支付时间, nullTrue, blankTrue)订单项OrderItem保存每一笔订单下的具体票种、数量、单价、场次信息相当于把订单和票种之间的多对多关系展开class OrderItem(models.Model): order models.ForeignKey(Order, on_deletemodels.CASCADE, related_nameitems) ticket_type models.ForeignKey(TicketType, on_deletemodels.PROTECT) session models.ForeignKey(Session, on_deletemodels.PROTECT, nullTrue, blankTrue) quantity models.IntegerField(数量) unit_price models.DecimalField(购买单价, max_digits10, decimal_places2) subtotal models.DecimalField(小计金额, max_digits10, decimal_places2)注意这里有一个细节unit_price是冗余字段保存下单那一刻的价格快照。为什么不全靠关联PriceRule去查因为价格规则是可以改的改完之后历史订单的金额就变了财务对账时会非常痛苦。这个字段虽然冗余但保证了订单的历史不可变性。再搭配核销模型TicketCode用于生成和验证入场凭证class TicketCode(models.Model): code models.CharField(核销码, max_length64, uniqueTrue) order_item models.ForeignKey(OrderItem, on_deletemodels.CASCADE, related_nameticket_codes) status models.IntegerField(核销状态, choices[(0, 未使用), (1, 已使用), (2, 已退款)], default0) used_at models.DateTimeField(核销时间, nullTrue, blankTrue)2.4 为什么订单号不建议用数据库自增ID很多初学Django的人直接拿主键ID当订单号这在真实业务里是大忌。原因有三第一自增ID暴露了系统销量竞对一看就知道你一天出了多少单第二自增ID没有业务含义客服跟客户沟通时订单号是多少——188233这种号码没法说第三多端并发时自增ID容易出现冲突和预测风险。我的方案是生成一个20位左右的订单号规则是yyyyMMddHHmmss 随机字母数字组合用Django的自带工具生成import uuid order_no datetime.now().strftime(%Y%m%d%H%M%S) uuid.uuid4().hex[:8].upper()这个方案在单机场景下足够多机部署时加上机器编号前缀就行。核销码我用的是短一点的12位随机码方便闸机扫码识别。3. 核心业务流程实现从选票到核销的一条完整链路数据模型定好之后核心业务流程就是往这个框架里填逻辑。整个系统最关键的链路有四段票价计算 - 下单锁库存 - 支付确认 - 到场核销。每一段都有值得展开的细节。3.1 票价计算不是简单查个价格是一套完整的匹配逻辑用户在页面上选择2025年1月4日周六日场滑雪票系统怎么算出价格直接查PriceRule表的逻辑是根据票种ID找到所有启用的PriceRule。过滤出start_date 目标日期 end_date的规则。再过滤出weekday包含目标日期所在星期的规则。检查session场次是否匹配。如果匹配到唯一规则直接返回价格如果匹配到多条按照具体专属规则优先于通用规则的原则排序取优先级最高的那一条。这里有个容易踩的坑weekday字段如果存的是1,2,3,4,5这种逗号分隔字符串查询效率很低而且Django的ORM对字符串包含查询contains无法走索引数据量大时会拖慢接口。后来我改成用中间表关联或者干脆存成JSON数组再配合__contains查虽然效率也不是最优但业务上完全可接受。如果你的数据量非常大建议引入Redis缓存价格规则按票种ID日期场次为key价格变更时主动失效对应缓存。写个简单的计算逻辑示意def get_price(ticket_type_id, target_date, session_type): weekday target_date.isoweekday() # 1-7 rules PriceRule.objects.filter( ticket_type_idticket_type_id, start_date__ltetarget_date, end_date__gtetarget_date, sessionsession_type ) for rule in rules: if str(weekday) in rule.weekday.split(,): return rule.price # 兜底返回基础价格 return TicketType.objects.get(idticket_type_id).base_price这个逻辑本身很简单但真实系统里还要叠加几层逻辑会员折扣、早鸟价、团购价、优惠券。这些不要全部堆在票价计算函数里否则这个函数会变成没人敢动的巨兽。我的建议是用策略模式把不同的定价策略拆开每个策略类只负责一种规则然后按优先级链式调用。3.2 下单流程事务、锁与订单状态的联动下单是整个系统中并发压力最大的环节。用户点提交订单按钮后端要完成这几件事校验票种是否可售、场次是否有库存。计算最终金额。创建订单和订单项。锁定库存预占。启动支付流程设置订单超时自动取消。库存预占这一步很关键不能简单地SELECT sold_tickets FROM session WHERE id...然后再UPDATE session SET sold_ticketssold_tickets1。这两条语句之间存在时间窗口并发请求会读到相同的sold_tickets值然后统统执行加1结果就是超卖。Django ORM里处理这个问题有两种方式原子更新和select_for_update行级锁。原子更新from django.db.models import F affected Session.objects.filter(idsession_id, sold_tickets__ltF(max_tickets)).update( sold_ticketsF(sold_tickets) quantity ) if affected 0: raise TicketSoldOutError(该场次余票不足)这段SQL本质上是UPDATE ... SET sold_tickets sold_tickets %s WHERE sold_tickets max_tickets数据库在UPDATE时会对行加锁所以并发下不会超卖。这是最推荐的方式性能最好写法也简单。F()表达式保证加减操作在数据库层完成不经过Python层的读取-修改-写入循环。select_for_update方式with transaction.atomic(): session Session.objects.select_for_update().get(idsession_id) if session.sold_tickets quantity session.max_tickets: raise TicketSoldOutError(该场次余票不足) session.sold_tickets quantity session.save(update_fields[sold_tickets])这种方式先锁住行其他事务必须等锁释放才能操作同一行逻辑直观适合在事务里还需要读取该行其他字段做判断的场景。缺点是持有锁的时间长高并发下吞吐量不如原子更新。我的建议是优先用原子更新做库存扣减selct_for_update留给复杂的业务判断场景。我当时两种都用了单纯扣库存用原子更新统计订单金额扣库存这种需要读多个表的场景用select_for_update。下单后订单状态是待支付需要设置支付超时。我用Django的transaction.on_commit配合Celery延时任务来实现下单成功后延迟30分钟执行取消超时未支付订单并释放库存的任务。from django.db import transaction transaction.on_commit(lambda: release_stock.delay(order_id))为什么要用on_commit而不是直接调delay因为delay在事务提交前触发的话任务可能先于事务提交被worker执行worker查订单会查不到。on_commit保证事务成功提交后才触发任务是Django里异步任务和数据库事务结合时的标准做法。3.3 支付回调幂等处理是底线支付接口微信支付、支付宝的回调通知不是只通知一次而且顺序可能乱。如果回调处理逻辑没有做好幂等就会出现同一次支付被回调两次订单被重复确认两张票变成四张票。幂等处理的套路是在回调处理的入口用订单当前状态做前置校验。def handle_payment_callback(order_no, transaction_id, amount): with transaction.atomic(): # 悲观锁锁住订单 order Order.objects.select_for_update().get(order_noorder_no) if order.status 1: # 已支付 return # 幂等已经处理过直接返回 if order.status ! 0: raise InvalidOrderStatusError(f订单状态异常: {order.status}) if order.total_amount ! amount: raise AmountMismatchError(金额不一致) order.status 1 order.transaction_id transaction_id order.paid_at timezone.now() order.save(update_fields[status, transaction_id, paid_at]) # 生成核销码 generate_ticket_codes(order)关键点在于if order.status 1: return。第二次回调进来时订单已经是已支付状态直接返回成功不会重复生成核销码。3.4 核销入场离线优先不要全依赖实时接口滑雪场现场的实际情况是雪具大厅里人挤人手机信号经常不好网络不稳定。如果核销逻辑完全依赖后端实时API高峰期闸机那就会出现扫码转圈圈的景象工作人员和游客都崩溃。我的方案是核销接口做成轻量级的同时设计离线验证兜底在线核销API只做一件事接收核销码返回{ valid: true/false, message: 验证通过/无效码/已使用 }。闸机系统拿到核销结果后本地缓存已核销的码遇到网络斷连时先用本地缓存判断重复核销等网络恢复后再批量同步到后端。后端核销逻辑api_view([POST]) def verify_ticket(request): code request.data.get(code) try: ticket TicketCode.objects.select_for_update().get(codecode) except TicketCode.DoesNotExist: return Response({valid: False, message: 无效的核销码}) if ticket.status 1: return Response({valid: False, message: 该票已被使用}) if ticket.status 2: return Response({valid: False, message: 该票已退款}) ticket.status 1 ticket.used_at timezone.now() ticket.save(update_fields[status, used_at]) return Response({valid: True, message: 验证通过})核销码生成时我用了UUID短码12位大写英文字母数字组合比如A3F8K2M9QX6Z。有两点要注意不要用连续的、可预测的编号容易被黄牛伪造也不要只用6位纯数字碰撞率太高且暴力枚举太容易。4. 高峰期并发与库存超卖问题真实场景下的几种解法对比这部分我想单独拿出来写因为这是整个项目里踩坑最深的环节。滑雪场元旦那天的订单量可以做到平日的三十倍系统第一次上线就差点在真实流量下翻车。4.1 问题现象同一场次同一时间点卖出了超出库存的票事情发生在雪季首周的一个周末上午10点到11点之间集中涌入了大量订单。后台告警显示某一场次的sold_tickets字段值超过了max_tickets。当时第一反应是业务代码写错了排查下来发现原因很经典我在下单逻辑里最初使用的是这段代码session Session.objects.get(idsession_id) if session.sold_tickets quantity session.max_tickets: session.sold_tickets quantity session.save() create_order(...)这段代码在并发情况下两个请求同时读到sold_tickets 80max_tickets 100此时80 10 90 100两个请求都满足条件都执行sold_tickets10最后数据库里sold_tickets变成100但实际卖出了110张。后面再来的请求读到1001005 100被拒绝但此时多出来的10张票已经造成了超卖。4.2 三种常用解法的优缺点对比方案实现方式优点缺点适用场景原子更新F(sold_tickets) quantity 条件更新性能高不持锁天然防超卖不能方便地在更新前读取字段做复杂判断纯库存扣减行级锁select_for_update() 事务逻辑直观能读能写持锁期间并发阻塞高峰期吞吐下降需要读多字段做判断的复杂流程Redis预扣DECR库存key低于0拒绝吞吐最高抗住瞬时流量引入额外组件Redis挂了库存就失联高并发秒杀场景最终我的生产方案是原子更新为主、Redis缓存层为辅真正的库存扣减用原子的UPDATE ... WHERE sold_tickets max_tickets。Redis缓存存一份场次可售余量下单前先DECR返回负数就说明没票了快速拒绝请求挡掉大部分无效流量。每笔订单支付成功后异步更新数据库里的实际库存。超时未支付的订单Celery任务定时释放Redis库存余量和数据库字段。这套组合拳的好处是Redis帮数据库挡掉了大量抢票但未支付的无效请求数据库只处理真正要落库的订单压力小很多。缺点是要处理Redis和MySQL的一致性问题比如Redis扣减了但数据库扣减失败需要定时对账任务去修正。对滑雪场这种高峰极短、平时很闲的场景这套方案性价比很高。4.3 支付防重同一个订单被支付两次的兜底处理超卖问题解决后还有一个很隐蔽的坑用户在提交订单之后由于页面卡顿重复点击了立即支付或者微信支付和支付宝同时发起回调。虽然我在支付回调入口做了幂等判断但用户端的重复请求仍然可能产生两个已支付的支付单。我的兜底方案是在用户发起支付请求时就用一个独立的payment_token做校验每个订单只允许生成一个有效的支付token第二个支付请求如果携带同一个token则直接返回已存在的支付参数。def get_payment_params(order_id): order_payment, created OrderPayment.objects.get_or_create( order_idorder_id, defaults{payment_token: generate_payment_token(), status: 0} ) if not created and order_payment.status 1: return order_payment.payment_params # 已生成的支付参数直接复用 # 生成支付参数并更新...这样即使客户端连续点击十次后端的支付请求也只会生成一次支付平台只会收到一个订单号的支付请求避免了重复支付和超付的问题。5. 后台管理与第三方对接Django Admin的定制和接口设计滑雪场这套系统面向C端用户的是小程序/H5购票页面但运营人员日常大量使用的其实是Django Admin后台。Admin定制得好不好直接决定运营同事每天的工作效率。5.1 Django Admin定制不是能用就行要按运营流程设计默认的Django Admin只做了增删改查但运营人员的实际工作流程是查看今天的票务销售情况 - 发现某场次库存快没了 - 调整价格或增加库存每一步都要形成闭环。我的定制策略列表页按高优字段排序和筛选。例如订单列表默认按创建时间倒序筛选面板固定显示支付状态、场次日期、票种三个常用维度。这个用Django Admin自带的list_display、list_filter、search_fields就能实现要花心思的是字段顺序和权重把运营最关心的信息放最前面。class OrderAdmin(admin.ModelAdmin): list_display [order_no, customer_phone, total_amount, status, paid_at] list_filter [status, paid_at] search_fields [order_no, customer__phone] date_hierarchy paid_at操作按钮要带业务语义。比如强制退款这个操作不能直接在Admin里调用Model的delete而是要走完整的退款流程状态置为已退款、释放库存、核销码失效、调用支付平台退款接口。我通过重写admin.ModelAdmin.actions来实现admin.action(description批量退款) def refund_orders(self, request, queryset): for order in queryset: try: refund_order(order.id) except Exception as e: self.message_user(request, f订单 {order.order_no} 退款失败: {e}, levelerror)Admin界面美化方面我用了django-jazzmin它的菜单折叠、主题配色、图标支持都很好不需要写大量前端代码就能让后台看起来比较专业运营同事接受度很高。5.2 第三方渠道对接分销商开票要有独立的凭据体系滑雪场会把票交给旅行社、酒店、团购平台代卖每个渠道的对接方式千差万别。有的渠道是一个月结算一次定期批量发票号过来有的渠道是实时API对接出票成功后反馈凭证号还有的渠道根本没系统就直接让游客报渠道名手机号。面对这种渠道碎片化的现状系统设计上要有一条原则核心订单模型与渠道无关渠道信息通过扩展字段或者独立模型挂在订单侧而不是为每个渠道单独建订单表。我建了一个ChannelOrder模型专门记录渠道来源、渠道订单号、渠道结算状态。分销商票和直营票在核销上完全一致但财务对账时可以按渠道分组汇总。webhook接口方面Django的csrf_exempt装饰器和签名验证是标配每个渠道分配一个唯一的app_key和secret回调请求必须携带签名HMAC-SHA256 hash防止伪造回调。这块细节很多核心思路是信任要验证接口要幂等失败要重试账单要能对。6. 部署与性能优化让Django项目在雪季高峰真正跑得稳代码写得再好部署环境不合理一样崩。这一章把部署架构和性能优化的实践经验完整写出来供参考。6.1 部署架构Nginx Gunicorn PostgreSQL最稳妥很多人喜欢用Django自带的runserver直接上生产这是大忌。runserver是单进程、自带静态文件服务性能极差且不安全。我的生产架构是Nginx负责静态文件、媒体文件、反向代理、SSL终止、请求限流。GunicornDjango的WSGI服务器配置多个worker进程建议workers CPU核心数 * 2 1。PostgreSQL比MySQL在某些复杂查询和并发控制上更稳尤其适合事务密集型的票务系统。如果你更熟悉MySQL也没问题但要注意连接池配置。Gunicorn的启动命令gunicorn ski_resort.wsgi:application -w 5 -b 127.0.0.1:8000 --timeout 30关键参数--timeout必须设置默认30秒比较合理。如果业务接口响应超过30秒worker会被强制杀掉不过正常情况下接口不应该这么慢。数据库连接池方面Django默认的CONN_MAX_AGE建议设置成一个合理的值比如60秒避免每个请求都重新建立数据库连接DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: ski_resort, USER: ski_app, PASSWORD: your_password, HOST: 127.0.0.1, PORT: 5432, CONN_MAX_AGE: 60, } }6.2 缓存策略接口层和数据库层的两级缓存Django的缓存框架我用了Redis。策略分两级接口层缓存针对票价查询、票种列表这些变化不频繁的只读接口直接用cache_page做全页面缓存TTL设置5分钟。价格调整后运营在后台点一下清除缓存按钮用signal监听PriceRule的post_save事件主动失效相关缓存key。from django.core.cache import cache cache_page(60 * 5, key_prefixticket_list) def ticket_list(request): # 查询所有在售票种 ...数据库查询缓存针对高频、实时性要求不那么高的统计数据比如今日已售XX张用cache.set()cache.get()包裹查询结果TTL 30秒到1分钟大大降低数据库压力。但是有一个底线凡是涉及支付回调、库存扣减、核销状态变更的接口一律不走缓存所有数据直读数据库。这类接口的正确性优先于性能缓存一旦不一致就是资金安全事故。6.3 日志与监控雪季运营期间的必修课最后聊聊日志和监控。很多人做项目觉得功能上线就完事了但在雪季高峰期系统一旦出问题运营打电话过来时你连日志都没有根本没法定位问题。我的logging配置LOGGING { version: 1, handlers: { file: { class: logging.handlers.RotatingFileHandler, filename: /var/log/ski_resort/app.log, maxBytes: 1024 * 1024 * 100, # 100MB轮转 backupCount: 10, } }, loggers: { django: {handlers: [file], level: INFO}, ski_booking: {handlers: [file], level: INFO}, } }特别重要的三处日志必须打印支付回调入参、库存扣减行为和结果、核销异常。这三类日志是排查资金和票务问题的一手证据。监控上Django项目接入Sentry是最快的路子配置个django-sentry线上异常自动上报包括调用栈、请求参数、用户信息比翻日志高效太多。再配一个简单的定时任务Celery beat每隔5分钟检查未支付订单数和今日销售额异常时发通知到工作群。7. 做完这个系统之后的几点复盘项目交付后回看整套开发过程有几点心得体会值得记录。第一业务模型比代码重要。滑雪场售票系统表面上是买票两个字但拆开来看涉及场次管理、库存预占、价格规则、渠道分销、支付回调、离线核销任何一块理解不到位后期都要返工。尤其是场次和库存的设计如果没有提前跟运营确认清楚承载量和售卖规则上线后按真实数据压测必出问题。第二不要把并发问题留到上线后才处理。超卖问题的本质是并发下的竞态条件这类Bug在低流量下根本暴露不出来。如果项目有上线压力建议在联调阶段就用压测工具如Locust、wrk模拟高峰流量把库存临界值卡准。我做过的实测是用Locust起50个并发用户同时抢同一个场次反复跑了三轮确认库存没有超卖后才放心。第三Admin后台的重要性不亚于C端。滑雪场运营人员每天大量时间都在Admin里操作查订单、调价格、看统计、处理退款。Admin做得顺滑运营效率大幅提升间接影响整个雪场的营收流转。花时间在Admin的列表筛选、批量操作、数据导出上非常值得。第四给异常恢复留好口子。票务系统一定会遇到这些事支付成功但没生成核销码、用户扫码了但核销失败、退款成功了但库存没释放、渠道订单重复同步。应对这些异常不能靠手动改数据库要在系统里内置对账工具和补偿任务。我在系统里做了一个对账中心每天晚上自动比对支付平台的交易记录、订单状态、核销状态不一致的自动告警。这套滑雪场售票系统从数据建模到上线运行前后经历了三次较大的结构调整最终稳定扛住了雪季高峰期的流量。如果你的业务形态也是那种票种多、价格变化快、高峰流量猛的线下服务业态这套基于Django的设计思路完全可以拿过去做参考。核心就是把业务规则从代码中抽离到数据表把并发安全放在设计的中心把异常处理的兜底做在前面。按这个思路走哪怕业务具体细节不一样整体框架依然成立。
返回列表