我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

高并发优惠券系统实战:从架构设计到压测调优

高并发优惠券系统实战:从架构设计到压测调优 做高并发系统大家嘴上都说“不就是缓存加队列嘛”可真把优惠券系统做到10万QPS的规模你会发现每一个细节都在挑战你对分布式系统的理解。这个项目我前后折腾了三个月从最初每秒几百请求都抖到最后扛住双11级别的流量洪峰中间踩了无数坑。这篇文章不做任何保留把整个从零搭建的过程、架构演进的思路、核心代码的落地方式以及压测和线上故障的排查实录全部写出来。先说说这个项目最终要交付什么一套支持三种优惠券类型满减券、折扣券、无门槛券、支持秒杀抢券、支持防刷限流、支持异步核销的高并发优惠券系统。核心指标是单机支撑5万QPS、集群整体10万QPS以上接口平均响应时间小于200ms99线小于500ms。我的开发环境是从零开始搭建的没有复用任何已有中间件全部自己部署、自己配置、自己调优。1. 项目难点拆解为什么优惠券系统比普通商城接口难做1.1 这不是简单的增删改查很多开发者觉得优惠券系统不就是几张表、几个接口吗实际上优惠券系统在电商体系里一直是高并发问题的重灾区原因在于它同时具备三个高难度特征高流量冲击、强一致性要求、弱数据容忍度为零。先说高流量冲击。普通商品详情页的访问你扛不住还可以降级返回缓存页面用户顶多觉得“图片加载慢”。但优惠券是明确的营销资源某个时间点开抢瞬间涌入的流量可能是日常峰值的几十倍。我经历过最夸张的一次预热页面访问量是平日的40倍抢券接口瞬间QPS冲到8万数据库连接池直接被打满。再说强一致性。发券必须保证库存不能超发用户不能重复领取同一张券券状态不能出现“已领取但不可用”的诡异情况。这些约束在低并发下用数据库事务很容易实现但一旦并发上去传统的行锁、事务机制就会成为瓶颈。最后说弱容忍度。用户对抢券失败的容忍度极低抢不到会疯狂重试重试又带来更大的流量。而且优惠券直接关联后续的核销和财务对账任何数据错误都会在结算时暴露造成真金白银的损失。1.2 十万QPS场景下的真实瓶颈在动手写代码之前我花了两天时间做了一件事把系统瓶颈的所有可能位置列出来并逐个估算上限。这个工作非常值得做它直接决定了后面的架构选型方向。首当其冲的是数据库。以单机MySQL为例5000 QPS的读写基本就是警戒线了超过这个值CPU、磁盘IO、锁竞争会全面恶化。10万QPS意味着数据库至少要被隔离到90%以上的流量都到不了它这里。然后是网络层。一个普通云服务器的带宽如果是1Gbps一次接口请求即使只有2KB响应理论上每秒也只能支撑6万次左右。但实际因为TCP握手、序列化、上下文切换的开销能到2万QPS就算不错了。所以10万QPS的系统多节点负载均衡是必须的单机极限根本不够用。再往深处看Redis也有上限。单实例Redis大约能支撑10万次简单读写操作每秒但优惠券的“检查库存-扣减库存-写入用户券记录”是一组复合逻辑至少需要三次Redis操作这样单实例上限就缩水到3万QPS左右。所以必须用分片集群或者拆分多个Redis实例。最后是应用层本身。一个纯Golang的HTTP服务在8核机器上处理简单逻辑大约能支撑3-5万QPS。如果业务逻辑复杂有远程调用、有锁竞争、有频繁GC这个数字还会大幅下降。我画出这个瓶颈分析表之后整个架构方向就清晰了数据库只做最终落库和查询备份Redis扛住核心读写本地缓存扛住热点读应用层无状态化自由扩展限流和防刷前置到网关层。2. 架构设计与技术选型先定边界再写代码2.1 整体架构的演进过程如果直接给出最终架构读者可能会觉得是“事后诸葛亮”更合理的思路是看这个架构如何从单体演进过来。我这个项目经历了三个阶段。第一阶段是单体应用加MySQL所有逻辑在一个服务里接口直接操作数据库表。这个阶段支撑2000 QPS就接近极限了问题出现在数据库连接数和行锁竞争上。用户抢同一张券时数据库行锁排队非常严重一个简单的库存扣减操作在500并发下平均耗时从50ms暴涨到2秒。第二阶段引入了Redis缓存库存和分布式锁。把库存预加载到Redis用分布式锁控制扣减数据库只做最终同步。这阶段能支撑到2万QPS左右但对热点券的并发抢购仍然吃力分布式锁本身会成为瓶颈而且网络抖动会导致大量重试。第三阶段是最终形态Redis Lua脚本原子扣减、多级缓存、MQ异步落库。整个发券流程不再需要分布式锁而是利用Redis单线程的特性用Lua脚本保证检查、扣减、记录三个步骤的原子性。用户领取成功的消息投递到MQ由独立的消费者批量写入数据库。技术栈方面我选用了Golang作为主语言Redis集群存储库存和券数据Kafka作为异步消息通道MySQL负责最终落库和后台查询。之所以选Golang而不是Java是因为在纯IO密集和缓存密集场景下Golang的协程模型和更低的资源占用有明显优势同样8核16G的机器Golang能支撑的并发连接数是Java默认配置下的3-5倍。2.2 为什么用多级缓存而不是只用Redis既然Redis已经能扛住十万级读写了为什么还需要本地缓存这个问题当初也困扰了我很久。直到线上出现一次Redis集群慢查询我才真正理解多级缓存的含义。那次故障的起因是某张热门券被用户疯狂查询详情同一个Redis key被高并发GET导致Redis实例CPU飙到90%以上所有操作延迟普涨。这让我意识到即使Redis能力足够也不能让它被无意义的重复读打满。优惠券详情、用户已领券列表这类读多写少的数据完全可以在应用本地缓存一份配合短TTL实现每秒上千次查询只用一次Redis。多级缓存的策略是第一层是应用本地缓存我用的是Golang的FreeCache存储热点券的详情数据和库存水位第二层是Redis存储全量券信息和实时库存第三层才是数据库。查询时先走本地本地没有或者过期再走RedisRedis没有再回源数据库并回填缓存。这里有两个细节必须处理好。一是本地缓存的更新时机我采用“主动失效加短TTL”双保险策略Redis里券信息变更时通过发布订阅广播通知所有应用节点删除本地缓存防止长时间数据不一致。二是本地缓存不能存储太多数据FreeCache默认内存上限我设为100MB超过就淘汰最老的数据避免本地缓存占用太多对应用本身的性能造成影响。3. 数据模型与库存扣减原子性是高并发的生命线3.1 优惠券系统的数据模型设计数据模型的设计决定了系统的灵活性也决定了后续扩展的难度。我的券核心表设计遵循了“模板与实例分离”的原则。券模板表存储券的定义信息包括券类型、面值、使用门槛、有效期规则、发放总量、每人限领数、适用商品范围等。用户实际领取到的是券实例表每行记录对应一张具体的券包含券码、领取时间、状态、过期时间、订单ID等信息。这种设计的好处是模板表数据量小可以完全放缓存实例表虽然数据量大但写入是顺序追加对数据库压力小。券模板的状态控制也很关键我用一个状态机管理草稿、待上线、上线中、已暂停、已结束。抢券开始时系统需要自动或者人工把模板状态切换到上线中此时库存预热到Redis允许用户领取。任何状态的变更都要有审计日志方便回溯问题。关于券码的生成我采用的方案是雪花ID作为券实例ID 独立短码用于用户展示。很多团队只用雪花ID做券码但雪花ID太长了用户复制、客服核对都不方便。短码我用62进制编码雪花ID的高位段生成10位字符串冲突概率在百万分之一级别同时生成10万张券的耗时不到1秒。3.2 库存扣减的原子操作是如何设计的库存扣减是整个系统最核心、最容易出错的地方。用户领券的流程看起来很简单实际包含五个步骤检查用户合法性、检查模板状态、检查库存是否充足、扣减库存、写入用户券记录。这五步在并发场景下必须做到整体原子。我先说一个典型的错误方案。我前期用Redis配合分布式锁来实现库存扣减伪代码如下# 伪代码有问题的方案 def grab_coupon(user_id, template_id): lock_key flock:coupon:{template_id} if not redis.set(lock_key, 1, nxTrue, ex5): return 手慢了 try: stock redis.decr(fstock:{template_id}) if stock 0: redis.incr(fstock:{template_id}) return 已抢光 record_user_coupon(user_id, template_id) return success finally: redis.delete(lock_key)这个方案的问题至少有四个一是单把锁成为热点锁的获取和释放本身在高并发下性能极差Redis会出现大量并发SETNX竞争二是锁超时后业务还没执行完其他线程拿到锁就会并发扣减导致超发三是即使消除了锁竞争一次领券需要五次Redis网络交互百万次请求就是五百万次网络往返延迟不可控四是崩溃恢复困难如果扣减库存后写用户记录前进程挂了库存和用户数据就不一致。最终我用的方案是纯Redis Lua脚本把检查、扣减、写入三步合并到一个原子脚本中-- lua_atomic_grab.lua local stock_key KEYS[1] local user_key KEYS[2] local template_status_key KEYS[3] local user_id ARGV[1] local template_id ARGV[2] local user_limit tonumber(ARGV[3]) -- 检查模板状态 local status redis.call(GET, template_status_key) if not status or status ~ ONLINE then return 0 -- 模板不在线 end -- 检查用户是否已领取 local user_has redis.call(SISMEMBER, user_key, user_id) if user_has 1 then return -1 -- 重复领取 end -- 检查并扣减库存 local stock tonumber(redis.call(GET, stock_key) or 0) if stock 0 then return -2 -- 库存不足 end redis.call(DECR, stock_key) redis.call(SADD, user_key, user_id) -- 记录用户领取数量用于限领校验 local user_count_key ucount: .. template_id .. : .. user_id redis.call(INCR, user_count_key) redis.call(EXPIRE, user_count_key, 86400) return 1 -- 领取成功Lua脚本的原子性来自Redis单线程模型执行过程中不会被其他命令插入所以整个逻辑天然串行化不用加锁也不会超发。脚本内三次内存操作加一次SADD整体耗时通常小于2ms单实例Redis可以支撑每秒数万次完整领券流程。这里要补充一个很重要的经验Lua脚本的KEYS和ARGV传递要非常谨慎。我最初把模板状态检查放在脚本外面想着减少脚本复杂度后来发现脚本外面查状态和脚本内扣减不是一个原子操作极端情况下模板已经下线但扣减还在执行导致已经下线的券还能被领取。所有关键校验必须全部放进脚本外面只做参数构造和结果处理。4. 核心链路的完整实现从用户点击到领券成功4.1 发券接口的限流与防刷设计高并发系统如果只追求“快”而忽略“防刷”上线第一天就会被羊毛党打穿。10万QPS的流量里可能有一半是脚本刷的。所以我把防刷设计成了独立的、前置的、贯穿全链路的能力。第一层防刷在网关层基于IP和用户维度的令牌桶限流。每个用户每秒最多请求5次领券接口每个IP每秒最多请求50次。超过的请求直接返回“操作频繁”的提示不会进入后端逻辑。这里的限流实现我用的是Redis的滑动窗口算法记录用户过去一秒内的请求数// 滑动窗口限流器的核心逻辑 func allowRequest(key string, limit int, windowSeconds int) bool { now : time.Now().UnixMilli() windowStart : now - int64(windowSeconds*1000) current, _ : redis.Eval(ctx, local key KEYS[1] local now ARGV[1] local window_start ARGV[2] local limit tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, window_start) local count redis.call(ZCARD, key) if count limit then return 0 end redis.call(ZADD, key, now, now .. : .. math.random(1000000)) redis.call(PEXPIRE, key, 5000) return 1 , []string{key}, now, windowStart, limit) return current.(int64) 1 }滑动窗口比固定窗口的优势在于不会在窗口临界点出现瞬时双倍流量。比如固定窗口每秒限制5次用户在0.9秒到1.1秒之间可以连续请求10次滑动窗口则不允许。压测数据显示滑动窗口限流后的后端流量比固定窗口平滑了40%。第二层防刷在业务逻辑层。除了限流还要校验用户设备指纹、用户行为特征。这里我记录了两个辅助特征用户在抢券前是否有浏览商品的行为用户的黑名单状态。无浏览行为的直接抢券请求会被标记为“机器人”即使通过了限流也会被拒绝发券。黑名单则是根据历史数据积累的识别到有批量注册、批量领券特征的设备号会自动拉黑。第三层防刷在券本身的设计上。每张券模板可以设置每人限领张数、领取总数量上限、领取时间段。这些配置在模板上线前强制校验防止运营人员配错导致超发。其中每人限领数是Redis Lua脚本里校验的防止用户通过多端并发绕过应用层的业务判断。4.2 将发券结果异步化MQ削峰与最终一致就算Redis能单机扛住5万QPSMySQL在峰值期也绝对不能直接承接全部写入流量。用户领券成功需要将券实例持久化到MySQL这个操作如果做成同步的数据库IO会很快成为瓶颈。我的方案是用户看到的“成功”只是Redis扣减成功真正落库通过MQ异步完成。具体流程是Lua脚本扣减成功之后应用将一张“用户领券成功消息”投递到Kafka消息内容包含templateId、userId、couponCode、timestamp。后端的消费者服务以批量方式消费消息攒够100条或者每秒定时批量刷一次一次性执行批量INSERT写入MySQL的券实例表。这里有一个核心问题必须解决消息丢失怎么办。如果用户领券成功但MQ消息丢了用户实际没有拿到券这就是数据不一致。我的方案是双写兜底应用投递MQ成功的同时在Redis中记录一份“待落库的用户券数据”消费者处理完成后再删除这条记录。定期扫描Redis中残留的待落库数据重新投递MQ。这个兜底逻辑确保即使MQ短期故障数据也不会丢失。另一个问题是批量写的速度。MySQL批量INSERT 100条数据在普通配置下耗时约30ms远快于逐条插入。消费者程序采用多分区、多协程并发消费同时控制消费速度不超过数据库写入阈值。初期我遇到过高估数据库能力导致的批量写入积压后来给消费者加了动态限速根据数据库当前IOPS和复制延迟调整消费速率积压问题才真正解决。实操提示投递MQ时一定要给消息设置业务唯一ID消费者做幂等处理。我踩过的坑是Kafka在极端情况下会重复投递消息如果消费者不做幂等就会重复写券实例表。用couponCode作为唯一键做INSERT ... ON DUPLICATE KEY UPDATE可以完美解决。5. 缓存策略与热点优化细节决定成败5.1 缓存穿透、击穿、雪崩的三类问题解法高并发系统里“缓存三大问题”我们团队内部讨论过很多次优惠券这个业务场景又是三类问题都可能遇到的典型。缓存穿透指查询一个不存在的数据缓存里没有数据库里也没有。攻击者如果反复查询不存在的券模板ID请求就会全部打到数据库。我的应对方案有三层第一层在网关校验模板ID的格式合法性非法请求直接拒绝第二层在Redis缓存一个空值TTL设为60秒避免同一ID反复穿透第三层用布隆过滤器预判模板ID是否存在布隆过滤器说没有就直接返回不用查缓存和数据库。缓存击穿指某个热点key过期的一瞬间大量请求同时回源数据库。典型场景是运营提前预告某个时间点开抢所有用户同时刷新券详情这个详情key在开抢前几秒刚好过期回源压力会瞬间拉满DB。我的解法是用“互斥回源加永不过期”双策略热点券的详情数据在Redis里不设过期时间更新通过MQ异步通知进行即使需要过期也用一个分布式锁保证同一时刻只有一个请求回源数据库其他人等待后读缓存。缓存雪崩指大量key同时过期或者整个缓存节点不可用。我处理过期问题时给不同券的缓存设置了不同的过期时间基础TTL是10分钟然后每个券根据模板ID的哈希值增加一个0到300秒的随机偏移让过期时间分散。缓存节点不可用的场景则依赖本地缓存兜底本地缓存即使更新的数据稍旧也能保证服务可用顶多用户看到的信息不是最新的。5.2 热点券的单独优化单券秒杀场景不是所有券都会高并发但一旦有一张“爆款券”它会带来巨大的局部热点压力。我遇到过一张满199减100的券瞬间80%的流量都打在这一个模板ID上Redis集群里这个key对应的分片节点CPU直接红了。针对这种单点热点常规的Redis分片无法解决因为同一个key必然路由到同一个分片。我的处理方式有三个。第一个是为库存key做“多槽位拆分”即把一个券的库存分散到N个子key例如stock:1001:01、stock:1001:02、stock:1001:03扣减时随机选一个子keyLua脚本操作单个子key。这样请求流量被分散到N个Redis实例热点被摊平。第二个是应用层内存预扣减。在流量入口本地缓存会记录热点券的库存水位和水位更新时间。如果本地快照显示还有库存就直接放行去操作Redis如果显示快卖完了就直接返回失败或提示补货不再继续产生Redis请求。这个策略能把Redis的读取压力降低60%以上。第三个是设置独立的限流阈值。热点券和普通券的防刷力度不一样热点券的单位时间全局限额会单独配置避免单个券占用整个系统的处理能力导致其他券无法正常工作。拆槽位要注意一个精度问题库存分散到多个子key后总的剩余库存需要由后台把各子key汇总展示抢券时用户看到“还剩98张”实际上可能是所有槽位剩余的累加。库存回调和退款操作也必须精确定位到具体的子key所以我在券实例表中记录了它来源于哪个槽位。这个细节如果遗漏后续对账时就麻烦了。6. 压测方案与性能调优一切不上压测的性能都是推测6.1 全链路压测的设计与执行这个系统到底能不能扛10万QPS不能靠嘴说必须实际压出来。我设计了三个阶段的压测单接口压测、服务压测、全链路压测。压测工具我用的wrk和自研的Go压测客户端。wrk适合单接口压测简单直接。全链路压测的场景模拟更复杂我写了一个Go程序模拟10万用户同时发起领券请求每个用户带有独立ID、随机的IP段、合理的请求间隔。之所以不用网上现成的JMeter脚本是因为它模拟的用户模型太简单不能真实反映业务特征。单接口压测重点关注三个指标QPS、平均延迟、错误率。压测从100并发开始逐步往上加每次记录性能拐点。比如单机8核16G的Golang服务在1000并发时QPS约1.2万延迟P99为40ms到3000并发QPS约2.5万P99为120ms到5000并发QPS反而下降到2.2万P99延迟涨到300ms。这个拐点说明服务已经进入过载状态需要扩容或者优化。全链路压测则是把Nginx负载均衡、网关、应用层、Redis、MQ、MySQL全部串起来压。这个阶段我第一次跑就发现了一个问题数据库写入的消费者程序成为瓶颈Kafka消费积压每小时涨2万条。排查后发现消费者程序里每条消息都执行一次独立的INSERT改成批量INSERT后积压问题立刻缓解。提示压测数据一定要留档。每次优化前后对比数据能帮你准确判断优化是否有效。我在这三个月里记录了四轮压测的数据后面做容量规划时直接拿出来估算需要几台机器不再靠猜。6.2 调优实战从CPU爆表到P99下降调优过程中最有成就感的一次是解决GC导致的延迟抖动问题。压测时我注意到P99延迟不稳定有时候从100ms突然跳到500ms在CPU监控里可以看到周期性的大幅抖动频率大约每20秒一次。排查后发现是Golang服务里频繁创建临时对象导致的GC压力过大。每次请求都要创建多个上下文对象、错误对象、日志结构体堆内存频繁增长GC随之频繁执行STW时间每次虽然只有几毫秒但高并发下积累起来对延迟影响非常大。我的优化方案是对象池复用和日志异步化。请求处理中的Session、Response结构体都使用sync.Pool复用减少堆分配日志由同步写改为写入环形缓冲队列由专门的协程批量输出避免日志IO在请求路径上阻塞。优化后GC次数从每秒4次降为每秒不到1次P99延迟稳定在120ms以内。另一个调优点在TCP连接管理。早期服务用标准库的net/http每个请求创建一个连接池连接高并发下文件描述符迅速耗尽。改用连接池复用后同样QPS下文件描述符占用减少了70%。并且我调整了内核参数包括tcp_max_syn_backlog和somaxconn避免连接队列溢出导致大量握手超时。还有数据库侧的调优MySQL的innodb_buffer_pool_size从默认128M调整为8Ginnodb_flush_log_at_trx_commit设为2binlog定期清理。这些参数虽然基础但在高并发下影响巨大。我第一次没调这个参数时批量写入5000条券记录耗时超过1秒调优后降到100ms以内。7. 线上故障排查实录三次印象深刻的故障处理7.1 库存超发的最终排查路径上线第二周运维反馈某张券的实际发放数量超过了配置的总量超了83张。虽然超发数量不大但这在资金合规上是大事必须立刻排查。我首先查数据库里的券实例数确实超过了模板配置量83张。这说明问题在下发链路要么Redis扣减时已经超发要么异步落库时重复执行了。查Redis当前库存显示为0与总量匹配不上。下一步排查消息队列我在消费端加了是否重复处理的日志发现同一条领券成功消息被消费了两次。根因是Kafka消费者在处理时发生了重平衡导致一部分消息被重新分配并重新消费。由于券实例表的INSERT没有做幂等控制同一条消息第二次插入时就产生了重复记录。修复方案是双重的第一步给券实例表加了coupon_code唯一索引重复插入直接失败第二步在消费端的代码里用Redis记录已处理的消息ID处理前先查重。这个故障给我的教训是任何异步处理都必须假设消息会重复幂等不是可选项而是必选项。7.2 缓存穿透导致数据库连接池被打爆上线第三天数据库连接池被打满整个服务不可用。排查时发现有一个运营人员在后台反复测试一个不存在的券模板ID同时有恶意脚本在批量扫描所有不存在的ID。每个请求都穿过了缓存直接查询数据库短时间内数据库连接数突破上限正常用户的请求也拿不到连接。处理方式分三步走第一步紧急把数据库连接池上限降下来给正常业务留出空间第二步像前面提到的布隆过滤器方案上线快速拦截不存在的ID第三步对可疑IP来源做全局限流违规IP直接封禁10分钟。这个故障让我意识到即使系统整体设计再好被恶意流量冲击时如果缺少最外层防护依然会全盘崩溃。后来我坚持把防刷设计放在第一优先级而不是当作可选优化项。7.3 热点券把Redis单节点打满有一次大促活动预热页上线后正在发放的“限量秒杀券”太热门这个券模板的Redis key所在的节点CPU达到95%对应分片上的所有命令延迟飙到1秒以上。更严重的是Redis单线程被慢命令阻塞后排队的大量请求在客户端形成雪崩导致应用层不断重试流量进一步放大。紧急恢复手段是把这张热点券的流量直接切到本地缓存允许一段时间内展示“库存已抢完”的假状态让用户停止刷新。随后立即上线了库存多槽位拆分的代码把热点key拆到多个分片。槽位拆分加上预扣减策略后Redis节点CPU从95%降回40%。这次故障最后复盘得到的结论是高并发系统必须为“超热点”预留特殊通道。不能指望一套通用架构解决所有问题热点券的处理逻辑要和普通券分开流量调度也要能识别热点并自动切换策略。8. 监控与告警体系没有监控的高并发就是裸奔8.1 从业务指标到系统指标的全覆盖这套系统上线稳定后我意识到监控和告警是高并发系统的“安全带”。没有监控你只能通过用户投诉感知故障有了监控你能在用户感知之前发现问题并处理。业务监控层面我重点盯这几个指标领券成功率、领取QPS、券模板状态分布、库存消耗速度。领券成功率如果突然从80%降到50%说明可能有异常流量或者代码异常库存消耗速度如果从每秒1000突然变成每秒100可能是入口流量被限流了也可能是服务出现了问题。系统监控层面应用服务的CPU、内存、GC频率、Goroutine数量、TCP连接数、文件描述符、HTTP错误码分布Redis的内存使用率、慢查询数、命中率、节点CPU数据库的连接数、活跃会话、慢查询日志、主从延迟MQ的生产消费速率、积压数量全部接入监控系统。首页放一张“核心指标大盘”一屏能看到所有关键数据。我用的监控方案是Prometheus加GrafanaExporter收集数据Plutus做告警。告警规则写得非常细比如Redis命中率低于90%告警Kafka消费积压超过1万条告警P99延迟超过300ms连续5分钟告警。8.2 告警阈值如何设定才合理告警阈值设定是个艺术设太宽松发现不了问题设太严苛又会被大量误报淹没团队慢慢就忽视告警了。我一开始参考网上文章把CPU告警阈值设为80%结果上线第二天半夜就被CPU 81%的告警吵醒爬起来看发现是后台定时的数据补偿任务在跑CPU短暂升高很正常。后来把CPU告警改为“连续10分钟大于85%”才消除了这类误报。对于QPS类的告警我会用动态基线算法系统自动统计过去7天同一个时间点的QPS均值当当前QPS超过基线的3倍时触发告警。这样既能在大促前流量暴涨时提前告警又不会因为日常波动产生误报。告警渠道上我接入了邮件、短信和企业即时通讯群机器人。即时通讯群是主要通道短信只用于P0级故障核心接口不可用、数据超发EMAIL只记录非紧急通知。分级发送真的很重要如果什么级别的告警都往短信发几次之后团队就会把短信告警静音。9. 项目复盘哪些经验值得复用9.1 从“能用”到“能扛”的思维方式整个项目做下来最大的收获不是学会了Redis Lua之类的具体技术而是思维方式的变化从“功能能跑”转向“异常下能不能扛住”。初期写代码时我关注的是接口能不能返回正确数据。后来心态转变每个环节都多问几个为什么如果Redis挂了怎么办如果数据库慢查询卡死怎么办如果MQ积压怎么办如果同一个用户同时多发请求怎么办。每一个“怎么办”的答案都是一个系统和组件之间的兜底方案。举一个很小的例子应用服务启动时我会主动加载热点券的数据到本地缓存而不是等用户第一次请求时才初始化。这个“缓存预热”的细节在流量高峰时避免了缓存冷启动带来的大量穿透也让服务启动后的前几秒就能满负载工作。9.2 容量评估和成本控制的平衡高并发系统很容易陷入“无脑堆机器”的误区。10万QPS看起来很吓人但如果流量是可以预期的大促峰值更理智的方案是“按峰值预估资源用弹性伸缩应对波动”。我这里给出一个容量估算公式供参考非常简单单机能力乘以机器数要大于预估峰值的1.5倍预留50%的buffer。比如单机支撑2.5万QPS那5台机器就有12.5万QPS的能力已经足够覆盖10万目标。按这个公式我把原本计划申请的20台机器缩减到8台成本节省了六成。同时通过流量调度策略在峰值过后自动缩减到3台机器。这部分我用的是容器平台的能力核心指标大盘里的QPS监控值作为弹性伸缩的输入QPS高于6万自动扩容到8台低于2万持续30分钟自动缩容到3台。这套机制上线后每月的服务器成本从固定的高投入变成了与流量匹配的动态投入。9.3 这套方案的扩展性体现在哪里这套优惠券系统做好之后并没有停留在发券这个单一场。目前团队的另一个项目已经复用了这套架构中的Redis Lua原子操作设计和MQ异步落库方案用于秒杀场景的库存扣减。核心代码几乎不需要修改只需要把优惠券模板换成商品SKU把券实例换成订单号整个秒杀系统就搭起来了。这说明一个合格的架构应该具备业务无关性。Lua脚本扣减库存的核心逻辑不关心你扣的是券还是商品消费者批量写库的逻辑也不关心你写的是券表还是订单表。我当初坚持把库存扣减和用户记录拆分成通用模块就是为了以后能复用。如果后续要扩展积分兑换、抽奖中奖、会员权益发放等场景只需要增加对应的模板类型和消息类型整个框架不需要改变。这也是我对这套方案最满意的地方它不是一套优惠券系统而是一套通用的“限量资源发放系统”。10. 写在最后的经验清单做高并发优惠券系统这三个月我踩过最大的坑是“把高并发想得太高深”总觉得必须有什么独家秘笈。实际上真正有用的是基础功底的扎实程度Redis的底层原理、操作系统的网络调优、数据库的索引和事务机制、消息队列的可靠性设计。这些东西没有一样是冷门知识但绝大多数团队只是“听说过”没有真正在十万QPS的压力下验证过。我给准备做类似系统的开发者几个建议都是基于这次实操的直接体会第一不要一开始就追求完美架构。先用最简单的方式把流程跑通再逐步替换瓶颈组件。我第一阶段用的单体加数据库虽然只能扛2000 QPS但它帮我验证了业务逻辑的正确性后面对比优化时也能明确知道瓶颈在哪里。第二把压测当作开发的一部分不是上线前的形式主义。每个模块做完就压测记录性能基线后面每次改代码、调参数都重新压测用数据说话。我花在压测上的时间占整个项目周期的40%这部分时间花得很值。第三预留兜底方案永远假设组件会挂。Redis会挂、MQ会积压、服务会OOM。每个关键节点的下游都要有降级机制哪怕降级方案只是简单返回一个静态提示也比整个服务崩溃强得多。第四数据和日志是最忠实的伙伴。上线初期一定要全量记录需要的关键日志包括用户ID、请求参数、处理结果、耗时、错误原因。如果日志不完整排查线上故障时就只能靠猜效率会低很多。这套系统目前已经稳定运行了半年经历了两次大促的峰值考验最核心的一次峰值QPS达到11.2万成功率达到99.3%数据库平均负载低于30%。线上没有出现过一次超发数据和后续的对账完全一致。回看整个项目每一个关键技术点都是我在反复的失败和调试中磨出来的这就是高并发系统最有魅力的地方它不会因为你看了几篇架构文章就变简单但只要你愿意一条条啃下来它也不会永远挡在你面前。
返回列表