
3天搞定淘金币抽奖技巧,手写实现后端逻辑避坑指南
是不是刚学完 Python 或 Java 语法,对着屏幕发呆,脑子里全是 if-else 和循环,但就是不知道怎么把这些碎片拼成一个能跑的项目?这种“会写代码但不会搭架构”的无力感,比写错一个括号更让人头大。今天咱们不整虚的,直接以电商场景里高频出现的“淘金币抽奖”为案例,带你从零开始,手写实现一个完整的抽奖后端服务。
别被“淘金币”这三个字劝退,这其实是一个经典的概率控制 + 状态管理问题。很多大厂面试里的并发扣库存、防刷、奖池配置,本质上和这个玩法如出一辙。与其死记硬背那些抽象的分布式理论,不如先在一个单体应用里,把逻辑跑通,把坑踩明白。
项目目标与核心逻辑拆解
我们要做的不是一个花里胡哨的前端页面,而是一个高可用、可配置、防作弊的抽奖核心引擎。
核心需求拆解:奖池配置化:奖品(金币、优惠券、谢谢惠顾)的数量和概率不能写死在代码里,必须支持动态调整。
原子性扣减:高并发下,不能出现超卖(比如奖品只剩1个,两个人同时抽中,导致库存变成-1)。
防刷机制:限制单用户抽奖次数,防止脚本恶意刷奖。
日志审计:每次抽奖都要留痕,方便对账和排查问题。很多人一上来就想上 Redis 分布式锁,其实对于初学者,先理解本地内存 + 数据库兜底的逻辑更重要。我们这次为了教学清晰,采用 Python + Flask + SQLite(模拟 MySQL)的组合,核心逻辑完全可迁移到 Java/Go。
目录结构与环境准备
工欲善其事,必先利其器。保持目录整洁是工程师的基本素养。
lottery_service/
├── app.py # 主入口,Flask 应用初始化
├── config.py # 配置管理,奖池规则定义
├── models.py # 数据库模型,用户、奖品、抽奖记录
├── services.py # 核心业务逻辑,抽奖算法实现
├── utils.py # 工具类,日志、异常处理
├── requirements.txt# 依赖库
└── data/ # 存放 SQLite 数据库文件在 requirements.txt 中,我们只引入最基础的库。这里要特别强调,不要乱装包。根据 PyPI 官方包索引,我们选择 Flask 作为 Web 框架,SQLAlchemy 作为 ORM。这两个库文档完善,社区活跃,是 Python Web 开发的基石。
pip install flask sqlalchemy核心代码实现:手写抽奖引擎
这是整篇文章的重头戏。我们采用问题-原因-对策的结构,逐行拆解核心代码。
1. 定义奖池模型与数据库结构
很多新手喜欢把奖池配置写在 JSON 文件里读取,但这样无法做事务控制。正确的做法是将奖池配置持久化到数据库,或者在内存中维护一份与数据库同步的快照。
# models.py
from flask_sqlalchemy import SQLAlchemy
from datetime import datetimedb = SQLAlchemy()class Prize(db.Model):__tablename__ = 'prizes'id = db.Column(db.Integer, primary_key=True)name = db.Column(db.String(50), nullable=False)stock = db.Column(db.Integer, nullable=False) # 剩余库存probability = db.Column(db.Float, nullable=False) # 概率权重,非百分比is_active = db.Column(db.Boolean, default=True)class User(db.Model):__tablename__ = 'users'id = db.Column(db.Integer, primary_key=True)username = db.Column(db.String(50), unique=True, nullable=False)gold_coins = db.Column(db.Integer, default=0)class LotteryRecord(db.Model):__tablename__ = 'lottery_records'id = db.Column(db.Integer, primary_key=True)user_id = db.Column(db.Integer, db.ForeignKey('users.id'))prize_id = db.Column(db.Integer, db.ForeignKey('prizes.id'))created_at = db.Column(db.DateTime, default=datetime.now)关键点: probability 字段存储的是权重,而不是直接的百分比。比如 A 奖品权重 10,B 奖品权重 90,总权重 100。这样调整概率时,不需要保证所有概率之和为 1,灵活性更高。
2. 核心抽奖逻辑:加权随机与库存控制
这是最容易出 Bug 的地方。直接 random.choice 是不行的,因为它是等概率的。我们需要加权随机。
# services.py
import random
import threading
from models import db, Prize, User, LotteryRecord
from datetime import datetime, timedelta# 线程锁,用于保护内存中的奖池快照
_pool_lock = threading.Lock()
# 内存缓存,避免每次抽奖都查库
_prize_cache = []def refresh_prize_cache():从数据库加载奖池到内存global _prize_cachewith _pool_lock:prizes = Prize.query.filter_by(is_active=True).all()_prize_cache = [{'id': p.id,'name': p.name,'stock': p.stock,'weight': p.probability} for p in prizes if p.stock 0]def execute_lottery(user_id):执行抽奖核心逻辑1. 检查用户资格2. 加权随机选择奖品3. 原子性扣减库存4. 记录日志# 1. 检查用户是否存在及是否还有抽奖机会user = User.query.get(user_id)if not user:raise ValueError(用户不存在)# 简单的频率限制:1分钟内只能抽1次last_record = LotteryRecord.query.filter(LotteryRecord.user_id == user_id,LotteryRecord.created_at = datetime.now() - timedelta(minutes=1)).first()if last_record:raise Exception(操作频繁,请稍后再试)# 2. 从内存缓存中加权随机选择with _pool_lock:if not _prize_cache:refresh_prize_cache()if not _prize_cache:return {'prize': '谢谢惠顾', 'id': None}total_weight = sum(item['weight'] for item in _prize_cache)random_num = random.uniform(0, total_weight)cumulative_weight = 0selected_prize = Nonefor item in _prize_cache:cumulative_weight += item['weight']if random_num = cumulative_weight:selected_prize = itembreakif not selected_prize:selected_prize = _prize_cache[-1] # 兜底策略# 3. 原子性扣减库存(关键步骤)# 使用数据库乐观锁或行锁,这里为了演示清晰,使用 SQL 更新updated = db.session.query(Prize).filter(Prize.id == selected_prize['id'],Prize.stock 0).update({Prize.stock: Prize.stock - 1}, synchronize_session=False)if updated == 0:# 库存不足,回退到“谢谢惠顾”或重新选择# 实际生产中可能需要重新触发随机,这里简化处理db.session.rollback()return {'prize': '谢谢惠顾', 'id': None}# 4. 记录抽奖日志record = LotteryRecord(user_id=user_id, prize_id=selected_prize['id'])db.session.add(record)db.session.commit()return {'prize': selected_prize['name'], 'id': selected_prize['id']}逐行解析:threading.Lock:虽然我们在 Flask 多线程环境下,但内存缓存的读取和更新必须加锁,防止脏读。
加权随机算法:累加权重直到超过随机数,这是标准的轮盘赌算法。时间复杂度 O(N),N 为奖品数量,通常奖品数很少,性能完全够用。
Prize.stock 0 条件更新:这是防止超卖的核心。UPDATE ... WHERE stock 0 是数据库层面的原子操作。如果返回受影响行数为 0,说明库存已经没了,直接回滚或返回失败,千万不要先查库存再更新,那是并发噩梦。3. 接口层封装
# app.py
from flask import Flask, request, jsonify
from services import execute_lottery, refresh_prize_cache
from models import db, Userapp = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data/app.db'
db.init_app(app)@app.before_first_request
def init_db():首次运行初始化数据库db.create_all()refresh_prize_cache()@app.route('/lottery', methods=['POST'])
def do_lottery():user_id = request.json.get('user_id')try:result = execute_lottery(user_id)return jsonify({'code': 0, 'msg': 'success', 'data': result})except Exception as e:return jsonify({'code': 1, 'msg': str(e), 'data': None})运行与测试:如何验证正确性
代码写完了,不能光看,得跑。初始化数据:
在 init_db 中插入测试数据:
# 在 init_db 函数中添加
if not Prize.query.first():db.session.add(Prize(name=淘金币x100, stock=10, probability=10))db.session.add(Prize(name=淘金币x10, stock=100, probability=50))db.session.add(Prize(name=谢谢惠顾, stock=9999, probability=40))db.session.commit()refresh_prize_cache()压力测试脚本:
不要手动点接口,写个脚本模拟 100 个用户并发抽奖。
# test.py
import requests
import threadingdef worker(user_id):try:r = requests.post('http://127.0.0.1:5000/lottery', json={'user_id': user_id})print(fUser {user_id}: {r.json()})except Exception as e:print(fUser {user_id} Error: {e})threads = []
for i in range(1, 101):t = threading.Thread(target=worker, args=(i,))threads.append(t)t.start()for t in threads:t.join()验证库存一致性:
运行脚本后,检查数据库中 prizes 表的 stock 字段。如果初始库存是 10+100,总消耗应该是 110 个奖品(加上谢谢惠顾)。
重点检查:是否出现 stock 0 的情况?如果出现了,说明你的并发控制失效了。优化扩展:从玩具到生产级
上面的代码能跑,但离生产环境还有距离。以下是几个进阶方向:Redis 队列削峰:
如果 QPS 达到万级,直接打数据库会崩。引入 Redis,将抽奖请求放入 List,Worker 消费。这涉及到异步任务队列的概念,推荐查看 Celery 官方文档。
概率动态调整:
现在的概率是静态的。可以设计一个后台接口,修改 probability 后,触发 refresh_prize_cache()。注意,修改概率时,要处理正在进行的抽奖,通常采用双缓冲或版本号机制。
防刷升级:
目前的“1分钟1次”太弱。生产环境应结合IP 频率限制、设备指纹、行为分析(如鼠标轨迹、点击速度)。
可观测性:
接入 Prometheus + Grafana,监控抽奖成功率、平均响应时间、各奖品中奖率偏差。如果实际中奖率与理论概率偏差超过 5%,说明算法或代码有 Bug。小结
通过这个“淘金币抽奖”项目,你实际上掌握了一个完整的后端业务闭环:数据建模:如何设计表结构支持业务。
核心算法:加权随机、乐观锁。
并发安全:线程锁、数据库原子操作。
工程化思维:目录结构、异常处理、日志审计。很多人觉得学编程就是学语法,其实搭项目才是将知识转化为能力的唯一路径。不要等到“准备好了”再动手,现在的代码虽然简陋,但它是你理解复杂系统的基石。
你公司项目里是怎么处理高并发抽奖或秒杀场景的?是用 Redis 预扣减,还是直接数据库乐观锁?欢迎在评论区分享你的实战经验,咱们一起避坑。