application.yml 核心调优配置

乐观锁玩家
2026-06-18 02:18
阅读 492

我在医疗SaaS项目里死磕高并发的那些日子

早上8点,成都的早晨总是带着点慢悠悠的烟火气,楼下的锅盔夹凉粉刚出摊,我已经坐在工位上泡好咖啡了。作为公司里为数不多的Python开发,我平时的主要阵地是搞搞医疗数据分析后台、写写AI辅助诊断的小工具。不瞒大家说,最近我晚上回家都在疯狂啃LangChain和RAG(检索增强生成),满脑子都是怎么把大模型落地到电子病历结构化里去。

但今天这篇博客,咱们不聊AI,也不聊Python。讲真,作为一个在医疗软件公司摸爬滚打的程序员,你永远不知道领导会给你派什么活。上周五,我们公司的核心产品——互联网医院SaaS平台,在准备上线“知名专家早高峰秒杀挂号”功能时,压测直接翻车了。因为核心交易链路是Java团队用Springboot写的,而我们Python组最近刚好在推行全栈架构评审,领导大手一挥,把我这个“早起型选手”也拉进了高并发优化突击队。

既然接了活,就得干漂亮。今天就来复盘一下,我们是怎么把这个随时会爆炸的“定时炸弹”拆掉,让系统稳如老狗的。

那个让人头皮发麻的早高峰放号

先交代一下背景。我们这款互联网医院SaaS产品,对接了西南片区几十家三甲医院的HIS系统。平时系统的QPS也就几十,大家慢悠悠地看看医生介绍、查查报告。但产品总监老李,不知道从哪学来的互联网电商玩法,非要搞个“每周三早8点,名医号源限量秒杀”的活动,用来拉新促活。

理想很丰满,现实很骨感。上周五下午,测试妹子小王拿着压测报告直接冲进我们开发区:“各位大佬,QPS刚推到1500,Springboot服务直接报OOM了,数据库CPU飙到100%,接口响应时间平均3秒起步,这还玩个锤子?”

我当时正看着AI大模型的论文,听到这话赶紧凑过去看监控。好家伙,链路追踪里一片飘红。拉出核心挂号接口的代码一看,我差点没忍住笑出声。这帮Java兄弟(没有恶意,纯技术探讨)居然在同步接口里做了这些事:

  1. 请求进来,先查Redis看医生排班。
  2. 如果Redis没有,直接穿透去查MySQL。
  3. 查出来之后,在内存里计算剩余号源。
  4. 判断有号,直接开启数据库事务,插入挂号订单,同时扣减HIS系统同步过来的号源表。

这简直是高并发教科书里的“反面典型”啊!大量请求阻塞在数据库事务和HIS系统的外部接口调用上,Tomcat线程池瞬间被耗尽,不OOM才怪。

理论落地:高并发三板斧的“魔改”

高并发设计的理论大家都能倒背如流:缓存、异步、削峰。但在医疗这种对数据准确性要求极高的行业,生搬硬套是要出大事故的。你想想,要是超卖了号源,患者大老远跑来医院挂不上号,那是会直接投诉到卫健委的。

我们几个核心开发关在会议室里白板画了两个小时,定下了改造方案。

第一板斧:把缓存用到极致,坚决不让请求打到DB

排班信息和号源库存这种读多写少的数据,必须全部前置到Redis。我们做了一个定时任务,每天凌晨2点把第二天所有专家的排班和号源总数预热到Redis里。

但这里有个核心痛点:怎么保证扣减库存时不超卖? 如果用传统的get判断数量,再decr,在并发下绝对会超卖。这时候,Redis的Lua脚本就派上用场了。我把最近学AI时养成的一种“原子化思维”用到了这里,把校验和扣减封装在一个Lua脚本里,利用Redis单线程执行Lua的原子性,完美解决超卖问题。

-- 扣减号源库存 Lua 脚本
-- KEYS[1]: 号源库存key, 例如 'schedule:stock:10086'
-- ARGV[1]: 扣减数量, 通常为 1

local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil then
    return -1 -- 库存key不存在
end

if stock < tonumber(ARGV[1]) then
    return 0 -- 库存不足
end

-- 原子扣减
redis.call('decrby', KEYS[1], ARGV[1])
return 1 -- 扣减成功

这段脚本看起来简单,但在生产环境跑的时候,运维老哥提醒了我一句:“如果Redis主从切换,极小概率会丢数据导致超卖。” 医疗系统虽然允许极少量的最终一致性延迟,但超卖是红线。所以我们后来在数据库层面加了一道基于乐观锁的最终防线,双保险。

第二板斧:异步削峰,让数据库喘口气

库存扣减成功后,真正的重头戏是生成挂号订单和调用HIS系统锁号。HIS系统的接口响应时间通常在200ms-500ms之间,这在秒杀场景下是致命的。

我们引入了RocketMQ。前端发起挂号请求,后端校验通过并扣减Redis库存后,立刻将挂号消息丢进MQ,然后直接给前端返回“排队中”。前端通过轮询或者WebSocket获取最终结果。

后端消费者拉取消息,慢慢去写MySQL和调用HIS接口。这里有个巨坑:消息重复消费。网络抖动导致MQ重复投递是常态,如果患者被重复扣费或者生成两个订单,那乐子就大了。

我们在数据库的挂号订单表上,加了 (patient_id, schedule_id, visit_date) 的唯一联合索引。同时在代码里利用Redis的SETNX做分布式锁防重。

// Springboot 消费端幂等性处理核心逻辑片段
public void consumeMessage(G挂号Message msg) {
    String lockKey = "lock:reg:" + msg.getPatientId() + ":" + msg.getScheduleId();
    boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
    
    if (!locked) {
        log.warn("重复消息,直接丢弃: {}", msg.getMsgId());
        return;
    }

    try {
        // 1. 调用HIS系统锁号 (耗时操作)
        hisService.lockNumber(msg);
        // 2. 本地数据库插入订单 (依赖唯一索引兜底)
        orderMapper.insert(convertToOrder(msg));
        // 3. 扣费逻辑...
    } catch (DuplicateKeyException e) {
        log.info("数据库唯一索引拦截重复订单: {}", msg.getPatientId());
    } catch (Exception e) {
        // 释放锁,让消息重试
        redisTemplate.delete(lockKey);
        throw new RuntimeException("消费失败,等待重试", e);
    } finally {
        // 正常消费完,延迟释放锁,防止HIS接口慢导致锁提前释放
        // 实际生产中这里用了看门狗机制自动续期
    }
}

第三板斧:Springboot与数据库的底层调优

代码逻辑理顺了,还得榨干机器的性能。我虽然平时写Python的FastAPI,但Java的调优也是相通的。

我们重新配置了Springboot的Tomcat线程池和HikariCP数据库连接池。之前默认配置在突发流量下太保守了。

server:
  tomcat:
    threads:
      max: 800       # 最大工作线程数,根据CPU核数调整,IO密集型可适当调大
      min-spare: 100 # 最小空闲线程
    accept-count: 2000 # 等待队列长度
    max-connections: 10000

spring:
  datasource:
    hikari:
      maximum-pool-size: 50  # 数据库连接池大小,千万别设太大,MySQL扛不住
      minimum-idle: 10
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000

这里分享个血泪教训:一开始有个兄弟把HikariCP的maximum-pool-size设成了200,结果压测时MySQL的连接数直接爆了,反而导致吞吐量下降。记住,数据库连接池不是越大越好,通常 CPU核数 * 2 + 磁盘有效数 是个不错的经验值。

生产环境的“保命”运维经验

代码改完,压测QPS稳稳站在了5000以上,99线响应时间降到了50ms以内。但上线那天,我们依然如履薄冰。在医疗行业,线上事故就是医疗事故,容不得半点马虎。

我们做了几件“保命”的事:

  1. 全链路限流降级:引入了Sentinel。在网关层对挂号接口做了QPS限流,超过6000的直接返回“前方拥挤,请稍后再试”。同时,对HIS系统的调用配置了熔断降级,如果HIS系统挂了,不能把我们自己的互联网医院拖死,直接降级为“仅展示,暂停挂号”。
  2. 监控报警拉满:Prometheus + Grafana是标配。我们专门配了一个大屏,盯着JVM的GC频率、MQ的堆积量、数据库的慢SQL。我还写了个Python脚本,结合最近学的AI大模型API,把关键的错误日志扔给大模型做初步的根因分析,然后推送到钉钉群里。这招意外的好用,运维老哥直呼内行。
  3. 数据对账:每天凌晨跑对账脚本,核对Redis库存、MySQL订单和HIS系统实际锁号的数量。医疗无小事,账必须平。

踩坑与反思

回过头来看这次高并发改造,其实踩了不少坑,也学到了很多。

最大的感触是,高并发设计从来没有银弹,只有权衡。在电商场景,超卖个几单可能也就是赔点优惠券;但在医疗场景,一个号源背后是一个患者的就医期望,一个订单背后是真金白银的医保或自费扣款。所以在设计时,我们宁愿牺牲一点极致的性能(比如加了分布式锁和双重校验),也要保证数据的绝对正确。

另外,跨语言团队的协作也是个学问。我作为Python开发去指导Java团队,一开始大家心里多少有点犯嘀咕。但我没去教他们怎么写Java,而是从系统架构、数据流向、甚至用我最近学的AI视角去帮他们梳理业务边界。比如,我用大模型帮他们生成了几百种边缘场景的压测用例,这直接折服了测试小王。技术人之间,用技术实力和解决问题的态度说话,比什么职级都管用。

现在,每周三早8点的秒杀活动已经平稳运行了两个月。每次看着监控大屏上那条平滑的吞吐量曲线,早上8点喝进嘴里的那口成都盖碗茶,都觉得格外香甜。

不说了,产品经理老李又拿着新需求过来了,说是要在挂号成功页加个AI健康助手推荐。我得赶紧去研究下怎么把RAG的响应时间压到1秒以内了。各位同行,咱们下篇博客再见!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝