←CRYSU

Case Study · 2026.08 – 2026.10

百年庆典
活动平台

民生医药成立 100 周年庆典的线上系统。员工在企业微信里收邀请函、登记服装尺码、查座位、现场打卡;外部嘉宾扫码即开,不用登录就能接受邀请、查座位;会务组在后台管报名、排座、发通知、看现场。3,409 人登记参会,经历了三次全员规模的访问高峰,庆典当天零宕机。

角色
独立开发:后端、PC 管理端、员工端与嘉宾端 H5
周期
约两个月,140 次提交
规模
260 个接口 · 59 张表
  • FastAPI
  • SQLAlchemy 2.0
  • PostgreSQL
  • Redis
  • MinIO
  • Vue 3
  • SVG 绘图
  • Naive UI
  • 企业微信 OAuth
  • Nginx 蓝绿发布
PC 管理后台的报名管理页,姓名与手机号已模糊处理
员工端首页:邀请函、议程、服装尺码、现场打卡、座位信息、交通指引
3,409人登记参会
员工 2,353 · 嘉宾 1,056
2,546人完成服装尺码登记
首日就有 2,168 人
25.2万庆典当天的请求
8.5 万次接口调用
0庆典当天宕机与重启
服务端错误率 0.001%
2,000QPS单机压测只读上限
写入 240 QPS

Staff · 企业微信 H5

员工端:从收到邀请到入场

员工从企业微信工作台进入,身份走 OAuth 静默授权,不用再注册。每个页面都按真实流程设计:收邀请、确认尺码、看议程、查座位、到场打卡。

左右滑动查看全部 · 点图看大图

Guest · 微信扫码即开

嘉宾端:
同一套页面,换一种身份

外部嘉宾不在企业微信里,扫邀请物料上的二维码或打开专属邀请链接就能进,不用注册登录。页面和员工端基本是同一套,身份由所在端决定:嘉宾端没有服装尺码,多了「是否参加晚宴」,着装要求换成嘉宾版。

嘉宾端首页
嘉宾端首页没报名也能先看邀请函,页面底部再引导「接受邀请」;报名状态以服务端复核为准,后台取消后入口会跟着变回去。
接受邀请表单,姓名与手机号已模糊
接受邀请只填姓名与手机号。同一手机号重复提交由数据库唯一索引兜底,直接回到已有的凭证。
帮他人申领页面,本人信息已模糊
帮他人申领对接人替自己负责的嘉宾申领,记录挂在对接人名下,点开就能看到对方的邀请函和入场二维码。

1,056 位嘉宾里,687 位通过嘉宾端报名,其中 227 位由 87 位对接人代为申领;其余由会务组按名单导入或在后台补录。

On Site · 现场工作台

会场里用的三件工具

庆典当天会务人员不坐在电脑前,常用功能都做成了手机能操作的版本;嘉宾扫会场物料上的二维码就能查自己的座位,不用登录。

扫码查座位页面,输入内容已模糊
扫码查座位公开免登录,姓名或手机号填一项即可。压测 400 rps 时 p95 只有 20 ms,真正的上限在静态资源的出口带宽。
移动管理台
移动管理台人员查询、扫码入场、名单比对、现场工作人员管理,按角色决定能看到哪几块。
座位总览:大会座位图与晚宴桌位图
座位总览大会 3,245 个座位按公司着色,晚宴 102 桌。一次给齐 3,279 人,计算要 1.7 s,加 Redis 缓存与写后失效后 120 rps 下 p95 220 ms。

Drawn in Code · SVG

这些图不是图片,
是代码画的

大会座位图、晚宴桌位图、地铁出站图、落客点示意图,都是 Vue 组件按配置画出来的 SVG。员工端、嘉宾端、座位总览和离线导出用的是同一套组件,数据一改,所有地方的图跟着变。

代码绘制的庆典大会座位示意图:舞台、一楼内场 A 到 G 区、一楼看台与二楼看台
庆典大会座位示意图舞台在北,一楼内场 A–G、一楼看台、二楼看台一圈,共 3,644 个座位。每区标出排数、座数与排距,所在的区整块高亮,红圈是「我的座位」。
座位图放大后的排号与座号
放大到排号、座号每排外侧标「几排 / 本排几座」,椅子上写座号,和现场贴的号一致
代码绘制的地铁站与会展中心指引图
地铁出站指引图照官方指引图复刻:两个站的出口、无障碍电梯、步行距离
代码绘制的自驾指引图,2 号口标红,东登录厅标黄
落客点示意图同一张底图按到达方式高亮:打车标东登录厅,自驾再标出 2 号地库口

13 MB 原图 → 配置

场馆给的看台牌号图 8191×5791、13 MB,直接发给手机又慢又费流量。改成一份配置描述每段座位的起点、方向和间距,座位位置由代码算出来。

放大不糊

拖动缩放改的是 SVG 的 viewBox,不用 CSS transform:transform 放大的是已经栅格化的位图,viewBox 变了会按新尺度重新矢量渲染。手机横过来,整层转 90°。

几千把椅子也不卡

每个区的座位合成一条 path,拖动缩放时不用逐个比对几千个节点;缩略图只画各区色块,座位小到不到半个像素时改画定位圈。

一套组件,多处复用

员工、嘉宾看「我的座位」;座位总览在同一张图上按公司着色、写姓名、搜人跳转。晚宴图按每桌实际座数画椅子:A 桌 30 座、主桌 16 座、普通桌 13 座。

座位写法都认

「A区3排5号」「208B区2排12座」都能解析,场馆区号 107–112 与字母区 H–L2 互认,导入的座位文本不用统一格式。

断网也能查

座位总览可以一键导出离线网页:两张图离屏渲染成带姓名的 SVG,搜索、拖动缩放、点椅子看人都在本地运行,现场网络不稳时用浏览器直接打开。

Console · PC 管理后台

会务组的后台

报名审核与补录、座位导入、入场记录、尺码统计、短信与企业微信推送、定时任务,都在这里。列表里的手机号默认脱敏,需要时再切换显示。

报名管理
报名管理审核、补录、导出座位表、员工名单核对、胸卡批量打印
入场与打卡记录
入场 / 打卡记录扫码入场与一键打卡统一记录,可按入场点和方式筛选
服装尺码统计
服装尺码统计按公司、部门汇总给采购用;可代填,可查谁还没提交
活动配置
活动配置活动信息、主视觉、报名与打卡开关、场次,都可在线改

Architecture · 架构

一台服务器上的完整链路

后端分层为 entity → repository → service → router,依赖注入统一管理。Nginx 通过 upstream 在两套后端实例之间切换,新版本健康检查通过后再切流量,发版不停服。

员工 H5 / PC 后台

Vue 3 + Vite,企业微信与浏览器内打开;构建产物带哈希、长缓存

Nginx

TLS、HTTP/2、gzip;静态资源与对象存储直出,API 反代到活跃的一侧

upstream 5758 ⇄ 5759

FastAPI × 8 worker

分层架构 + dependency-injector;请求级 UnitOfWork 管事务

260 个接口

PostgreSQL

序列发号、部分唯一索引、pg_trgm 模糊搜索

Redis

登录令牌、座位总览缓存(写后按版本失效)

MinIO

邀请函海报、图片、视频

企业微信开放接口

OAuth 网页授权、通讯录同步、JS-SDK 签名、在线文档回写

APScheduler 定时任务

定时短信、企业微信推送每分钟扫描,名单比对每 20 分钟回写在线文档;执行记录可在后台查看

运维与观测

systemd 模板单元做蓝绿实例;连接池泄漏追踪开关,借出超 60 s 的连接按调用栈打日志

Production · 真实流量

三次全员访问高峰

来得最急的一次不在庆典当天,而在服装尺码登记通知发出的那一分钟:接口调用从每分钟 70 次左右跳到 2,079 次,2,000 多名员工几乎同时第一次登录。

每分钟接口调用(5 分钟平均),三天叠在同一时间轴上

09-08 尺码登记首日10-16 庆典前两天10-18 庆典当天

09-08 尺码登记首日14:00 通知发出

登录员工
2,303 人
首次进入系统
2,292 人
最忙一分钟
2,079 次
最忙一秒
62 次
当天提交尺码
2,168 人

10-16 庆典前两天上班时段的宽峰

登录员工
1,729 人
访问请求
20.5 万
7–10 点占比
44%
在线发版
3 次
网关 502 / 504
0

10-18 庆典当天开场前一小时最忙

访问请求
25.2 万
现场打卡
822 人
网关最忙一秒
133 次
宕机与重启
0
服务端错误
1 次

数据来自 Nginx 与后端访问日志、数据库业务表的逐秒还原;压测流量已按时间窗剔除。实际峰值离压测上限很远:最急的那一秒也只用到只读容量的 3%。

Engineering Notes · 工程笔记

压测、定位、修掉,再压一遍

第一轮生产压测时系统 43 rps 就封顶,过载后不是降级而是雪崩。之后每轮只改一个瓶颈,用同口径的压测验收,前后十余轮。

报名写入去掉活动行锁

原来提交一进来就 SELECT … FOR UPDATE 锁住活动行,全场排一条队。改成凭证号用数据库序列发号、同手机号重复交给部分唯一索引兜底,只有配了名额才加锁。

40 → 160rps 写吞吐 · 13,587 次并发写入撞号 0、重复 0

修掉连接泄漏与单例竞态

scoped_session 从未 remove(),连接池被慢慢吞掉;providers.Singleton 不加锁,进程启动时多线程并发会造出两个 engine。改为请求结束释放会话、ThreadSafeSingleton。

567,000次请求 0 失败 · 15 分钟稳态,数据库连接数恒定 87

鉴权依赖链不再每请求重建

依赖注入的 Factory 让每个需要登录的请求都重建整条 service 链,还要读一次磁盘上的配置,单次 12.8 ms。改为单例后,扫码核销与姓名搜索的吞吐都翻了一倍。

58 → 120rps 扫码核销 · 搜索补录 130 → 280

座位总览加缓存

一个接口给齐 3,279 人的座位,单次计算 1.6–1.8 s,5 rps 就排队超时。加 Redis 缓存,13 个写接口提交后按版本失效,超管可手动刷新。

120rps 全部成功 · p95 220 ms(加缓存前 5 rps 成功率 16%)

外部调用移出数据库事务

登录回调先查库再同步调企业微信,企微一慢,事务就空转超过 idle_in_transaction_session_timeout 被 PostgreSQL 断开,线程上的会话从此报错且不会自愈。改为先调企微再开事务,Redis 加超时,常驻调度线程兜底释放会话。

当天修复并完成生产压测验收,之后两次高峰没有再出现

蓝绿发布,发版不停服

两套后端实例轮流上线:新版本先在空闲端口启动,健康检查通过后改 upstream 切流量,旧实例处理完手上的请求再退出;回滚只要把 upstream 指回去。

3次庆典前两天在线发版 · 网关 502 / 504 为 0

Stack · 技术栈

用到的东西

BACKEND

Python · FastAPI · SQLAlchemy 2.0 · dependency-injector · APScheduler · Pydantic

DATA

PostgreSQL(序列、部分唯一索引、pg_trgm)· Redis · MinIO

FRONTEND

Vue 3 · Vite · Pinia · Naive UI · Tailwind CSS · SVG 绘图 · html2canvas

OPS

Nginx · systemd 蓝绿实例 · vegeta 压测 · sysstat · 企业微信开放平台