从零实现一个高可用的化妆预约毕设系统:技术选型与核心逻辑解析
摘要:许多学生在开发“化妆预约毕设”类项目时,常陷入数据库设计混乱、并发预约冲突、服务耦合度高等问题。本文从技术科普角度出发,详解如何基于 RESTful API + MySQL + Redis 构建一个具备幂等预约、防超订、事务回滚能力的轻量级预约系统。读者将掌握关键数据模型设计、乐观锁控制并发、以及前后端解耦的最佳实践,显著提升系统健壮性与可维护性。
1. 背景痛点:学生项目里那些“预约黑洞”
先讲个真事:去年帮学弟调代码,他的化妆预约模块上线 10 分钟,同一位化妆师被 5 位同学约到 14:00 时段,数据库里 5 条记录状态全是“已支付”。老板当场社死——这就是典型的“并发超订”。再随手列几个常见坑:
- 重复提交:前端按钮没防抖,用户狂点“立即预约”,后端不做幂等,订单雪崩。
- 库存负数:UPDATE 语句没加原子判断,
available=available-1在并发下直接变负。 - 事务半截子:扣了库存、写了订单,结果支付回调失败,库存却回不来了。
- 身份不验:/book 接口谁都能调,隔壁宿舍用 Postman 就能帮你把所有时段约满。
这些坑一句话总结:“业务代码写得快,并发一来全翻车”。下面从 0 梳理一套可落地的技术方案,让毕设既能跑通演示,也能扛住 100 人同时秒杀限量化妆师。
2. 技术选型对比:别让“轻量级”变成“玩具级”
2.1 SQLite vs MySQL
| 维度 | SQLite | MySQL |
|---|---|---|
| 并发写 | 库级锁,QPS≈1 | 行级锁,QPS 几千 |
| 容量 | 单文件 GB 级 | 单机 TB 级 |
| 高可用 | 0,文件即库 | 主从、半同步、MGR |
| 本地 CI 友好 | 开箱即用 | Docker 一键拉 |
结论:毕设答辩完就扔仓库,可选 SQLite;但只要涉及“并发”“回滚”“演示高可用”,直接上 MySQL 8.0,省得答辩时被老师一句“如果 500 人同时预约怎么办”问倒。
2.2 本地缓存 vs Redis
- 本地变量(如 Python dict):
- 单机内存,重启即没
- 多进程数据孤岛,gunicorn 3 worker 就计数失真
- Redis:
- 单线程原子指令(INCR/DECR)
- 可持久化、可集群
- 天生适合“分布式计数器”做库存扣减
结论:只要系统可能水平扩展,哪怕两台 Docker 容器,也请把 Redis 当“进程外内存”用,别省这一步。
3. 核心实现:Flask + MySQL + Redis 最小闭环
下面用 Python Flask 演示“化妆师时段预约”关键接口,其他语言思路完全一致。
3.1 数据模型(精简但够用)
-- 化妆师表 CREATE TABLE artist ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32), avatar_url VARCHAR(255) ); -- 时段模板表(每个化妆师每天可设置多时段) CREATE TABLE slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, artist_id BIGINT, start_time DATETIME, end_time DATETIME, capacity INT DEFAULT 1, -- 该时段可接受几人 version INT DEFAULT 0, -- 乐观锁字段 INDEX(artist_id, start_time) ) ENGINE=InnoDB; -- 订单表 CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, slot_id BIGINT, status ENUM('RESERVED','PAID','CANCEL'), ctime DATETIME DEFAULT CURRENT_TIMESTAMP );3.2 防超订思路
- 利用 slot.version 做乐观锁:UPDATE 时只有 version=预期值才成功
- 利用 Redis 做“剩余库存”计数器:key=
slot:{id}:stock,初始=capacity - 两步走:
- Redis DECR 返回 >=0 才继续落库,否则直接回滚
- 落库成功再异步刷新 MySQL 的 capacity 字段(兜底)
3.3 预约接口源码(含注释,Clean Code)
# app.py from flask import Flask, request, jsonify import pymysql, redis, os, logging from datetime import datetime DB = pymysql.connect(host='127.0.0.1', user='root', password='123456', database='makeup_book', autocommit=False) R = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) app = Flask(__name__) logging.basicConfig(level=logging.INFO) # 工具:简单事务上下文 class Tx: def __enter__(self): return DB.cursor() def __exit__(self, exc, *_): if exc: DB.rollback() else: DB.commit() # 1. 预约接口 @app.post('/api/slot/<int:slot_id>/book') def book_slot(slot_id): user_id = request.json.get('user_id') if not user_id: return jsonify(msg='missing user_id'), 400 # ① Redis 扣库存(原子) stock = R.decr(f'slot:{slot_id}:stock') if stock < 0: R.incr(f'slot:{slot_id}:stock') # 恢复 return jsonify(msg='sold out'), 409 # ② MySQL 乐观锁写订单 with Tx() as cur: # 查当前 version cur.execute('SELECT version FROM slot WHERE id=%s', (slot_id,)) row = cur.fetchone() if not row: R.incr(f'slot:{slot_id}:stock') # 回滚库存 return jsonify(msg='slot not found'), 404 version = row[0] # 插订单 cur.execute('INSERT INTO booking(user_id, slot_id, status) VALUES (%s,%s,%s)', (user_id, slot_id, 'RESERVED')) # 版本号+1,只有 version 没变才成功 cur.execute('UPDATE slot SET version=version+1 WHERE id=%s AND version=%s', (slot_id, version)) if cur.rowcount == 0: # 并发冲突 R.incr(f'slot:{slot_id}:stock') return jsonify(msg='concurrent conflict'), 409 return jsonify(msg='ok', order_id=cur.lastrowid), 201 # 2. 支付回调(幂等) @app.post('/api/order/<int:order_id>/paid') def order_paid(order_id): with Tx() as cur: cur.execute('SELECT status FROM booking WHERE id=%s', (order_id,)) row = cur.fetchone() if not row: return jsonify(msg='no such order'), 404 if row[0] == 'PAID': # 已处理过 return jsonify(msg='duplicate notify'), 200 cur.execute('UPDATE booking SET status=%s WHERE id=%s', ('PAID', order_id)) return jsonify(msg='thx'), 200 if __name__ == '__main__': app.run(debug=True)代码要点逐条说:
- 事务边界清晰:Redis 与 MySQL 的“回滚”成对出现,任何一步失败都回补库存
- 乐观锁:version 字段保证同一行 slot 只能有一个事务修改成功,其他并发请求直接 409
- 支付接口幂等:重复回调只返回 200,不会重复发货
- 日志:每个分支都打印关键参数,方便复现
4. 性能 & 安全:学生项目最容易忽视的两张“成绩单”
4.1 性能瓶颈
- 冷启动延迟:Flask debug 模式 + SQLAlchemy 反射,首次请求 2 s;解决:Gunicorn + gevent,预建连接池
- 连接数打爆:MySQL 默认 151 连接,压测 200 并发直接拒连;解决:使用 DBUtils 或 SQLAlchemy 连接池,max_overflow 设 20
- Redis 大 key:如果按“秒”存库存,key 数量爆炸;解决:按 slot_id 粒度即可,过期时间跟随时段结束自动清理
4.2 安全风险
- 未校验用户身份:/book 接口随便传 user_id 就能下单;解决:JWT + 统一网关鉴权,把 user_id 放 token
- 水平越权:用户 A 传 order_id=123 去查订单,其实是 B 的;解决:SQL 里再 AND user_id=%s
- 回调接口裸露:/paid 谁都能 POST;解决:内网白名单 + 支付平台签名验证
5. 生产环境避坑指南
- 事务边界:把“扣库存”与“写订单”包在一个本地事务里不现实,Redis 无法 JOIN;采用“先扣缓存,再写 DB,失败回补”的 SAGA 模式
- 幂等性:所有外部回调、用户重试都必须带唯一业务单号(user_id+slot_id+date),用 UK 约束或 SETNX 防重
- 测试覆盖:
- 单元:mock Redis,并发 100 goroutine/协程 调 /book,断言库存最终 = 0
- 集成:JMeter 200 线程,TPS 持续 5 min,监控无 500、无超卖
- 混沌:随机 kill容器、断网 3 s,验证库存回补是否最终一致
- 监控:Prometheus + Grafana 模板,看“Redis 剩余库存 <0 次数”“MySQL 乐观锁冲突率”,超过阈值就告警
- 灰度:先让 10% 真实流量走新逻辑,对比旧系统订单差异,0 差异再全量
6. 小结与思考
实现一套“能跑”的化妆预约只需 200 行代码,但“能扛并发、可回滚、易维护”却需要:
- 正确的技术选型(MySQL+Redis)
- 细粒度并发控制(乐观锁+分布式计数)
- 清晰的代码与测试
下一步,不妨思考:
如果系统要支持“多技师并行预约”,即用户一次性选 3 位化妆师同时段对比,再一键下单,如何保证跨技师库存原子扣减?事务边界又该如何划分?
把答案落地到你的毕设里,重构一遍代码,相信答辩时老师会问:“同学,考虑读博吗?”
动手吧,代码仓库已经 push,剩下的坑等你来踩。