Do You Even Scale?高并发系统扩展性实战指南
2026/6/6 5:20:57 网站建设 项目流程

1. 项目概述:这不是一句玩笑话,而是一记精准的行业叩问

“Do You Even [Feature] Scale?”——乍看像极了程序员茶水间里一句带点嘲讽的调侃,语气里混着咖啡因和深夜debug后的疲惫。但如果你在系统架构、SaaS产品设计、高并发服务运维或技术决策岗位上干过三年以上,这句话一出口,周围人会下意识停下手里的键盘,抬头对视一眼,然后默契地压低声音开始复盘:我们那个引以为傲的「实时通知推送」模块,真能扛住百万级DAU同时在线触发的事件风暴吗?我们引以为豪的「用户行为画像更新」逻辑,当数据源从MySQL切到Kafka流之后,延迟是否还稳定在200ms以内?我们刚上线的「AI内容审核API」,在促销大促期间QPS翻了7倍时,错误率有没有悄悄爬升到0.8%?——这些都不是假设题,是每天真实发生的压力测试现场。

这句话的核心,从来不是质疑某个功能“能不能用”,而是直指一个更残酷的事实:绝大多数功能在小流量、单机、理想环境下的“可用”,与在真实业务规模、复杂依赖、突发峰值下的“可靠可扩展”,之间存在一道被严重低估的鸿沟。它不关心你用了多酷的框架、多新的算法、多漂亮的UI,只冷冷地问:当量级翻十倍、百倍、千倍时,你的[Feature]是优雅地横向铺开,还是像被踩扁的易拉罐一样发出刺耳的金属变形声?它逼你把“扩展性”从PPT里的一个模糊术语,变成每个接口定义、每行SQL、每次缓存读写、每个线程池配置背后必须回答的硬问题。

我见过太多团队,在MVP阶段靠单体应用+Redis+定时任务快速跑通闭环,用户从0涨到5万时一切丝滑;可当用户冲到50万,订单创建耗时从200ms飙到3秒,客服电话被打爆,老板在会议室拍桌子问“为什么不能像竞品那样稳”,技术负责人却卡在“不知道瓶颈在哪”的窘境里。问题从来不在“有没有做”,而在于“有没有在做的每一步,都带着‘它将来要撑多少’这个念头去设计”。所以,“Do You Even [Feature] Scale?”不是一句修辞,它是一套倒逼工程思维升级的检查清单,是把“可扩展性”从验收标准,前置为设计约束的实战方法论。这篇文章,就是为你拆解这套方法论——不讲抽象理论,只讲我在电商中台、金融风控、内容推荐三个不同领域里,亲手踩过坑、填过坑、最终沉淀下来的实操路径。无论你现在负责的是一个登录按钮、一套审批流,还是一整套微服务网格,只要你希望自己的代码在未来半年、一年、三年后,依然能让人放心地加机器、扛流量、接新需求,那接下来的内容,就是你真正需要的“扩展性生存指南”。

2. 内容整体设计与思路拆解:从“能跑”到“能扛”的四层穿透式验证

很多人误以为“做扩展性”就是等系统出问题了再加机器、换数据库、上消息队列。这是典型的“救火式思维”,代价极高——不仅修复成本是预防的5倍以上,更致命的是,它让你永远在追赶业务增长的脚步,技术债越滚越大,团队陷入“上线即维护、维护即加班”的恶性循环。真正的扩展性设计,必须是一套贯穿需求、设计、开发、测试全生命周期的“穿透式验证”体系。我把它拆解为四个不可跳过的层次,每一层都像一次X光扫描,层层深入,确保你的[Feature]不是纸糊的城堡,而是钢筋混凝土的堡垒。

2.1 第一层:语义层穿透——先让需求自己“暴露”扩展性风险

很多扩展性问题,根源其实在PRD(产品需求文档)里就埋下了雷。比如产品经理写:“用户上传图片后,系统需在5秒内生成高清缩略图并返回URL。” 这句话听起来很合理,但它隐含了一个致命假设:所有图片都是2MB以内、分辨率不超过2000x2000的JPG。一旦有用户上传100MB的RAW格式照片,或者批量上传50张,整个缩略图服务就会瞬间雪崩。所以,第一道关卡,是强制对每一个功能描述进行“语义解构”。

我的做法是,在需求评审会上,拿着这句话直接问三个问题:

  1. “谁”在“什么场景”下会触发这个功能?(例如:是普通用户日常上传,还是运营人员在大促前批量导入商品图?)
  2. “多少”量级会同时触发?(例如:是单次1张,还是单次最多支持100张?峰值时段每分钟预计多少次调用?)
  3. “多大”的输入/输出会出现在极端情况?(例如:图片最大支持多大?网络超时设定多少?失败后重试几次?)

这三个问题的答案,会立刻把模糊的“5秒内”转化成可测量的SLA(服务等级协议):比如“99%的请求在5秒内完成,P99延迟≤4.2秒,支持单次100张、单张≤50MB的PNG/JPG/WebP格式,峰值QPS≥1200”。没有这一步,后续所有技术方案都是空中楼阁。我曾在一个直播打赏功能的需求评审中,通过追问“单场直播最高同时在线人数预估?”、“打赏峰值集中在开播后前5分钟?”、“单用户最高可能连续点击多少次?”,提前识别出“实时打赏金额聚合”模块必须从内存计数器升级为Redis原子操作+异步落库,避免了上线后因内存溢出导致的整个直播间金额显示错乱。

2.2 第二层:架构层穿透——用“分而治之”对抗规模诅咒

当需求明确了量级,下一步就是选择能承载它的骨架。这里没有银弹,只有基于场景的理性权衡。我总结了三类最常踩坑的架构选择误区,并给出对应的真实替代方案:

  • 误区一:“单体够用,何必微服务?”
    适用场景:团队<5人,功能耦合度高(如内部OA系统),月活<10万。
    风险点:当核心功能(如报销审批)因流程变更需频繁迭代时,整个单体应用必须全量发布,牵一发而动全身。
    我的解法:在单体内部实施“逻辑微服务化”。用Spring Boot的@Profile或Go的package隔离不同业务域,强制定义清晰的内部API契约(如IExpenseService.Process()),并通过单元测试保证各模块独立可测。这样,当某天真的需要拆分时,只需将对应package打包成独立服务,契约不变,迁移成本极低。我们曾用此法,将一个年营收千万的财税SaaS单体应用,在6个月内平滑拆分为5个核心服务,零线上故障。

  • 误区二:“消息队列万能,所有异步都塞进去!”
    适用场景:解耦强依赖、削峰填谷、最终一致性要求高的场景(如订单创建后发优惠券)。
    风险点:过度使用导致链路过长、追踪困难、消息堆积后消费延迟飙升。
    我的解法:“三色消息”分级治理。

    • 红色消息(强实时):用户关键操作反馈(如支付成功页跳转),必须同步处理,禁用MQ;
    • 黄色消息(弱实时):通知类(如站内信)、统计类(如UV计算),走Kafka,设置合理分区数与消费者组;
    • 蓝色消息(离线批处理):报表生成、模型训练数据准备,走Spark Streaming或Flink,与在线链路物理隔离。
      关键参数:Kafka Topic分区数 = 预估峰值QPS / 单分区吞吐(实测约3000 QPS/分区),消费者组实例数 = 分区数 * 1.2(预留扩容余量)。
  • 误区三:“缓存就是Redis,一把梭哈!”
    适用场景:高频读、低频写、数据一致性要求非强一致的场景(如商品详情页)。
    风险点:缓存击穿(热点Key失效)、缓存雪崩(大量Key同时过期)、缓存穿透(查不存在的ID)。
    我的解法:“三级缓存”防御体系。

    • L1:本地缓存(Caffeine),存储热点且变化极少的数据(如配置项、城市列表),TTL设为10分钟,避免远程调用;
    • L2:分布式缓存(Redis Cluster),存储核心业务数据(如商品信息),采用“逻辑过期+后台刷新”策略:Key永不过期,Value内嵌expireTime字段,读取时若过期则异步刷新,避免击穿;
    • L3:数据库兜底(MySQL读库),所有缓存未命中请求,必须走DB,但需加@Cacheable(sync=true)防止缓存穿透(同一Key的并发请求只放行1个查DB,其余等待结果)。
      实测效果:在日均5亿PV的商品详情页,缓存命中率从92%提升至99.7%,DB QPS下降83%。

2.3 第三层:实现层穿透——代码里的“扩展性基因”

架构选对了,代码写歪了,照样完蛋。我见过太多工程师,把“高并发”理解为“多开几个线程”,结果线程池无界增长,OOM直接宕机。真正的扩展性,藏在每一行代码的细节里。以下是我在多个项目中反复验证、必须写进Code Review Checklist的五条铁律:

  1. 永远不要信任外部输入的“大小”。
    所有HTTP请求参数、文件上传、第三方API返回值,必须在入口处做严格校验。例如,接收JSON数组,必须限制maxItems=100;接收Base64图片,必须在解码前校验字符串长度< 10MB * 1.33(Base64膨胀系数)。我曾因未校验一个tags[]数组,导致恶意用户传入10万个标签,后端JSON解析直接吃光2GB内存。

  2. 数据库访问必须“懒加载+分页”。
    禁止SELECT * FROM orders WHERE user_id = ?这种写法。正确姿势:SELECT id, status, amount FROM orders WHERE user_id = ? AND create_time > ? ORDER BY id DESC LIMIT 50 OFFSET 0。OFFSET在大数据量下性能极差,必须改用“游标分页”:WHERE id < ? ORDER BY id DESC LIMIT 50,用上一页最后一条记录的ID作为下一页的游标。

  3. 循环内禁止远程调用。
    for (Order order : orders) { callPaymentService(order); }是性能杀手。必须批量聚合:paymentService.batchProcess(orders),后端用IN语句或批量插入。我们曾将一个订单结算循环从12秒优化至350毫秒。

  4. 日志必须分级、限流、脱敏。
    ERROR日志记录全栈,WARN记录关键参数(如order_id=xxx, amount=123.45),INFO级禁止打印敏感信息(手机号、身份证号)。更重要的是,对高频日志(如“用户登录失败”)加RateLimiter,避免日志刷爆磁盘。我们用Guava RateLimiter,设置100 permits/sec,超出的日志直接丢弃。

  5. 资源必须显式释放。
    所有InputStream,Connection,ResultSet,HttpClient,必须用try-with-resourcesfinally块确保关闭。我曾因一个未关闭的ZipInputStream,导致文件句柄耗尽,整个服务无法读取任何新文件。

2.4 第四层:验证层穿透——用“制造灾难”来证明可靠

写完代码,跑通单元测试,只是万里长征第一步。真正的考验,在于你敢不敢主动“搞破坏”。我坚持在每个重要功能上线前,执行一套标准化的“灾难演练”(Chaos Engineering Lite),它不追求Netflix那种复杂度,但足够暴露真实弱点:

  • Step 1:基础压测(必做)
    用JMeter或k6,模拟2倍预估峰值QPS,持续10分钟。监控指标:

    • 应用CPU ≤ 75%,GC Young GC ≤ 5次/秒,Full GC = 0;
    • 数据库CPU ≤ 60%,慢查询数 = 0,连接池使用率 ≤ 80%;
    • Redis内存使用率 ≤ 70%,evicted_keys= 0,rejected_connections= 0。
      任一指标超标,立即停止,回溯代码。
  • Step 2:依赖故障注入(推荐)
    使用Resilience4j或Sentinel,在测试环境模拟下游服务超时(RT=5秒)、熔断(错误率>50%)、降级(返回Mock数据)。观察本服务是否:

    • 能快速失败(Fail Fast),不阻塞主线程;
    • 降级逻辑正确(如支付失败时,自动切换为“货到付款”选项);
    • 熔断后能自动恢复(Half-Open状态探测成功)。
      我们曾在此环节发现,一个未配置fallback的Feign Client,在依赖超时后会无限重试,拖垮整个线程池。
  • Step 3:数据倾斜攻击(高阶)
    构造极端数据:让90%的请求都打向同一个Key(如user_id=1000000001),或让90%的SQL都命中同一个分表(如shard_id=0)。验证:

    • 缓存层是否出现热点Key,导致单节点CPU 100%;
    • 数据库分片是否均衡,shard_id=0的QPS是否远超其他分片;
    • 是否触发了自动扩缩容(如K8s HPA)?响应时间是否可控?
      这一步,往往能揪出那些被“平均值”掩盖的致命瓶颈。

这四层穿透,不是线性流程,而是螺旋上升的闭环。每一次需求变更、每一次技术选型、每一行代码提交、每一次压测失败,都要回到这四层重新审视。它逼你放弃“差不多就行”的侥幸,建立一种肌肉记忆式的工程敬畏——因为你知道,线上那个沉默运行的[Feature],下一秒就可能面对百万用户的集体叩问:“Do You Even Scale?”

3. 核心细节解析与实操要点:从“知道”到“做到”的关键参数与技巧

光有框架还不够,真正的功夫在细节。我把过去十年在不同规模系统中沉淀下来的、那些写在内部Wiki里、口口相传的“核弹级”参数与技巧,毫无保留地拆解出来。这些不是教科书里的理论最优解,而是我在凌晨三点盯着Grafana面板、反复调整、实测对比后,确认有效的“生存参数”。

3.1 数据库连接池:别再迷信HikariCP的默认值

HikariCP号称“最快连接池”,但它的默认配置(maximumPoolSize=10)是为单机演示设计的,放到生产环境就是定时炸弹。连接池大小不是拍脑袋定的,它必须满足一个黄金公式:
maximumPoolSize ≈ (2 × CPU核心数) + 磁盘数
这个公式的物理意义是:每个连接在等待IO(磁盘读写、网络往返)时,CPU可以切换去处理其他连接,因此连接数应略高于CPU并发能力。以一台16核、SSD磁盘的数据库服务器为例,理论最大连接数≈32+1=33。但实际中,我们必须给数据库自身留出余量(MySQL默认max_connections=151,其中至少30个要留给DBA、监控、备份),所以应用端连接池应设为25~30

提示:绝对禁止设置maximumPoolSize=Integer.MAX_VALUE!这会导致连接数失控,瞬间打满DB连接数,引发雪崩。我们曾因一个配置错误,让20个应用实例各自开100个连接,直接占满MySQL的151个连接,所有业务请求全部超时。

更关键的是connectionTimeout(获取连接超时)和validationTimeout(连接有效性验证超时)。很多团队设为30秒,这是灾难性的。正确值应为:

  • connectionTimeout = 3000(3秒):如果3秒内拿不到连接,说明连接池已枯竭,应快速失败,由上游重试或降级,而不是让请求排队等待;
  • validationTimeout = 3000(3秒):每次从连接池取出连接前,执行SELECT 1验证,3秒内没响应就丢弃该连接,避免脏连接污染。

实操心得:在K8s环境中,务必开启leakDetectionThreshold=60000(60秒)。它会在连接被借用超过60秒未归还时,自动打印堆栈,精准定位“连接未关闭”的代码位置。我们靠它揪出了一个隐藏三年的DAO层Bug——某个异常分支里漏写了conn.close()

3.2 Redis缓存策略:如何让“热点Key”不再成为单点故障

“缓存击穿”是高频面试题,但真实世界里,更可怕的是“缓存穿透”和“缓存雪崩”的组合拳。我们的解决方案,是一个经过双11实战检验的“三明治”结构:

  1. 外层:布隆过滤器(Bloom Filter)拦截无效请求
    在接入层(Nginx/OpenResty)或网关(Spring Cloud Gateway)部署布隆过滤器,预先加载所有合法user_idproduct_id的Hash值。当请求/api/user/123456789时,先查布隆过滤器:

    • 若返回false(大概率不存在),直接返回404,绝不穿透到后端;
    • 若返回true(可能存在),再走正常缓存/DB流程。
      布隆过滤器的误判率(False Positive Rate)可控制在0.1%,内存占用仅几MB。我们用RedisBloom模块,bf.reserve users 0.001 10000000(1000万用户,0.1%误判率)。
  2. 中层:逻辑过期+互斥锁(Mutex Lock)防击穿
    缓存Value结构为JSON:{"data": {...}, "expireTime": 1717023456}。读取时:

    String json = redis.get(key); if (json == null) { // 缓存未命中,尝试加锁 String lockKey = "lock:" + key; if (redis.set(lockKey, "1", "NX", "EX", 3)) { // 加锁,3秒过期 try { // 双检:再次查缓存 json = redis.get(key); if (json == null) { // 真实加载DB Object data = loadFromDB(key); // 写入缓存,永不过期 redis.setex(key, 0, buildJson(data, System.currentTimeMillis() + 300000)); } } finally { redis.del(lockKey); // 必须释放锁 } } else { // 加锁失败,短暂休眠后重试(避免自旋) Thread.sleep(50); return getWithRetry(key, retryCount - 1); } } // 解析json,检查expireTime long expireTime = parseExpireTime(json); if (System.currentTimeMillis() > expireTime) { // 逻辑过期,异步刷新 asyncRefresh(key); } return parseData(json);
  3. 内层:本地缓存兜底(Caffeine)
    对于超高频、极小数据(如开关配置、地区字典),在应用JVM内加一层Caffeine缓存,maximumSize=1000, expireAfterWrite=10, refreshAfterWrite=5。它能在Redis集群抖动时,提供毫秒级的本地响应,避免全链路雪崩。

注意:布隆过滤器的Key必须与业务主键严格一致,且更新机制要可靠。我们采用“变更写DB -> Binlog监听 -> 异步更新布隆过滤器”的最终一致性方案,延迟控制在200ms内。

3.3 消息队列:Kafka分区与消费者组的“血泪平衡术”

Kafka的吞吐量神话,建立在“分区(Partition)”这个基石之上。但分区数不是越多越好,它是一把双刃剑:

  • 好处:分区是Kafka并行度的基本单位。一个Topic有N个分区,就能支持N个消费者并发读取,理论上吞吐量线性提升。
  • 坏处:分区数过多,会导致ZooKeeper/KRaft元数据压力剧增;每个分区对应一个文件夹,大量小文件拖慢磁盘IO;消费者组Rebalance时间随分区数指数级增长(100分区Rebalance约5秒,1000分区可能长达30秒)。

我的黄金法则:分区数 = max(预估峰值QPS / 单分区吞吐, 3 × 副本数)。单分区吞吐实测值:

  • 普通SSD:2000 ~ 3000 QPS;
  • NVMe SSD:5000 ~ 8000 QPS;
  • 网络带宽成为瓶颈时(如10G网卡),按带宽 / 平均消息大小计算(如10Gbps / 1KB = 1.25M QPS,但实际受制于磁盘和CPU,通常取1/3)。

举个真实案例:一个订单履约Topic,预估峰值QPS=15000,用NVMe盘,单分区吞吐取6000,则理论分区数=15000/6000≈2.5→向上取整为3。但为了冗余和未来扩容,我们设为6(3×副本数2)。上线后,监控显示单分区QPS稳定在2500左右,完全在安全区间。

消费者组(Consumer Group)的实例数,同样有讲究。最佳实践是:消费者实例数 ≤ 分区数。如果实例数大于分区数,多余的实例将处于“空闲”状态,白白消耗资源。我们曾因盲目扩消费者,导致12个实例争抢6个分区,Rebalance频繁,消息延迟飙升。调整为6个实例后,延迟从秒级降至毫秒级。

更隐蔽的坑是“消息顺序性”。Kafka只保证“单个分区内的消息有序”,不保证全局有序。如果你的业务强依赖全局顺序(如“下单->支付->发货”必须严格串行),唯一的解法是:用业务主键(如order_id)做消息Key,确保同订单的所有消息路由到同一分区。Kafka Producer的DefaultPartitioner会自动做hash(key) % numPartitions,完美解决。

3.4 JVM调优:G1垃圾回收器的“三板斧”实战参数

Java应用的扩展性,一半在架构,一半在JVM。G1(Garbage First)是目前最主流的GC算法,但它的默认参数(-XX:+UseG1GC)绝不能直接上生产。我总结了三个必须调整的“保命参数”:

  1. -Xms-Xmx必须相等
    例如-Xms4g -Xmx4g。理由:避免JVM在运行时动态扩容堆内存,这个过程会触发Full GC,造成STW(Stop-The-World)停顿。我们曾因-Xms2g -Xmx4g,在堆从2G涨到4G的过程中,触发了一次长达8秒的Full GC,导致所有请求超时。

  2. -XX:MaxGCPauseMillis设为200~300ms
    这是G1的“软目标”,它会尽力让GC停顿不超过此值。设得太低(如100ms),G1会频繁Minor GC,吞吐量暴跌;设得太高(如500ms),用户体验变差。我们线上服务统一设为250,实测P99 GC停顿稳定在220ms内。

  3. -XX:G1HeapRegionSize设为2MB
    G1将堆划分为固定大小的Region,默认值根据堆大小动态计算(堆<4G时为1MB,>4G时为2MB)。但手动指定为2MB,能显著减少Region数量,降低元数据开销。对于4G堆,Region数从4096个减至2048个,GC效率提升15%。

实操心得:务必开启GC日志,但别用-verbose:gc这种老古董。正确姿势:
-Xlog:gc*:file=/var/log/app/gc.log:time,tags:filecount=5,filesize=100M
然后用gceasy.io在线分析,它能直观告诉你:是Young GC太频繁(说明Eden区太小),还是Mixed GC占比过高(说明老年代对象晋升太快),或是Humongous Allocation(大对象直接进老年代)导致碎片化。我们曾靠它发现一个byte[10MB]的临时对象,将其改为ByteBuffer.allocateDirect()后,Full GC消失。

4. 实操过程与核心环节实现:一个电商“秒杀库存扣减”功能的全链路扩展性落地

纸上谈兵终觉浅,下面我以一个真实项目——“618大促秒杀库存扣减”功能为例,手把手带你走一遍从需求到上线的全链路扩展性落地过程。这个功能看似简单:“用户点击秒杀,库存-1,成功则生成订单”,但正是这种“简单”,最容易在流量洪峰下原形毕露。

4.1 需求解构与SLA定义:把“快”变成可测量的数字

产品经理的原始需求:“秒杀页面,用户点击‘立即抢购’,3秒内返回成功或失败。”

我们立刻启动语义穿透:

  • 谁在什么场景下触发?
    100万用户同时进入秒杀页面,其中50万人在开抢瞬间(T0)点击按钮,预计峰值QPS=50000(50万/10秒)。
  • 多少量级同时触发?
    单次请求,但50000个请求在100ms窗口内到达。
  • 多大的输入?
    请求体极小({ "sku_id": "123456", "user_id": "789012" }),但需校验sku_id合法性、user_id有效性、用户是否已参与过本场秒杀。

由此,我们定义SLA:

  • P99响应时间 ≤ 800ms(比“3秒”更严苛,为网络抖动留余量);
  • 成功率 ≥ 99.99%(允许万分之一的失败,用于熔断保护);
  • 库存扣减精度 = 100%(绝不允许超卖,宁可少卖,不可多卖)。

4.2 架构设计:五层防护网的构建

基于SLA,我们设计了如下五层防护架构,每一层都承担明确的“泄洪”职责:

层级组件核心职责关键参数
L1:接入层限流Nginx + Lua全局QPS限流,拒绝恶意刷量limit_req zone=seckill burst=10000 nodelay(10000请求缓冲,不延迟)
L2:网关层校验Spring Cloud Gateway校验Token、IP黑白名单、用户资格(是否封禁)白名单IP直通,黑名单IP 403,Token过期500
L3:缓存预热层Redis Cluster预热秒杀商品库存(seckill:sku:123456:stock=10000),用DECR原子操作扣减Key TTL=2小时,DECR返回值<0则库存售罄
L4:库存扣减层自研库存服务(Go)接收Redis扣减结果,若>0则生成订单,否则返回失败同步调用,超时300ms,失败自动降级为“排队中”
L5:异步落库层Kafka + Flink订单数据异步写入MySQL,Flink实时计算库存快照,反哺RedisTopic分区数=12,Flink Checkpoint间隔=30秒

注意:L3和L4的分离是关键。Redis只做“库存数字”的原子扣减,不涉及任何业务逻辑;真正的订单创建、风控校验、优惠计算,全部交给L4的库存服务。这保证了Redis的极致轻量,也避免了在Redis里写Lua脚本引入复杂性。

4.3 核心代码实现:Redis原子扣减与订单生成的“零误差”保障

L3的Redis扣减,是整个链路的咽喉。我们采用EVAL执行Lua脚本,确保“读-判-扣”三步原子性:

-- lua脚本:seckill_stock.lua local sku_key = KEYS[1] -- e.g., "seckill:sku:123456:stock" local user_key = KEYS[2] -- e.g., "seckill:user:789012:sku:123456" local stock = tonumber(ARGV[1]) -- 库存阈值,如10000 -- 1. 检查用户是否已参与(防重复秒杀) if redis.call("EXISTS", user_key) == 1 then return {0, "already_participated"} -- 0表示失败 end -- 2. 原子扣减库存 local current_stock = redis.call("GET", sku_key) if not current_stock or tonumber(current_stock) <= 0 then return {0, "out_of_stock"} end -- 3. 扣减并检查结果 local new_stock = redis.call("DECR", sku_key) if new_stock < 0 then -- 扣减后为负,说明超卖,回滚 redis.call("INCR", sku_key) return {0, "concurrent_error"} end -- 4. 标记用户已参与(设置短过期,如1小时) redis.call("SET", user_key, "1", "EX", 3600) -- 5. 返回成功及剩余库存 return {1, new_stock}

调用方式(Java):

String script = Files.readString(Paths.get("seckill_stock.lua")); List<String> keys = Arrays.asList("seckill:sku:123456:stock", "seckill:user:789012:sku:123456"); List<String> args = Arrays.asList("10000"); // 阈值 Object result = redisTemplate.execute( new DefaultRedisScript<>(script, List.class), keys, args ); // result = [1, 9999] 或 [0, "out_of_stock"]

L4的订单生成,必须保证“幂等性”。我们采用“唯一业务ID”+“数据库唯一索引”双重保险:

  • 订单ID =seckill_+sku_id+user_id+timestamp(如seckill_123456_789012_1717023456);
  • MySQL订单表建唯一索引:UNIQUE KEY uk_sku_user (sku_id, user_id)
  • 插入订单前,先INSERT IGNORE,若影响行数=0,说明已存在,直接返回成功。

4.4 压测与调优:从“崩溃”到“稳如泰山”的七次迭代

我们用k6进行全链路压测,初始配置:k6 run -u 50000 -d 10m script.js(5万并发,10分钟)。第一次结果惨不忍睹:

  • P99响应时间:12.4秒;
  • 成功率:63.2%;
  • Redis CPU:100%;
  • MySQL慢查询:237条/分钟。

迭代1:L1限流未生效
原因:Nginx配置的burst缓冲区太小,瞬间流量打满。
解法burst=50000 nodelay,并增加Nginx Worker进程数至CPU核心数。

迭代2:Redis连接池打满
原因:应用端HikariCPmaximumPoolSize=20,50000并发下连接池瞬间枯竭。
解法:按公式计算,maximumPoolSize=30,并开启leakDetectionThreshold

迭代3:Lua脚本阻塞
原因:DECR操作在高并发下竞争激烈,部分请求在Redis队列中等待超时。
解法:将Lua脚本中的DECR替换为INCRBY,并传入负数(INCRBY sku_key -1),性能提升40%。

迭代4:MySQL唯一索引冲突
原因:高并发下,两个请求几乎同时通过Redis扣减,都拿到new_stock=1,然后都尝试插入订单,第二个因唯一索引失败而报错。
解法:在订单服务中,捕获DuplicateKeyException,视为“成功”,直接返回。

迭代5:Flink背压
原因:Kafka消息积压,Flink消费不过来,导致库存快照延迟。
解法

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询