资讯动态

预约小程序高并发号源扣减与幂等防重实战

发布时间:2026/10/25 10:49:30 来源:尧图企业网站定制
做预约类小程序这些年我经手过好几个方向的需求新冠疫苗预约小程序是其中比较特殊的一类它的业务模型看着简单——选个接种点、挑个时段、填个信息、提交——但真做起来你会发现坑几乎全埋在看不见的地方。它上线就要扛住集中时段的访问洪峰号源一旦超发线下接种点当天就会乱成一锅粥它对实名校验、隐私保护的要求比普通业务高一个量级它还牵扯接种点、区域管理方、居民三类角色任何一方的操作习惯没被照顾到最后都会变成客服电话打到你手机上。这篇东西写给两类人看。一类是接到类似预约项目、正在做技术选型和排期的开发者我尽量把选型背后的判断讲透让你少走我走过的弯路另一类是对接这类系统的产品或运营同学你能从中看到哪些需求在技术上代价很高、哪些看起来复杂其实很好做方便你在评审会上判断优先级。全文围绕号源模型、并发扣减、幂等防重、容量测算、隐私处理、上线运维这几块展开代码和表格都给到可以直接抄的程度但我不保证照搬就能跑业务参数永远比代码重要。1. 需求拆解预约小程序真正要解决的是什么1.1 三类角色与三条主流程刚拿到这个需求的时候最容易犯的错是把它当成一个表单提交系统。实际上它至少有三类角色在跑三条不同的流程任何一条没闭环系统就是残的。居民端是最直观的实名建档姓名、证件号、手机号→ 选择接种点 → 选择日期和时段 → 提交预约 → 收到预约凭证 → 到场出示二维码核销。这条流程里最容易被低估的是建档和预约的先后关系。早期我图省事把建档塞在预约提交的那一步里结果用户填完一大堆信息点提交号源被别人抢走了前功尽弃投诉量直接翻倍。后来改成先建档、建档信息本地缓存预约时只做一次身份校验体验立刻不一样了。接种点端的流程是查看当日时段容量设置 → 领取当日预约名单 → 扫码或手动核销 → 登记实际接种完成 → 处理爽约。这里有个细节接种点的工作人员往往不是技术熟练用户扫码枪不一定有网络也不一定稳。所以核销页面必须做离线兜底核销记录先落本地网络恢复后再批量同步同时用预约单号做幂等避免重扫导致重复记录。管理端负责的是接种点信息维护、号源批次发布、容量调整、数据统计导出、异常预约处理。这条流程的复杂度不在功能在权限。区域管理员和接种点管理员能看到的数据范围必须严格隔离一个接种点的管理员不应该看到别的接种点的预约名单这是我在第二个项目里被明确要求整改过的点。三条流程共用一套号源数据但读写特征完全不同居民端是高频写、强并发接种点端是低频写、弱并发但要求强一致管理端是低频批量写。把这三者的读写特征分清楚后面的架构设计基本就顺了。1.2 为什么电商下单那套模型套不过来很多人第一反应是这不就是个秒杀吗然后直接把商品库存那套方案搬过来。我试过会出事。第一个差异是超卖的成本归属不同。电商超卖大不了退款加补偿券损失是平台自己吃下来的、可量化的。预约号源超卖损失是线下承担的一个接种点上午本来能接待一百二十人你卖出去一百五十个号多出来的三十人只能在外面排队等着现场秩序、工作人员情绪、居民体验全都崩掉。这种成本没法用钱衡量也没法事后补偿所以号源扣减必须是宁可少卖不可超卖的强约束。第二个差异是不可转移性。商品可以跨仓调拨号源不行。A接种点上午九点的号不能因为B接种点空闲就转移过去居民跑不了那么远。这意味着号源必须是细粒度独立管理的不存在总库存池这种抽象。第三个差异是时效与爽约。预约有明确的时间点过期作废而且爽约率是真实存在的。电商订单不履约可以一直挂着预约单过期了就必须把号还回池子里否则后面的居民永远约不上。这就要求我们设计回收机制而回收机制又会引入新的并发问题——回收的号源和放号同时发生时怎么保证不超发这是个专门要处理的问题。把这三条差异想明白你就知道这不是秒杀这是个带时间维度的、强一致的、细粒度的资源分配问题。2. 整体架构与技术选型把复杂度放在该放的地方2.1 技术栈组合与我的取舍逻辑我最终落地的组合大概是这样小程序端用原生框架或者跨端框架都行这类业务页面不多首页、建档、选点、选时段、我的预约、核销跨端框架的性能损耗完全可接受选哪个主要看你团队熟悉什么。后端我用的是单体应用加模块化分包Java 或 Node 都可以数据库 MySQL缓存层 Redis异步任务走一个轻量消息队列短信和推送接第三方服务。为什么不一开始就上微服务因为这个项目的实际并发峰值虽然高但持续时间很短——通常是号源集中放出的那几分钟。为了几分钟的峰值去承担微服务的运维成本、链路追踪成本、分布式事务成本完全是亏本买卖。我做过一个粗略估算单体应用在两台四核八G的机器上配合 Redis 预扣足够撑住每秒两三千次的预约请求而这个量级对一个区域级的预约系统来说已经很夸张了。真正撑不住的从来不是应用层而是数据库的写入和缓存的热点 key。不过模块化边界还是要提前划清楚的用户与实名模块、号源与预约模块、核销模块、通知模块、管理后台模块。每个模块只暴露接口不跨模块直接访问表。这样等业务真的涨到需要拆的时候拆起来才不会把整个系统推倒重来。这个原则我在三个项目里都坚持了救过命。2.2 号源存在哪一层两级结构的设计号源的位置是整个架构的核心决策。我采用的是 Redis 预扣 MySQL 落库的两级结构理由如下。MySQL 单独扛不住。每一次预约都意味着一次行锁竞争当几百个请求同时打同一行号源记录时数据库的锁等待会迅速堆积响应时间从几十毫秒涨到几秒然后连接池被打满整个服务雪崩。这不是理论推演是我在第一次压测里亲眼看到的八百并发下去接口平均响应时间飙到四点七秒错误率三成。Redis 单独扛不住持久化。号源是本业务的命根子必须落库可查、可审计、可对账。只放 Redis一旦缓存节点故障或者数据被误刷你连昨天卖了多少号都说不清楚。两级结构的分工是这样的Redis 里存的是某个号源池剩余可约数量用原子操作做预扣预扣成功之后异步或者同步地把预约单写进 MySQL并且用数据库的唯一索引做最终兜底。Redis 挂了可以用 MySQL 的计数重建MySQL 写入慢了因为有 Redis 挡在前面用户侧不会立刻感受到。这里有个关键约束我要强调Redis 的预扣结果和 MySQL 的落库结果必须能做对账。我的做法是给每次预扣打一个唯一流水号落库时带上这个流水号唯一索引就建在流水号上。这样即使出现Redis 扣了但 MySQL 没写进去的情况也能通过扫描流水号补回来或者把号还回去。对账任务我写成了一个每五分钟跑一次的小脚本跑了半年多抓到过两次异常都是网络抖动导致的写库超时。2.3 数据表设计要点与唯一索引的用法表设计这块我列一下核心几张表和关键字段方便你直接对照。表名关键字段设计要点用户表用户ID、手机号、证件号密文、证件号哈希、建档状态证件号存密文另存一份哈希用于判重和查询接种点表接种点ID、名称、地址、台位数、开放时段、状态台位数是容量测算的输入不能随便改号源池表号源池ID、接种点ID、日期、时段起止、总容量、已约数、版本号已约数用版本号做乐观锁防止并发覆盖预约单表预约单号、用户ID、号源池ID、状态、预扣流水号、创建时间预扣流水号建唯一索引防止重复落库核销记录表记录ID、预约单号、核销时间、操作人、核销方式预约单号建唯一索引防止重复核销操作日志表日志ID、操作类型、操作对象、操作人、变更前后值管理端的容量调整必须留痕唯一索引在这个系统里的作用被我放大了。它不只是防重复它是最后一道一致性防线。预约落库用预扣流水号做唯一索引核销用预约单号做唯一索引用户建档用证件号哈希做唯一索引。这三道索引让我在面对到底会不会重复这个问题时敢直接说不会数据库拦着。有个小坑提醒一下唯一索引的字段类型要跟业务语义严格对齐。我早期把预扣流水号存成了变长字符串结果因为前后空格问题唯一索引没拦住重复。后来统一改成定长字符串并且写入前做一次 trim问题消失。这种低级错误在赶工期的时候特别容易出。3. 号源扣减的三种实现与最终选型3.1 三种方案的实测对比号源扣减我认真试过三种做法把它们摆在一起对比最清楚。方案实现方式优点缺点适用场景数据库乐观锁UPDATE ... SET 已约数已约数1 WHERE 已约数 总容量实现简单、强一致、无额外组件高并发下行锁竞争激烈吞吐上不去单号源池并发低于百级数据库悲观锁SELECT ... FOR UPDATE再判断再更新逻辑直观、绝对不超发锁持有时间长容易出现死锁和连接池耗尽不推荐用于本项目Redis 原子预扣Lua 脚本里判断余量并自减吞吐极高、响应快、能扛住突发需要额外的对账与补偿逻辑本项目主选方案乐观锁那一条 SQL 其实挺优雅的WHERE 已约数 总容量这个条件天然防止了超发而且不需要额外组件。如果你们的预约系统日均订单不到一千我真的建议就用它别折腾。但号源集中放出的那几分钟同一行记录的并发更新会排成一条长队我实测在两百并发下就开始出现明显的锁等待。悲观锁我踩过坑直接说结论不要用。当初为了绝对安全选了它压测的时候连接池一路打到上限然后所有请求开始超时最后整个服务不可用。事后复盘很清楚——锁持有时间包含了一次网络往返和一次业务判断在高峰期这个时间被放大了几十倍。Redis 原子预扣的 Lua 脚本大概长这样-- KEYS[1] 号源池 keyARGV[1] 扣减数量ARGV[2] 预扣流水号 local remain tonumber(redis.call(GET, KEYS[1]) or -1) if remain 0 then return 0 -- key 不存在返回失败由业务层回源 MySQL 重建 end if remain tonumber(ARGV[1]) then return -1 -- 余量不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(SET, reserve: .. ARGV[2], KEYS[1], EX, 300) return 1这里有两个经验点。第一key 不存在时我返回的是 0 而不是直接当作余量为零因为 key 不存在可能是缓存过期或者被清理直接判失败会让用户莫名约不上。业务层拿到 0 之后应该回源 MySQL 读一次真实余量写回缓存再重试一次。第二预扣流水号的映射我设了三百秒过期这个时间要略大于落库的最长耗时太短会导致落库时找不到对应关系太长会占用内存。3.2 幂等一次点击只允许产生一条预约幂等这件事用户不关心但它是投诉量的分水岭。常见的用户行为是点了提交没反应再点一次再点一次然后发现自己约了三单其中两单还得打电话取消。这种场景在弱网环境下极其普遍。我的做法是三层防护叠在一起。第一层是前端按钮防抖点击后立刻置灰并显示提交中这个能挡掉八成重复提交。第二层是提交时带一个客户端生成的请求令牌服务端用 Redis 的SET NX做一次性消费令牌有效期五分钟。第三层是数据库唯一索引用预扣流水号做兜底。// 请求令牌校验令牌由前端在进入预约页时获取 Boolean first redisTemplate.opsForValue() .setIfAbsent(req:token: token, userId, 5, TimeUnit.MINUTES); if (Boolean.FALSE.equals(first)) { throw new BizException(请勿重复提交); }三层里最关键是第二层因为它挡在号源扣减之前。如果只靠数据库唯一索引那么重复请求会先把 Redis 里的余量扣掉、再在落库时失败号源就凭空少了一个虽然能通过对账补回来但那是事后补救不如事前拦住。还有个容易被忽略的幂等点核销。接种点工作人员手抖扫两次码如果核销表没有唯一索引就会出现两条核销记录统计报表里的接种人数就虚高了。这个我在第一个项目里吃过亏后来在所有写操作上都加了唯一索引约束形成肌肉记忆。3.3 防刷与限流分四层做才有效号源是稀缺资源一定有脚本在抢。我在实际项目里遇到过单个账号在十秒内发起两百多次预约请求的情况明显不是正常用户。防刷我分了四层。第一层在网关做 IP 维度的粗粒度限流单 IP 每分钟不超过六十次请求。第二层在接口做用户维度的频控单用户每分钟最多五次预约尝试用 Redis 计数器实现key 按用户 ID 加分钟窗口生成。第三层是人机校验在预约提交前插入一次轻量校验异常账号才触发正常用户无感这样不会伤害体验。第四层是业务规则风控比如同一证件号在同一时段只能有一个有效预约、同一设备指纹短时间内不能关联过多账号。这里我想说的是第四层最有价值也最容易被忽略。前三层是通用防护任何一个做过接口开发的人都会想到第四层才是贴着业务长出来的它不靠识别你是不是机器人而是靠约束即使你是真人也不能这么干误伤率低拦截效果好。限流阈值不要拍脑袋定。我的做法是先看历史数据找出正常用户的请求分布取百分之九十九分位再乘一个安全系数。比如正常用户预约操作平均一点八次、最多四次那我就把阈值定在每分钟十次留足余量。定太紧会误伤定太松等于没做。4. 时段容量建模与放号策略4.1 容量怎么算才不脱节号源数量不是运营随便填一个数字它应该从接种点的实际接待能力反推出来。我给一个可以直接用的计算式单时段容量 台位数 × 时段分钟数 ÷ 单人平均服务分钟数 × 冗余系数举个例子某接种点开放三个登记台位上午时段从八点半到十一点半共一百八十分钟单人从登记、问询到完成接种平均需要六分钟冗余系数取零点八五考虑排队、填表、突发情况。那么单时段容量 3 × 180 ÷ 6 × 0.85 ≈ 76.5向下取整七十六人。冗余系数这个参数是我加了又加的东西。一开始取一结果现场经常因为个别居民问询时间长导致后面的队伍越排越长。取零点九之后好一些取零点八五之后基本平稳。这个值没有标准答案跟接种点的流程熟练度强相关我的建议是上线第一个月按零点八算跑一段时间后根据实际接待数据回调。还有个细节时段不能切得太碎。我见过把上午切成六个三十分钟的小时段结果是每个时段都要重新排队现场反而更乱。我的经验是单时段控制在六十到九十分钟比较合适既能削峰又不会让居民频繁切换。4.2 放号策略一次放完还是分批放这个问题我和运营争过好几次。一次性把未来七天的号全放出来好处是用户一次就能约到坏处是号源被瞬间抢空后面几天想约的人完全没机会而且爽约率明显上升——因为约到的是三天后的号用户变数大。我最后采用的是分批放号加短预约窗口。具体规则是每天固定时间放出第七天的号源形成滚动窗口同时保留一部分号源作为机动池由接种点当天早上根据实际到岗情况释放。这套规则跑下来爽约率从最初的两成多降到一成以内效果比任何提醒手段都管用。光靠规则不够还得有回收机制。用户取消预约、预约过期未核销号源都要回到池子里。回收的实现有两种一种是定时任务扫描过期预约单批量回补另一种是用户取消时实时回补。我采用两者结合取消时实时回补让号源尽快回到市场过期未核销的用每十分钟一次的定时任务批量处理。这里有个并发陷阱要特别小心回补号源和正常扣减同时发生时如果两者操作的是不同的数据副本就会超发。我的处理方式是把回补也走同一套 Redis 原子操作先INCRBY再更新数据库已约数而且回补前要先查一次数据库里的实际状态确认这张预约单确实处于应回收状态。这个先确认再操作的顺序不能反反了就会出现取消两次回补两个号的问题。5. 实名核验与隐私处理5.1 最小化采集的边界在哪实名信息是这个系统里最敏感的部分。我的原则很简单能不采的坚决不采能少存的绝不多存。具体到字段姓名、证件号、手机号是预约和核销的必要信息没有替代方案。但像居住地址、工作单位这类字段早期版本里有人建议加上便于统计分析我直接砍掉了。统计需求完全可以通过接种点和时段维度满足不需要个人住址。后来证明砍对了字段越少合规评审越顺用户填写转化率也更高。证件号的存储我做了两套一套密文用于展示和业务核对一套哈希值用于判重和查询。哈希用带固定盐的算法保证同一个证件号每次算出来一样能用于唯一索引同时无法被反推。查询用户时走哈希展示时走密文解密两条路径分开避免密文参与索引导致性能问题。手机号稍微特殊一点因为要发短信。我的做法是存储密文同时在缓存里保留一份有效期很短比如十分钟的明文用于发送短信发送完立刻删除。这样即使缓存被读取暴露的窗口也很有限。5.2 加密、脱敏与日志的三个红线加密这块证件号和手机号用对称加密存储密钥不放在代码里走配置中心或者密钥管理服务并且要能轮换。密钥轮换听起来很工程化实际上很多项目上线两年都不换一次这是隐患。我在项目里设定的是每半年轮换一次轮换期间新旧密钥并存解密时按密钥版本号选择重加密任务在低峰期跑。脱敏是给所有非必要场景用的。我的规则是管理后台列表页一律脱敏展示姓名保留姓加星号证件号保留前三位和后四位手机号保留前三后四只有在预约详情页并且操作人有审批过的权限时才展示完整信息而且每次查看都记一次操作日志。日志这条红线必须写死在规范里任何日志、任何异常堆栈、任何埋点都不允许出现明文证件号和手机号。这件事说起来简单做起来特别容易破防——开发调试时随手一个log.info(user.toString())就把整条记录打出来了而toString里往往包含所有字段。我的解法是在实体类上把敏感字段的toString输出直接改成掩码让随手打日志这个动作天然安全。这个改动只花了半小时但它把一类事故的概率降到了接近零。另外提醒一句导出功能是隐私泄露的重灾区。管理端的报名单导出一定要做权限校验、操作留痕并且导出的文件本身要做加密或者设置有效期。我在项目里对导出加了二次验证虽然被吐槽过麻烦但这是必须坚持的。6. 压测、监控与上线应急6.1 压测怎么做才有意义压测不是跑一个数字出来交差它要能回答我们的系统在什么情况下会崩这个问题。我的做法是先把场景模型搭出来。真实的预约流量不是均匀的而是集中在放号后的前两分钟。所以压测脚本要模拟这种脉冲前三十秒零请求第三十一秒开始每秒涌入峰值流量的百分之八十持续九十秒然后快速衰减。用均匀流量压出来的结论会严重高估系统能力。压测的步骤是逐步加压找拐点。从每秒一百请求开始每轮增加一百观察三个指标接口响应时间的九十九分位、错误率、数据库连接池使用率。当九十九分位超过一秒或者错误率超过百分之一或者连接池使用率超过八成就把当前压力记为拐点实际承载能力按拐点的七折计算。我当初跑出来的拐点在每秒两千四百请求左右两台应用服务器。按七折算是每秒一千六百八十而这个区域的实际峰值预估是每秒八百到一千留了一倍余量够了。压测过程中发现的两个瓶颈也很典型一个是 Redis 单 key 热点一个是 MySQL 的慢查询都通过加本地缓存和补索引解决了。压测环境要尽量贴近生产包括数据库的数据量级。我见过数据量差两个数量级导致压测结论完全失真的情况索引在十万行时走得好好的到一千万行就全表扫描了。6.2 监控指标与降级预案上线之后最怕的不是崩是崩了你还不知道。我列一下这个系统必须盯的指标和对应的处置动作。监控指标预警阈值处置动作预约接口九十九分位响应时间超过八百毫秒持续一分钟检查 Redis 命中率与数据库慢查询预约失败率超过百分之三持续两分钟触发降级关闭人机校验检查号源一致性Redis 内存使用率超过百分之七十五清理过期流水号评估扩容号源余量与已约数偏差差异大于零立即告警启动对账脚本必要时暂停放号数据库连接池使用率超过百分之八十五限流收紧排查慢查询核销接口错误率超过百分之二持续五分钟切到离线核销模式恢复后批量同步最后一行号源余量与已约数偏差是这个系统最核心的一个监控。我写了一个每五分钟跑一次的对账任务比对 Redis 中所有活跃号源池的余量加已约数是否等于总容量。这个等式一旦不成立说明数据已经不一致了必须马上处理。这个任务帮我抓到过两次因为重试逻辑写错导致的多扣都是在小范围内发现没有扩散。降级预案要提前写好并且演练过。我的降级顺序是先关人机校验不影响主流程只是放松防护再收紧用户维度限流然后把非核心接口比如接种点地图、公告直接返回缓存最后才是暂停放号。暂停放号是核弹级操作会让所有用户看到暂无可约号源所以这个开关必须只有极少数人有权操作并且操作前要在群里同步。7. 常见问题与排查速查现象可能原因排查思路解决方式用户反馈约上了但列表里没有预扣成功但落库失败用预扣流水号查对账表补偿落库或回退号源同一用户出现两条有效预约幂等令牌失效或未带令牌查请求日志是否有令牌字段补唯一索引清洗历史数据号源显示有但点进去约不上Redis 与数据库余量不同步跑对账脚本比对以数据库为准重建缓存到点放号后页面卡死峰值远超容量连接池打满看连接池使用率与响应时间曲线分批放号增加应用节点核销后统计人数偏大重复核销无唯一约束统计核销记录按预约单号去重补唯一索引重算报表接种点说名单少了几个人查询走了缓存或分页错位直接查数据库原始数据修分页逻辑名单接口不走缓存短信发不出去第三方服务限流或余额不足查短信服务返回码加队列削峰配置备用通道导出文件打开报错导出的字段含特殊字符未转义抽样检查文件内容统一做字段转义与编码声明除了表格里这些我想单独说一个印象最深的问题。上线第三天有用户反馈我能约到明年的号。查了半天发现是日期选择器的边界校验写在了前端后端接收参数时只判断了格式没判断范围有人改参数就绕过去了。这个教训很朴素所有的业务边界校验都必须在服务端再做一次前端校验只是体验优化不是安全措施。补上之后我在服务端加了统一的参数范围校验拦截器所有涉及日期、时段、数量的入参都过一遍拦截器这类问题再没出现过。还有一个是性能相关的。上线初期我的预约列表页面加载特别慢查看日志发现是每次请求都要解密证件号和手机号而列表页其实只需要展示部分信息。后来改成列表页只查脱敏字段详情页才解密响应时间从一点二秒降到一百八十毫秒。这个优化的关键在于想清楚这个页面到底需不需要明文大多数时候答案是不需要。最后再分享一个我很受用的习惯给号源池加一个可约状态的快照。每次发布号源之后把当时的号源池完整状态存一份快照到单独的集合里包含总容量、各时段分布、发布时间。这个快照在事后复盘和纠纷处理时特别好用——用户说我明明看到还有号你能直接把当时的快照调出来对质。这个设计成本很低但在需要自证清白的时候价值极高。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑