前阵子帮团队从零搭了一套广告服务型的互联网平台,技术栈选了 Python + Django。整套系统涵盖了广告主开户、广告创建与审核、媒体广告位接入、投放链路、计费扣费和报表统计这些完整功能。广告平台比一般业务系统麻烦的地方在于它有三方参与者——广告主、媒体方、平台方,每一方都有自己的诉求,而技术要做的就是在同一个框架下把这三方的诉求高效串起来,既要让广告主能精准投放并看到真实效果,也要让媒体方能稳定接入并拿到合理收入,平台自身还要控制风险、保证准确性。
这篇内容就把这套平台从技术选型到数据模型,从投放链路到性能优化,再到我实际踩过的坑完整拆一遍。如果你也用 Python 规划过类似的广告系统,或者公司内部想搭一个聚合投放管理平台,这篇应该能让你少走不少弯路。
1. 项目整体设计与技术选型
1.1 为什么是 Python + Django,而不是别的组合
先说结论:用 Django 做广告管理系统的确不是性能最优解,但它是"从零到一"阶段最稳的路线。这里有个核心矛盾——广告平台的核心链路是读多写少、并发高、对延迟敏感的实时接口,按理说用 Go 或 Java 更合适。但我们的实际情况是团队主要技术栈是 Python,而且项目要先快速跑通业务模型,验证广告主、媒体、平台三方协作的流程,没有时间也没必要一上来就上微服务。
Django 在这个场景下有几个实打实的优势:
- 自带 Admin 后台,广告审核、广告主管理、媒体站点管理这些运营操作几乎不用额外开发前端页面,Django Admin 改一改配置就能用。广告平台的运营后台非常多,审核、对账、财务、介质管理,Admin 能省掉一大半工作量。
- ORM 足够成熟,广告业务的数据模型关系很复杂,广告计划、广告组、创意、广告位、站点、账户之间全是外键关系,Django ORM 的 select_related / prefetch_related 用起来顺手,模型迁移和管理也比很多 Python 框架省心。
- 生态完整,缓存、队列、任务调度、权限认证都能找到现成的库。广告平台需要的定时报表、异步扣费、跑批任务,Celery + Redis 直接一套搞定。
如果换成 Flask 或者 FastAPI,权限体系、ORM、Admin、后台管理全都要自己组装,工期至少翻倍。FastAPI 的性能虽然更好,但独立开发一个小团队很难在短时间内补齐后台管理的部分。我的建议是:广告平台的第一版老老实实用 Django,先把业务跑通,等流量真正上来了再考虑把实时投放接口抽出去换成高性能语言。
1.2 广告平台的业务模型与核心链路拆解
广告服务型平台本质上是一个撮合引擎,对外给广告主提供广告管理服务,对内接入各类媒体资源。业务角色拆成三方:
- 广告主:创建广告计划、设置每日预算、上传创意、查看消耗与效果报表。
- 媒体方:提供广告位,通过平台 API 拉取广告并展示,平台按展示或点击给媒体结算费用。
- 平台方:负责广告审核、投放调度、数据统计、费用结算,是核心的撮合与管控角色。
整个业务链路可以拆成下面这几步:
- 广告主创建计划并充值。
- 平台审核广告(图片、文案、落地页合规性)。
- 媒体端的广告位发起广告请求,平台收到请求后匹配符合条件的广告。
- 平台返回创意素材和跳转链接,媒体展示。
- 媒体上报展示事件和点击事件。
- 平台计算扣费,广告主看报表,媒体看结算单。
这个链路里最核心的两个字是"匹配"和"计算"。匹配决定广告能不能投出去、投给谁;计算决定钱怎么扣、扣多少、剩多少。作为一个 Django 项目,最先要做的不是写接口,而是把这两件事背后的数据结构理清楚。
2. 数据模型设计:把业务落到表里
2.1 账户与广告主体系
广告平台的账户体系和普通业务的用户体系不太一样,它需要区分"登录账号"和"广告主主体"。一个广告主主体下面可以有多个登录账号(比如财务、投放优化师各有各的账号),而充值、扣费、报表是按主体维度汇总的。所以我把账户模型拆成了两层:
- AuthUser:继承 Django 的 User 模型,只负责登录认证。
- Advertiser:广告主主体,包含公司名称、行业类别、联系信息、审核状态等。
关联关系是 AuthUser 通过外键指向 Advertiser,多个 AuthUser 可以归属同一个 Advertiser。这样财务开票、投放操作都能按主体权限来控制,不会被单一的 User 模型绑死。
模型大概长这样(我做了简化处理):
class Advertiser(models.Model): STATUS_PENDING = 0 STATUS_APPROVED = 1 STATUS_REJECTED = 2 STATUS_CHOICES = [ (STATUS_PENDING, "待审核"), (STATUS_APPROVED, "已通过"), (STATUS_REJECTED, "已驳回"), ] name = models.CharField("企业名称", max_length=128, unique=True) industry = models.CharField("行业分类", max_length=64) status = models.SmallIntegerField("审核状态", choices=STATUS_CHOICES, default=STATUS_PENDING) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Account(models.Model): advertiser = models.ForeignKey(Advertiser, on_delete=models.CASCADE, related_name="accounts") user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="ad_account") role = models.CharField("角色", max_length=32) # manager / operator / finance balance = models.DecimalField("账户余额", max_digits=12, decimal_places=2, default=0)这个设计的核心思想是:余额挂在 Account 上但统计报表按 Advertiser 聚合。实操中要注意余额字段的精度,用 DecimalField 而不是 FloatField,否则扣费累计到百万级别后会出现小数点漂移的问题,财务对账时非常麻烦。
2.2 广告计划、创意与广告位
广告投放的层级关系是广告主 → 广告计划(Campaign)→ 广告组(AdGroup)→ 广告创意(Creative),这是广告行业的通用层级模型。计划定义投放目标和总预算,广告组定义定向和出价,创意是用户真正看到的内容。
我设计的模型大概是:
class Campaign(models.Model): advertiser = models.ForeignKey(Advertiser, on_delete=models.CASCADE, related_name="campaigns") name = models.CharField("计划名称", max_length=128) total_budget = models.DecimalField("总预算", max_digits=12, decimal_places=2) daily_budget = models.DecimalField("日预算", max_digits=12, decimal_places=2) start_date = models.DateField("开始日期") end_date = models.DateField("结束日期") status = models.CharField("状态", max_length=16, choices=CAMPAIGN_STATUS) delivery_status = models.CharField("投放状态", max_length=16, default="STOP") class AdGroup(models.Model): campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, related_name="ad_groups") name = models.CharField("广告组名称", max_length=128) bid_price = models.DecimalField("出价(CPM)", max_digits=10, decimal_places=2) # 定向条件用一个JSON字段存,灵活但需要约定好格式 targeting = models.JSONField("定向条件", default=dict, blank=True) frequency_cap = models.IntegerField("单用户总展示频控", default=100) class Creative(models.Model): ad_group = models.ForeignKey(AdGroup, on_delete=models.CASCADE, related_name="creatives") title = models.CharField("创意标题", max_length=128) image_url = models.URLField("创意图片") target_url = models.URLField("落地页地址") audit_status = models.CharField("审核状态", max_length=16, default="PENDING")广告位那边我用了单独的站点和广告位模型:
class Site(models.Model): name = models.CharField("站点名称", max_length=128) owner_type = models.CharField("媒体类型", max_length=16, choices=MEDIA_OWNER_TYPES) payout_ratio = models.DecimalField("媒体分成比例", max_digits=5, decimal_places=4) class AdSlot(models.Model): site = models.ForeignKey(Site, on_delete=models.CASCADE, related_name="slots") code = models.CharField("广告位编码", max_length=64, unique=True) slot_type = models.CharField("广告位类型", max_length=32) width = models.IntegerField("宽度") height = models.IntegerField("高度") status = models.CharField("状态", max_length=16, default="ON")为什么要单独拆 Site 和 AdSlot?因为广告位是媒体方的资源,结算时要按广告位维度跟媒体算收入,而广告投放要按广告位尺寸筛素材。把站点和广告位分开后,报表、结算、投放筛选都能省一层 JOIN。
广告审核状态我建议设在 Creative 上而不是 AdGroup 上,因为同一个广告组下面可能有多个创意,一个创意违规只要单独下线它,不至于整个组停投。
2.3 展示、点击与计费记录
投放产生了巨量的展示和点击事件,这是全系统数据量最大的部分。我把展示和点击分开建模,都做成"日志型"表,不做更新、只做插入,方便后面做分区和归档。
class ImpressionLog(models.Model): ad_slot = models.ForeignKey(AdSlot, on_delete=models.CASCADE, db_index=True) creative = models.ForeignKey(Creative, on_delete=models.CASCADE, db_index=True) request_id = models.CharField("请求ID", max_length=64, unique=True) user_agent = models.CharField("UA", max_length=255, blank=True) ip_address = models.GenericIPAddressField("IP") device_type = models.CharField("设备类型", max_length=16, blank=True) media_fee = models.DecimalField("媒体分成费用", max_digits=10, decimal_places=4, default=0) created_at = models.DateTimeField("展示时间", db_index=True) class ClickLog(models.Model): impression = models.OneToOneField(ImpressionLog, on_delete=models.CASCADE, related_name="click") request_id = models.CharField("请求ID", max_length=64, unique=True) created_at = models.DateTimeField("点击时间", db_index=True)计费记录我单独抽了一张表,记录每次扣费行为,包含广告主扣费和媒体结算两条流水(用 type 字段区分),这样财务对账才有依据。核心扣费逻辑放在应用层,避免在数据库层面做太多复杂计算。
这里还要提一下账单表:
class BillingRecord(models.Model): advertiser = models.ForeignKey(Advertiser, on_delete=models.CASCADE, related_name="billing_records") campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) amount = models.DecimalField("变动金额", max_digits=10, decimal_places=2) balance_after = models.DecimalField("变动后余额", max_digits=12, decimal_places=2) biz_type = models.CharField("业务类型", max_length=16) # CONSUME / RECHARGE / REFUND related_id = models.CharField("关联单号", max_length=64, db_index=True) created_at = models.DateTimeField(auto_now_add=True)广告系统的统计数据最好单独做物化表,不要实时去日志表里聚合。我是用定时任务把展示、点击、消耗按天聚合到 AdReportDaily 表,前台报表直接查这张表,秒级返回。
3. 核心链路实现:从广告请求到扣费完成
3.1 广告请求与匹配逻辑
广告投放接口是媒体端通过 SDK 或 API 调用的,核心方法就是传一个广告位编码、用户标识和设备信息,平台返回一条广告创意。这个接口的响应时间直接影响媒体端的页面加载,所以必须控制在 100ms 以内。
我的匹配流程是:
- 根据广告位编码查出广告位,校验状态是否启用。
- 根据广告位尺寸筛出符合条件的创意。
- 根据定向条件过滤候选广告组。
- 按 CPM 出价倒序排序,取第一个作为候选广告。
- 做频次控制校验。
- 做预算校验和实时扣减。
这里有个关键的顺序问题:先把候选集缩小再做频次和预算校验,因为频次和预算都是 Redis 操作,如果候选广告有几千条挨个查 Redis,性能马上就崩了。具体实现:
def get_ad(request_data): slot_code = request_data.get("slot_code") user_id = request_data.get("user_id") device_type = request_data.get("device_type") # 1. 查广告位 slot = get_slot_from_cache(slot_code) if not slot or slot.status != "ON": return None # 2. 从缓存或DB里拿候选创意 candidate_creatives = list( Creative.objects.filter( ad_group__status="RUNNING", ad_group__campaign__status="RUNNING", audit_status="APPROVED", ad_group__campaign__advertiser__status=Advertiser.STATUS_APPROVED, ) .filter(related_slot__code=slot_code) .select_related("ad_group", "ad_group__campaign") ) # 3. 过滤定向条件(mock判断) matched = [] for creative in candidate_creatives: targeting = creative.ad_group.targeting if _match_targeting(targeting, device_type, request_data.get("tags")): matched.append(creative) # 4. 按出价排序 matched.sort(key=lambda c: c.ad_group.bid_price, reverse=True) for creative in matched: # 5. 频次控制 if not _check_frequency(creative.ad_group_id, user_id): continue # 6. 预算控制与扣减 if not _consume_budget(creative.ad_group.campaign_id, 1): continue return build_response(slot, creative) return None这里有几个性能细节:
- 候选创意一定要
select_related一次把广告组和计划查出来,避免在循环里产生 N+1 查询。 - 每次请求都查数据库是不现实的,我实际是把启动状态的创意列表缓存到 Redis,每隔 30 秒同步一次。广告主调整出价后最多 30 秒生效。
- 排序放在应用层内存里做,候选集最多几千条,排序开销可以忽略,没必要放到数据库 ORDER BY。
3.2 频次控制与预算控制怎么做
频次控制防止同一个用户反复看同一条广告,一方面是保护用户体验,另一方面也是控制广告主成本。最常用的实现是 Redis 计数器 + 过期时间:
def _check_frequency(ad_group_id, user_id): if not user_id: return True key = f"freq:{ad_group_id}:{user_id}" count = redis_client.get(key) if count and int(count) >= 100: # 上限可以按广告组配置 return False return True def _record_impression(ad_group_id, user_id): if not user_id: return key = f"freq:{ad_group_id}:{user_id}" pipe = redis_client.pipeline() pipe.incr(key) pipe.expire(key, 86400) # 按天计数,24小时过期 pipe.execute()注意 INCR 之后如果等于 1 再设置过期时间,否则每次刷新过期时间会让计数器永不过期。这是我踩过的坑,一定要分开处理。
预算控制要解决两个并发问题:同一时间大量请求同时进来,预算怎么保证不超扣。我用了 Redis 的 DECR 原子操作,扣减后判断剩余值,小于 0 就返回 False,这样并发场景下不会重复扣超:
def _consume_budget(campaign_id, cost): key = f"budget:{campaign_id}" # 直接用 Lua 脚本保证原子性 lua_script = """ local current = redis.call('GET', KEYS[1]) if not current then return -1 end if tonumber(current) < tonumber(ARGV[1]) then return -1 end redis.call('DECRBY', KEYS[1], ARGV[1]) return tonumber(redis.call('GET', KEYS[1])) """ remaining = redis_client.eval(lua_script, 1, key, cost) return remaining >= 0这个方案的逻辑是:每天启动时把当天的日预算写入 Redis,每小时同步一次数据库余额做持久化。Redis 崩了最多丢一个小时的扣费记录,靠账单表可以重放修复,但投放链路不受影响。
扣费本身我建议做成异步的。展示事件进来后,先推消息队列,Celery 消费队列去更新日消耗统计,不要直接在投放链路里写数据库,否则数据库压力一大接口必超时。
4. 性能优化:从单机到扛住高并发
4.1 Redis 缓存策略
Django 项目做广告平台,最大的性能瓶颈通常在数据库,不在 Python 本身。我的优化策略分三层:
第一层是热点配置缓存。广告位信息、广告主状态、广告单状态这些变更频率很低的配置,全部缓存到 Redis,后台修改后主动删缓存。读取的接口只走 Redis,不走数据库。
第二层是候选创意列表缓存。这是投放链路最核心的缓存。我把所有状态正常的创意按广告位分组缓存成一个列表结构,后台上下线创意时更新时间戳,前台用版本号判断是否需要重建缓存:
def get_candidate_creatives(slot_code): cache_key = f"slot_ads:{slot_code}" cached = redis_client.get(cache_key) if cached: return pickle.loads(cached) # 查询并缓存 creatives = list(Creative.objects.filter(...)) redis_client.set(cache_key, pickle.dumps(creatives), ex=30) return creatives这里用 pickle 做序列化在内部系统没问题,但要注意安全,不要对公网开放 unpickle 接口。
第三层是幂等去重。曝光上报这个接口要保证同一请求 ID 只处理一次,否则重复上报会造成扣费错误。我在 Redis 里用 SETNX 请求 ID 做幂等判断,过期时间设置成 10 分钟覆盖重试窗口。
4.2 Django ORM 与数据库优化
Django 的 ORM 写起来爽,但随手就能写出慢查询。广告平台的数据量主要集中在日志表,日报表、月报表聚合时如果直接查日志表,几千万条数据再好的查询计划也抗不住。
我的做法是:日志表只保留 90 天数据,每天凌晨用分区表归档上一日期数据,同时用定时任务生成聚合报表。聚合报表的结构是广告主 + 广告计划 + 日期 + 展示量 + 点击量 + 消耗金额,前台全部查这张表,后台异步生成,用户在界面上最多看到当天有 10 分钟左右延迟。
数据库索引方面踩过几个坑:
- 外键字段一定要加
db_index=True,否则 JOIN 查询慢得离谱。 created_at要建联合索引,报表查询基本都是时间范围 + 业务维度。- 状态字段(比如 status)区分度太低,单列索引没用,要跟其他字段组合起来才有意义。
还有个大坑是 Django Admin 的下拉框。广告计划关联广告主,如果后台列表直接查广告主下拉选项,几万条数据会卡死页面。我改成了按广告主名称首字母分组 + 远程搜索的模式,或者干脆只读显示。
5. 常见问题与排查技巧
5.1 问题速查表
我把实际运行中碰到的典型问题整理了一张速查表,新成员接手系统时能少踩坑:
| 现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 投放接口偶发超时 | Redis 连接池不足 | 看 Redis client connected_clients | 调整连接池大小,改用连接复用 |
| 广告消耗金额跟财务对账不一致 | 扣费记录没做幂等 | 按 request_id 查重复记录 | 上报接口增加 SETNX 幂等 |
| 同用户 1 秒内刷出 3 条广告 | 频控计数器过期时间设置问题 | 检查 KEY 过期时间 | 计数器首次创建时设置过期 |
| 后台报表打开很慢 | 直接查了日志表 | 看 SQL 慢查询日志 | 改用聚合报表表 |
| 新建广告后迟迟不投放 | 创意缓存未失效 | 检查缓存版本号 | 每次变更手动删缓存 |
| Admin 后台广告主下拉框卡死 | 全量加载option | 抓请求分析SQL | 改为远程搜索控件 |
5.2 实际踩坑记录
这里挑两个对我影响最大的坑详细说下。
第一个是并发扣费超扣问题。第一版我用的是数据库乐观锁:先 SELECT 余额,判断大于出价后再 UPDATE 减去金额。流量一上来立刻超扣,同一个广告计划同一秒有几十个请求同时读到相同余额,全部通过校验然后各自扣费,最后余额变成负数。这就是典型的并发竞态问题。后来改成 Redis Lua 脚本原子扣减才解决。我的建议是:凡是涉及钱的并发操作,不要用"读-判断-写"三段式,要么用原子操作,要么上分布式锁。
第二个是 N+1 查询导致投放接口崩溃。广告匹配时循环候选创意,每个创意查一次广告组、再查一次广告计划,候选集 500 条就有 1000 多次 SQL。上线当天数据库连接直接被打满。后来把所有查询合并成一条select_related语句,加上缓存优化,接口响应时间从平均 800ms 降到了 30ms 左右。查询优化不是玄学,看日志和慢查询一抓一个准。
6. 我的实操体会与避坑经验
整套系统做下来,我最大的体会是广告平台真正的难点不在"写代码"而在"业务闭环"。你可能花了两周把投放接口写好了,但没想清楚怎么跟媒体结算、广告主余额怎么冻结、报表口径怎么统一,后面返工的成本远大于一开始多花的时间。
如果让我重新做一次,我会先把数据流和钱流画清楚。数据流是"展示→点击→转化→报表",钱流是"充值→冻结→扣费→结算→提现",两条链路都通了,代码只是填砖的事。
还有一个经验是:广告平台一定要有模拟器。我在后台做了一个广告投放模拟器,输入广告位、地域、设备后可以直接看到平台返回哪条广告,这样测试和联调效率非常高,不然只能跟联调方一遍遍地传参数验证。
最后一个小技巧:日志表的数据量远比你想的大,记得给日志表按日期做表分区,否则三个月后报表查询会因为数据量增长越来越慢。这套系统上线后最得意的事就是广告主在后台看到的报表和财务对账完全一致,这也是我坚持所有扣费走统一计费服务的原因,哪怕多写几百行代码也值。