ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

做预约类小程序这些年,我经手过好几个方向的需求,新冠疫苗预约小程序是其中比较特殊的一类:它的业务模型看着简单——选个接种点、挑个时段、填个信息、提交——但真做起来,你会发现坑几乎全埋在看不见的地方。它上线就要扛住集中时段的访问洪峰,号源一旦超发,线下接种点当天就会乱成一锅粥;它对实名校验、隐私保护的要求比普通业务高一个量级;它还牵扯接种点、区域管理方、居民三类角色,任何一方的操作习惯没被照顾到,最后都会变成客服电话打到你手机上。

这篇东西写给两类人看。一类是接到类似预约项目、正在做技术选型和排期的开发者,我尽量把选型背后的判断讲透,让你少走我走过的弯路;另一类是对接这类系统的产品或运营同学,你能从中看到哪些需求在技术上代价很高、哪些看起来复杂其实很好做,方便你在评审会上判断优先级。全文围绕号源模型、并发扣减、幂等防重、容量测算、隐私处理、上线运维这几块展开,代码和表格都给到可以直接抄的程度,但我不保证照搬就能跑,业务参数永远比代码重要。

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] 号源池 key,ARGV[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 与数据库余量不同步跑对账脚本比对以数据库为准重建缓存
到点放号后页面卡死峰值远超容量,连接池打满看连接池使用率与响应时间曲线分批放号,增加应用节点
核销后统计人数偏大重复核销无唯一约束统计核销记录按预约单号去重补唯一索引,重算报表
接种点说名单少了几个人查询走了缓存或分页错位直接查数据库原始数据修分页逻辑,名单接口不走缓存
短信发不出去第三方服务限流或余额不足查短信服务返回码加队列削峰,配置备用通道
导出文件打开报错导出的字段含特殊字符未转义抽样检查文件内容统一做字段转义与编码声明

除了表格里这些,我想单独说一个印象最深的问题。上线第三天,有用户反馈"我能约到明年的号"。查了半天,发现是日期选择器的边界校验写在了前端,后端接收参数时只判断了格式没判断范围,有人改参数就绕过去了。这个教训很朴素:所有的业务边界校验都必须在服务端再做一次,前端校验只是体验优化,不是安全措施。补上之后,我在服务端加了统一的参数范围校验拦截器,所有涉及日期、时段、数量的入参都过一遍拦截器,这类问题再没出现过。

还有一个是性能相关的。上线初期"我的预约"列表页面加载特别慢,查看日志发现是每次请求都要解密证件号和手机号,而列表页其实只需要展示部分信息。后来改成列表页只查脱敏字段,详情页才解密,响应时间从一点二秒降到一百八十毫秒。这个优化的关键在于想清楚"这个页面到底需不需要明文",大多数时候答案是不需要。

最后再分享一个我很受用的习惯:给号源池加一个"可约状态"的快照。每次发布号源之后,把当时的号源池完整状态存一份快照到单独的集合里,包含总容量、各时段分布、发布时间。这个快照在事后复盘和纠纷处理时特别好用——用户说"我明明看到还有号",你能直接把当时的快照调出来对质。这个设计成本很低,但在需要自证清白的时候,价值极高。

返回列表