
前阵子参与了一个出行服务平台的“幸运旅客”活动核心玩法不复杂每日从当日下单乘客里抽取一批“幸运编号”乘客拿着编号就能兑换奖品。一开始团队里有人觉得这不就是给乘客发个随机码嘛抽中就中没抽中拉到。但真正把“幸运旅客的编号”这个需求拆开从规则制定、编号生成、身份绑定、兑奖核销到高并发压测每一个环节都能找出几个坑。这篇文章就把这次项目的主要设计与踩坑过程梳理一下给后面要做类似抽奖、营销编号系统的同学做个参考。1. 为什么需要一个“幸运旅客的编号”而不是简单抽奖活动刚立项时产品经理提的方案比较传统乘客下单成功后台把订单号加入抽奖池定时抽N个订单中奖订单的用户收到Push通知。听起来很直接大家也都做过类似的东西。但后来运营提了一个需求直接把复杂度拉高了一个层级他们希望乘客在下单完成页就能看到自己的“幸运编号”并且能拿着这个编号去活动页查询当天是否中奖。“看到编号”和“看到是否中奖”是两件完全不同的事情。如果编号就是订单号或者手机号这类确定性标识乘客很容易自己推算出规则提前判断哪些号码会中奖活动公平性马上会被挑战。如果编号是完全随机的乘客又会觉得“编号和我没关系”参与感差很多。最后我们定的方案是每位符合条件的乘客在下单后立刻获得一个由系统生成的、全局唯一的“当日幸运编号”该编号由当日开奖种子与乘客业务ID共同决定开奖时刻公布中奖编号区间。这个方案有几个业务上的好处编号和乘客强绑定每个人看到的都是自己的专属编号不会出现两个乘客拿到同一个号的情况。编号表面上看起来毫无规律乘客无法提前知道自己的号会不会中除非他能预测当日种子。中奖结果可以设计成“编号后三位命中”或“编号区间命中”运营可以灵活控制中奖人数给不同价值的奖品设置不同的命中规则。从系统设计的角度我对这个需求设了三个硬性要求。第一可验证性乘客拿编号能查出是否中奖接口必须具备幂等性重复查询结果一致。第二不可预测性编号生成逻辑中的关键参数不能从对外返回的数据里反推出来。第三可审计性运营需要能倒推任意一笔订单的编号生成过程方便处理客服投诉和争议订单。正是这三个要求决定了后面算法选型和数据表结构的设计方向。1.1 编号生成的三种规则设计对比我们内部讨论过三轮方案各有适用场景但最后只留下一种作为主方案。第一种方案是纯随机数乘客下单时用Math.random()或UUID生成一串数字当作编号。优势是开发最简单劣势非常明显——运营无法对“编号”做任何规则化运作比如“每日第100个下单的乘客拿特殊编号”“编号尾号88免单”。纯随机数还会带来一个隐藏问题如果编号不连续乘客很难记忆和传播活动参与门槛会变高。第二种方案是订单号派生把订单号或用户ID直接做后几位截取。开发比第一种还简单但可预测性太强。运营如果定了“尾号为7的编号中奖”乘客在下单前就能算出来自己买什么时间段的票更容易中。而且订单号是递增序列不同乘客的编号分布不均匀晚下单乘客的编号大概率排在前面早下单乘客的编号靠后资深用户很快就能摸清规律。第三种方案是种子哈希乘客下单成功后系统取当天的开奖种子例如当天日期字符串加一个服务端秘钥与乘客业务标识做拼接进行哈希运算再截取其中若干位作为乘客的当日幸运编号。这个编号具备“像随机数但不完全是随机数”的特征同一个乘客在同一天内无论调用多少次生成接口得到的编号都稳定不变不同乘客在同一天内编号分布近似均匀看起来完全随机。我们最终选择了第三种方案同时保留“种子”由运营每天更新这一管理手段。这样既保证编号的不可预测性也能在活动出现争议时由系统管理员更换秘钥重新计算。2. 编号生成算法的选型随机数、哈希种子和开奖时刻的博弈这一章直接说实现层面的细节。整个编号系统最核心的部分是一段哈希计算代码看起来不复杂但要在工程上把它做对需要处理好几个边界问题。2.1 我推荐的方案当天种子 哈希截断编号生成的主流程是这样的乘客下单成功后客户端调用/lucky/no接口服务端从请求中解析出用户ID和订单ID然后执行以下计算步骤取系统当前日期作为种子日例如2025-01-15。将日期字符串与服务端配置的秘钥拼接比如2025-01-15|random-salt-abc记为daySeed。把daySeed和用户ID拼接做一次SHA-256哈希。将哈希结果转成十进制数字截取后6位作为幸运编号不足6位时前补0。用Python示意的话是这样import hashlib def generate_lucky_no(user_id: str, date_str: str, salt: str) - str: day_seed f{date_str}|{salt} content f{day_seed}|{user_id}.encode(utf-8) digest hashlib.sha256(content).hexdigest() # 取哈希值最后8位十六进制转成十进制再截取6位 number_part int(digest[-8:], 16) % 1000000 return f{number_part:06d}这里有个关键点哈希截取不能直接用int(digest, 16) % 1000000全量取模。SHA-256的输出是64位十六进制字符全量转成十进制再取模运算代价会明显上升高并发下单接口扛不住。我实际测试过对全量摘要取模比只取后8位再取模慢了三到四倍而且全量取模并没有带来更好的分布均匀性。哈希值的后8位已经包含了足够多的熵截断后做模运算在百万量级的编号空间里分布足够均匀。编号位数我选了6位理由是活动目标是每日单量在10万到50万之间6位编号可以提供100万个不同编号足以保证同一日内几乎不会出现碰撞。如果把编号位数定成4位理论上可分配空间只有1万个一旦当日订单超过1万就必然产生重复编号。如果定成8位又太长乘客在页面输入时容易输错传播性也差。2.2 编号碰撞与重试同一乘客多次下单的场景很多团队在做编号时容易忽略一个问题同一个乘客当天可能下多个订单。按活动规则乘客当天应该只有一个幸运编号而不是一个订单一个编号。所以生成编号前需要先查询乘客当天是否已经生成过编号。如果已经生成就直接返回历史编号如果没有再执行哈希计算并落库。这个查询和写入在并发场景下容易出问题。同一乘客的两个订单可能来自两个不同请求同时进到服务里双双发现库里没有记录然后各自生成一个不同的编号后写入的覆盖先写入的导致乘客前端看到的编号和后台记录的编号不一致。解决方法是给乘客当日的编号记录表加唯一索引比如(user_id, activity_date)插入时用insert ... on duplicate key update或INSERT IGNORE通过数据库约束保证同一乘客同一天只有一个编号。更稳妥的做法是把“生成编号落库”放在一个事务里且计算完成后先查缓存再查数据库。查询链路可以用Redis来挡一部分但Redis只能做缓存最终一致性还是要靠数据库的唯一索引兜底。我在项目里就把这两层都做了Redis存lucky:no:{date}:{user_id}决不做“先删后写”的操作数据库表加唯一索引作为冲突仲裁者。2.3 开奖时刻的算法与判定逻辑真正的“开奖”不是把所有中奖编号挨个列出来而是维护一组“命中规则”。运营在活动后台配置若干条规则例如规则A编号尾号为8奖励2元打车券。规则B编号后三位为666奖励免单券。规则C编号在[000001, 000500]区间内奖励会员周卡。判定某个编号是否中奖就是对编号做字符串和数值判断不需要预生成中奖名单。这么做的好处是即使活动已经上线运营也能在不影响已有编号的情况下调整规则只要保证规则在某个固定时间点统一生效即可。判定必须放在服务端做前端只做展示否则乘客改个参数就能“改”出自己的中奖结果。服务端判定时还要对同一个编号做幂等缓存防止乘客在零点开奖瞬间高频刷新把下游奖品系统打挂。3. 绑定、核销与风控从“有一个编号”到“能用编号领奖”有编号只是第一步。真正让活动跑起来的关键是怎么把编号和乘客身份绑定以及怎么防止有人通过伪造或盗用编号把奖品领走。3.1 游客状态下如何绑定乘客身份活动上线时我们发现有很大一部分乘客在下单时并没有登录账号属于游客状态。他们能看到订单号也能看到生成的幸运编号但如果编号只存在服务端没有和任何账号关联后续领奖时就会面临“这个人到底是不是编号所有者”的验证问题。我们的做法是在生成编号的同时把编号和“设备标识”绑定并在兑奖时要求乘客完成手机号验证验证通过后将编号与手机号二次绑定。流程如下乘客下单完成页调/lucky/no接口服务端用设备ID生成编号并入库。乘客去活动页输入编号查询中奖结果页面提示“请输入下单手机号验证身份”。服务端校验手机号与下单订单号是否匹配订单表中存有游客下单手机号匹配后返回中奖结果。中奖奖品入账到该手机号对应的账户中若账户尚不存在则引导注册。这里有一个容易出错的细节游客订单中的手机号和最终兑奖手机号可能不是同一个。比如乘客用A手机号下单但兑奖时用B手机号登录平台。如果不做关联B手机号会在绑定环节被拒绝导致真实乘客无法领奖。我们的处理方式是在生成编号时把订单号也关联进去兑奖时允许乘客输入订单号后四位加上手机号进行双重验证而不是只验手机号。3.2 兑奖链路中的风控动作中奖结果对外暴露后很快就会有批量“薅羊毛”的请求进来。我们的兑奖接口做了这几个风控校验设备指纹校验同一设备在短时间内领奖次数超过阈值直接拒绝因为正常乘客一天最多领一次。手机号频控同一手机号当日领奖次数超过1次拒绝防止手机号囤号。IP频控同一IP在开奖后10分钟内领奖请求超过20次进入验证码流程。黑名单库运营维护的异常设备号、异常手机号列表请求命中后直接标记并返回“活动参与资格异常”。这些规则放在网关层做还是放在业务层做是有讲究的。我当时倾向于放网关层因为兑奖接口的QPS并不高真正的压力在“查询是否中奖”这个接口上。把频控逻辑放在网关能提前拦截大部分恶意流量业务服务就不需要关心请求是从哪里来的只需要关心“这个人能不能领这个奖”。3.3 异常处理退票、改签、重复领奖、超时未领运营过程中一定会出现退票、改签的乘客。乘客退票后这张订单还应该参与活动吗我们一开始定的规则是退票后编号作废乘客不能领奖。但实际跑下来发现这个规则很麻烦因为乘客可能在开奖后退票此时编号已经被判定为中奖奖品已经入账。“先中奖后退票”和“先退票后中奖”处理逻辑完全不同。我的处理经验是把“退票是否影响领奖”做成一个配置项而不是写死在代码里。默认配置为“开奖后退票不影响已中奖奖品”也就是奖品发出后就不再回收开奖前退票则编号失效不参与抽奖。这个配置运营可以根据活动预算随时调整。这么做的好处是避免客服在处理“乘客退票但奖品已发”的纠纷时无据可依。重复领奖靠数据库唯一约束控制。核销表的主键是(activity_id, user_id)或(lucky_no, date)重复核销直接报错不让奖品系统出现二次发货。超时未领的情况也要提前设计。我们确定奖品领取有效期为开奖后72小时内过期自动作废。这个过期操作不需要额外跑定时任务而是在乘客查询阶段性状态时判断当前时间是否超出领取截止时间如果超出则返回“已过期”。只有奖品库存清理和过期数据归档才需要跑一个每日一次的低峰定时任务。4. 上线前的压测与上线后的监控最容易翻车的三个地方这个环节我吃过不少亏专门列出来说希望大家在排期时提前预留出测试时间。4.1 凌晨零点开奖的缓存陷阱活动规则是每日零点开奖。零点前一个小时活动页的访问量开始爬升绝大多数乘客会集中在00:00到00:05之间刷新开奖结果。这个流量峰值比全天任何时段都高通常会占到全天查询量的40%以上。第一次压测时我们只测了查询接口在单机2000 QPS下的表现没测缓存失效后的穿透情况。结果正式开奖当日Redis里缓存的是“昨日编号与昨日开奖结果”零点一到昨日缓存全部失效所有查询请求同时打到数据库数据库连接数瞬间被打满接口超时率飙到30%以上。解决方案是把缓存设计成“双缓存”结构。具体来说服务端维护两套keylucky:result:{date}:{no}存当日中奖结果。lucky:result:{date-1}:{no}存昨日中奖结果。零点后新的请求先查当日缓存如果当日缓存尚未生成则回源查询数据库并回填如果查询的是昨日编号则查昨日缓存。通过提前预加载上一日的缓存新日期的缓存生成逻辑可以在零点前提前跑而不是等到零点后有请求了再回源。更进一步的方案是在零点前30分钟启动一个预热任务把当天可能被频繁查询的编号区间段先加载进Redis但这些编号在零点前返回的“未中奖”结果不能写入当日缓存只能先写一个pending标记等零点真正到达时再刷新为最终结果。4.2 多实例下的库存扣减奖品库存扣减必须保证原子性。如果奖品系统是独立服务并且库存存在MySQL中并发扣减时很容易出现超卖。我们最后用的是Redis Lua脚本来做“库存预占”和“扣减确认”两步操作。预占的逻辑如下-- KEYS[1] 库存 key -- KEYS[2] 已领取用户 set key -- ARGV[1] 用户 id -- ARGV[2] 本次活动奖品 id if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 0 -- 已领过 end local stock tonumber(redis.call(get, KEYS[1])) if stock nil or stock 0 then return -1 -- 无库存 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) return 1这个脚本把“查用户是否已领取、检查剩余库存、扣减库存、记录领取用户”合并成一个原子操作避免并发下多个请求同时读到剩余库存为1然后同时扣减导致库存变负。线上库存数据最终要和数据库对账Redis脚本只负责活动高峰期的实时扣减数据库里维护一个异步对账任务每天凌晨把Redis里的领取记录同步到MySQL并核对库存数量。4.3 数据一致性监控与对账活动系统的数据一致性比一般业务系统更难监控因为用户侧看到的“编号”“中奖结果”和后台运营侧看到的“订单”“奖品”是两套数据链路。我在项目里配置了三个核心监控指标编号生成成功率下单完成页的编号生成接口失败率超过0.5%就要告警。失败原因通常是哈希计算或数据库写入异常。中奖查询成功率和平均RT正常情况P99应低于300ms超过800ms就要排查缓存是否失效。兑奖成功/失败比例失败比例突然升高可能是风控误杀也可能是奖品库存耗尽后接口未正确返回“奖已领完”的文案。对账任务的核心SQL是把每日生成的编号数量、实际参与开奖的编号数量、中奖编号数量、成功兑奖编号数量四条链路的数字拉出来做对比。正常情况它们应该层层递减如果出现“实际参与开奖的编号数量”小于“中奖编号数量”说明规则配置或编号生成存在重复必须立刻排查。5. 这次项目结束后的三点改进建议活动上线跑了一个月效果不错但复盘时仍然发现了不少可以优化的地方。这些改进不一定影响当前版本但对下一次活动迭代很有价值。5.1 让编号变成可分享的社交传播载体当前版本里编号只有乘客自己能在下单完成页看到实质上是个“个人查询凭证”没有形成传播。复盘后我认为可以把编号设计成“类强关联但又可以公开传播”的形态。具体做法是为每个编号生成一个短链接乘客活动页点击“晒一晒我的幸运编号”生成一张带编号和活动文案的分享卡片卡片带上乘客专属的邀请码朋友通过卡片进入活动后双方都能获得额外抽奖次数。这里有个安全设计分享卡片上的编号不能用于直接兑奖兑奖时仍然需要验证手机号和订单信息。编号可以作为传播元素但不能成为唯一的领奖凭证否则分享出去的编号被其他人看到就能冒领。5.2 设计规则开关与降级方案运营希望在活动期间随时调整中奖概率比如奖品发得太快晚上8点就把当天预算发完了这时候应该关闭部分高价值奖品的抽奖规则而不是让活动直接报错。我们的做法是为每一条中奖规则增加一个“状态”字段状态分为“生效中”“暂停”“结束”。运营在后台调整状态后规则不会立刻对所有请求生效而是延迟15秒生效避免正在兑奖的乘客请求结果不一致。降级方案包括奖品接口超时时用户侧统一返回“奖品正在补货中”编号生成依赖的秘钥被破解或泄露时支持按天热更新秘钥历史秘钥保留用于历史编号校验当前活动日启用新秘钥。5.3 用埋点数据反推活动中奖体验上线时我们埋了三个关键事件编号生成成功、中奖结果查询、兑奖成功。复盘时对比这三个事件的漏斗发现从“查询结果”到“兑奖成功”的转化率只有12%远低于预期。点开日志一看大量用户查询到“未中奖”结果后直接离开页面还有一部分用户中奖了但被“订单号后四位手机号验证”这一步卡住输错了三次就被验证码拦截。针对反馈比较集中的问题后续版本把验证从四位数码改成了“自动识别登录态”用户登录后系统可以自动匹配订单手机号不需要手动输入只有游客才需要手动验证。这个改动预计能把兑奖转化率提升至少一倍也让我更确定一个判断活动系统的技术难点不只是生成和计算编号更多在于让参与者用最少的操作拿到奖励。如果时光倒流重新做一次我会在第一个版本就把“查询、绑定、兑奖”三步走简化为“查询即兑奖”。编号生成、中奖判定这些逻辑保持现状但验证链路尽量复用平台已有的账号体系不要让用户为了一个十几块钱的奖品填三遍信息。毕竟“幸运旅客的编号”带来的应该是一种轻量、惊喜的体验而不是一场考验耐心的流程考试。