拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot生产环境三大坑:Redis穿透、Kafka丢消息、连接池打满

先交代背景。这篇博文里的三个问题都是我过去两年在生产环境里真实踩过的坑不是从网上复制来的面试八股文。Spring Boot 项目上线后遇到 Redis 缓存穿透导致数据库被击穿、Kafka 消息莫名其妙丢失、HikariCP 连接池不断告警——每一个问题都让当时的我头皮发麻但排查清楚之后发现底层原理大多相通。这次我把完整的排查思路、根因分析和最终落地的解决方案整理出来希望能帮你少走弯路。1. Redis 缓存穿透数据库被刷爆的那一刻我做了什么1.1 现象缓存命中率暴跌数据库连接数告警那次事故发生在周四下午我正在开需求评审会手机连续弹出十几条告警数据库连接池活跃连接数飙升到上限、接口 P99 延迟从 80ms 涨到 3.8s、Redis 缓存命中率从 92% 骤降到 40% 以下。第一反应是缓存过期时间设置有问题登录监控后台一看发现大量 key 根本不存在——每一个请求都在绕过 Redis 直接查询数据库。这个场景其实就是教科书里写的缓存穿透请求的数据在缓存和数据库中都不存在导致每次请求都要落到数据库层。正常情况下商品详情、用户信息这类热点数据第一次查询后会写入缓存后续请求都能命中。但当时我们有个开放接口接收前端传过来的商品 ID直接根据 ID 查询商品详情。攻击者或者爬虫完全可以遍历 ID把一批不存在的 ID 轮番打过来Redis 里永远没有数据数据库就会被无效查询拖垮。1.2 穿透、击穿与雪崩先把几个概念彻底分清排查问题之前我先把三个容易混淆的概念在团队里对齐了一次因为很多人面试背过八股文但真正遇到问题时的处理方式还是搞混。缓存穿透查询一个根本不存在的数据缓存和数据库都没有请求直接打到数据库。缓存击穿某个非常热点的 key 正好失效大量并发请求同时打到数据库。缓存雪崩大量 key 在同一时间段内集中过期请求全部落到数据库。穿透解决的是数据不存在的问题击穿解决的是单个热点 key 失效的问题雪崩解决的是批量 key 失效的问题。方案完全不同穿透用空值缓存或布隆过滤器击穿用互斥锁或逻辑过期雪崩用过期时间加随机值。当时线上问题属于第一种。我们用一个唯一标识码作为字典的 code 去查询字典明细先查 Rediskey 不存在就查数据库数据库也没有就返回 null但不会写回 Redis。问题就在这里——每次请求都重复这个查缓存未命中 → 打数据库 → 返回空的过程等于缓存形同虚设。1.3 排查过程从监控到代码的层层定位我先确认数据库慢查询日志发现大量 SQL 语句结构完全一样只有 code 在变化而且这些 code 根本不存在的场景占了六成。接着看 Redis 监控get 命令的 QPS 很高但 hits 很低——说明大量请求在缓存里根本查不到数据。再回到代码层面当时的业务逻辑简化后大概是这样的public DictVO getDictByCode(String code) { String cacheKey dict:code: code; Object cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { return (DictVO) cacheValue; } // 缓存未命中查询数据库 DictVO dictVO dictMapper.selectByCode(code); if (dictVO ! null) { redisTemplate.opsForValue().set(cacheKey, dictVO, 30, TimeUnit.MINUTES); } return dictVO; }问题一眼就能看出来当dictVO为 null 时没有任何写回缓存的逻辑。也就是说空数据永远不会被缓存每一次请求都会穿透到数据库。1.4 根因分析恶意请求和逻辑缺陷的双重叠加本质上这里有三个层面的问题叠加在一起第一接口层缺少对 code 参数合法性的预校验。code 有明确的格式规则比如长度 8 位、字母开头但接口层完全没有校验导致一批格式明显不合法的 code 也能进入核心查询流程。第二缓存策略没有覆盖空数据场景。我们只缓存有数据的结果忽略了无数据本身也是一个值得缓存的结果。第三缺少多级防护手段。即便是合法但数据库中确实不存在的 code也没有布隆过滤器之类的机制快速判断。1.5 解决方案一空值缓存与短过期时间兜底最直接、见效最快的方案是空值缓存。数据库查询返回 null 时也写一个空值到 Redis但要设置一个较短的过期时间比如 3 到 5 分钟避免过多的空 key 占用内存。同时空值的类型需要和正常值区分开可以在 value 里包一层标记或者单独约定一个空值对象。public DictVO getDictByCode(String code) { String cacheKey dict:code: code; Object cacheValue redisTemplate.opsForValue().get(cacheKey); if (cacheValue ! null) { if (cacheValue instanceof NullValue) { return null; } return (DictVO) cacheValue; } DictVO dictVO dictMapper.selectByCode(code); if (dictVO ! null) { redisTemplate.opsForValue().set(cacheKey, dictVO, 30, TimeUnit.MINUTES); } else { // 空值缓存过期时间设短 redisTemplate.opsForValue().set(cacheKey, NullValue.INSTANCE, 3, TimeUnit.MINUTES); } return dictVO; }用NullValue作为标记对象有几个好处正常值反序列化后可以直接使用空值反序列化后能明确区分。缓存空值可以拦截大量重复的无效查询代价是额外占用少量 Redis 内存但 3 分钟过期时间配合定期清理实际内存增量非常有限。1.6 解决方案二布隆过滤器前置拦截空值缓存能解决同一个不存在的 code 反复查询的问题但如果攻击者不断生成新的不存在 code空值缓存也拦不住——每个 code 都要先查一次数据库才能确认不存在即便后面写了空值。这时候需要布隆过滤器在缓存之前就判断这个 code 到底存不存在。布隆过滤器的原理是用多个哈希函数把元素映射到一个很长的位数组上。判断一个元素存在时需要所有哈希位置都是 1但因为有哈希碰撞布隆过滤器存在误判率而且只能判断一定不存在和可能存在。对于缓存穿透场景这一点刚好够用如果布隆过滤器判断 code 不存在直接返回 null根本不需要查 Redis 和数据库如果判断可能存在才继续走原有的缓存查询流程。Google 的 Guava 库提供了一套本地布隆过滤器实现适合单机场景如果系统是分布式部署可以在 Redis 里自己实现一套也可以使用 Redis 4.0 之后提供的 BF 模块。我们当时需要把所有字典表中存在的 code 预热到布隆过滤器里数据量大概几万条误判率设置成 1%效果很好。Component public class DictBloomFilter { private static BloomFilterString bloomFilter; PostConstruct public void init() { // 预期数据量 100000误判率 0.01 bloomFilter BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 100000, 0.01); // 从数据库加载所有合法 code这里省略批量加载逻辑 ListString allCodes dictMapper.selectAllCodes(); for (String code : allCodes) { bloomFilter.put(code); } } public boolean mightContain(String code) { return bloomFilter.mightContain(code); } }使用布隆过滤器后配合接口参数校验无效请求在入口处就被拦截数据库的无效查询量直接降到了原来的 1% 以下。1.7 踩坑心得穿透治理要三层联动回顾这次处理我的心得是穿透问题不能靠单一手段解决要三层联动第一层是入口校验。对接口参数做格式校验把明显不合法的请求挡在门外。这一步往往被很多人忽略但成本最低、效果最直接。第二层是布隆过滤器。对业务上存在性确定的 key 做前置判断拦截不存在的 key。第三层是空值缓存。作为兜底方案保证即便一层、二层都没拦住数据库的无效查询也能被缓存扛住一定的重复请求。还有一个重要细节空值缓存的过期时间不应该是一个固定值最好加上少量随机偏移避免大量空值 key 在同一时间点过期导致缓存雪崩叠加穿透。比如 3 分钟到 5 分钟之间随机。切身体会布隆过滤器确实有效果但也不是银弹。如果业务数据经常增删你需要考虑过滤器与数据源的同步问题新增数据要能及时 put 进过滤器删除数据时因为布隆过滤器不支持删除只能定期重建。对于字典表这种低频变更的数据布隆过滤器表现很好对于频繁变更的数据还是以空值缓存为主。2. Kafka 消息丢失从一次对账失败开始的逐层排查2.1 现象下游报表数据莫名少了几条第二个坑是在消息队列环节。当时我们有一个订单消息链路订单服务发送 MQ 消息 → 报表服务消费消息并写入报表库。某天业务方反馈某天的报表数据和订单库对不上少了十几条订单。第一反应是消费端代码有 bug但检查日志发现这些订单的消息在生产者端压根就没被成功发送。这个现象挺迷惑的因为订单服务日志里没有任何异常消息发送代码也执行了 send 方法。后来才发现Kafka 生产者默认的 acks 配置是 1意味着只要 Leader 副本收到消息就算发送成功不会等待 Follower 副本确认。如果 Leader 所在 Broker 在消息还没被复制到其他副本时宕机这条消息就丢了。而且send()是异步的很多人在调用后不检查回调结果等于我以为发出去了实际上没成功。2.2 消息丢失的三个环节生产端、Broker、消费端Kafka 消息丢失按照我拆解的经验应该从三个环节逐一排查生产端丢失使用了send()但不检查回调acks配置为 0 或 1副本未同步就认为成功重试参数配置不合理瞬时故障后直接放弃拦截器或序列化器抛异常消息没进到发送队列Broker 端丢失分区副本数量为 1没有冗余min.insync.replicas配置为 1没有强制要求 ISR 中最少存活副本数磁盘故障、页缓存未刷盘掉电丢失消费端丢失先提交位移commit再处理业务业务处理失败后消息无法重新消费消费线程在 submit 后进程崩溃使用了enable.auto.committrue且处理消息耗时长自动提交位移导致消息未处理完就提交每一层都有不同的排查着眼点我在文章下面逐一展开。2.3 生产端丢失acks 参数与重试机制的正确打开方式先看生产端。Kafka 生产者发送消息是异步的send()方法返回一个Future如果不主动 get 或者注册回调发送失败时你根本感知不到。这里有两种合理的处理方式方式一注册回调在回调里处理失败场景。producer.send(new ProducerRecord(order-topic, orderId, message), (metadata, exception) - { if (exception ! null) { // 记录日志发送失败 log.error(Kafka send message failed, orderId: {}, orderId, exception); // 这里可以重试或者写入本地消息表 } });方式二配置重试参数让生产者自己处理可重试的异常。props.put(ProducerConfig.ACKS_CONFIG, all); props.put(ProducerConfig.RETRIES_CONFIG, 3); props.put(ProducerConfig.RETRY_BACKOFF_MS_CONFIG, 1000); props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);acksall意味着 Leader 会等待所有 ISR 副本都确认后才返回成功这是保证消息不丢的基础配置。但要注意acksall会增加单条消息的确认时延对吞吐有影响。不过大多数业务系统不会真的需要每秒钟几十万条的消息发送为了不丢消息这点时延是值得的。enable.idempotencetrue是生产端防重的一个关键配置。幂等生产者会为每条消息分配序列号Broker 通过序列号去重即使生产者因为网络原因重试了同一条消息Broker 也不会重复写入。这个配置要配合acksall使用否则会报错。2.4 Broker 端丢失分区副本与 min.insync.replicas 的关系生产端配置搞定了Broker 端的风险也要排查。Kafka 的消息高可用靠的是多副本机制每个分区有多个副本Leader 负责读写Follower 负责同步。如果分区只有 Leader 一个副本Broker 宕机时消息直接没命。在 Kafka 集群创建 topic 时建议设置副本因子为 3kafka-topics.sh --create \ --topic order-topic \ --partitions 12 \ --replication-factor 3 \ --bootstrap-server localhost:9092副本数为 3意味着一个 Broker 挂了还有两个副本可以顶上。但仅仅有副本还不够min.insync.replicas决定了至少有几个副本同步成功才认为这条消息写入成功。假如你的min.insync.replicas1那么即使副本数为 3只要 Leader 自己写成功就返回其他两个副本异步同步中一旦 Leader 宕机还没同步的副本可能丢失消息。推荐配置是min.insync.replicas2它与生产端的acksall配合起来才能保证至少有两个副本确认写入。这个配置在 Broker 端的server.properties里修改或者在创建 topic 时单独指定kafka-topics.sh --create \ --topic order-topic \ --partitions 12 \ --replication-factor 3 \ --config min.insync.replicas2 \ --bootstrap-server localhost:9092需要注意的是如果 ISR 中活跃副本数小于min.insync.replicas生产者写入时会抛NotEnoughReplicasException。这本身是一种保护机制——宁可发送失败也不要静默丢失。2.5 消费端丢失手动提交位移顺序不能乱消费端丢消息是最隐蔽的因为它往往表现为业务处理失败但消息又不重试。标准流程应该是处理业务 → 提交位移。但很多人的代码写成了提交位移 → 处理业务或者开启了自动提交。我当时排查消费端源码时发现问题在于消费逻辑里用了 Spring 的KafkaListener默认的enable.auto.committrue。虽然 Spring Kafka 在AckMode上做了一些封装但默认行为依然存在风险如果消息处理逻辑抛异常但容器在某种情况下还是会继续拉取下一批消息位移可能已经提交。正确做法是配置手动提交spring: kafka: consumer: enable-auto-commit: false auto-offset-reset: earliest properties: max.poll.interval.ms: 300000 max.poll.records: 100然后在代码里使用Acknowledgment手动提交。KafkaListener(topics order-topic, groupId report-group) public void onMessage(ConsumerRecordString, String record, Acknowledgment ack) { try { // 业务处理 orderReportService.process(record.value()); // 业务成功后再提交位移 ack.acknowledge(); } catch (Exception e) { // 记录异常根据业务决定是否重试 log.error(处理消息失败消息内容: {}, record.value(), e); // 不提交位移下次会重新消费 } }这里的关键点是如果消息处理失败不要调用ack.acknowledge()让位移停留在当前位置这样消费者重启后会从最后提交的位移开始重新消费。但要注意如果不提交位移且一直消费失败Kafka 会认为消费者卡住了触发 rebalance有可能导致消息重复消费。所以消费端的逻辑还需要配合重试表或死信队列来最终解决。2.6 消息积压排查lag 与消费端性能的博弈在排查丢消息的过程中另一个常见问题是消息积压。消息堆积会让消费滞后量越来越高一旦消费者进程重启auto-offset-resetearliest会把位移重置到最早的未提交位置可能引发大量重复消费。查看消费组 lag 的常用命令kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group report-group输出结果里有CURRENT-OFFSET和LOG-END-OFFSET两列差值就是积压量。如果 lag 持续上涨要么是消费逻辑太慢要么是分区数不够导致消费并发上不去。增加分区数和消费者实例数可以提升消费速度但要注意消费者实例数超过分区数时多余实例会闲置。我们的订单报表场景后来通过增加分区数、优化消费端批量处理逻辑把消费耗时从每条 120ms 降到了 30mslag 维持在一个极低水平。2.7 踩坑心得消息可靠性配置速查表环节关键配置推荐值作用生产端acksall等待 ISR 全部确认生产端retries3可重试异常自动重试生产端enable.idempotencetrue防止生产者重试导致重复Brokerreplication.factor3副本冗余Brokermin.insync.replicas2最少同步副本数消费端enable.auto.commitfalse关闭自动提交消费端auto.offset.resetearliest无位移时从最早消费这套配置组合下来消息丢失的概率无限接近于零。但 无限接近零 不等于绝对不丢分布式系统里没有任何机制能保证 100% 不丢。所以在金融级交易链路里还需要配合对账系统用数据库记录消息发送状态通过定时任务补偿未确认的消息这属于业务侧的兜底。另外要提醒一点Kafka 的acksall和min.insync.replicas2组合起来会有一个明显的副作用——如果某个分区只剩一个存活副本所有写入该分区的请求都会失败。处理方式是把min.insync.replicas设置为 1 来换取可用性但这样会牺牲数据可靠性。取舍取决于业务对一致性的要求订单这类核心链路我会坚持可靠优先日志这类允许丢失的非核心数据可以降低配置标准。3. HikariCP 连接池打满从表象到底层的完整排查实录3.1 现象应用卡死连接获取超时第三个坑和数据库连接池有关。Spring Boot 2.x 默认的数据库连接池就是 HikariCP它性能好、轻量很多人在配置类里写个 datasource 就不管了我也是其中之一。直到某天业务高峰期应用日志开始刷Connection is not available, request timed out after 30000ms才知道连接池是会被打满的。这个问题我们当初排查了很久一开始以为是慢 SQL 太多导致连接被占满但看了监控后发现数据库本身的慢查询量并没有显著增加CPU 和磁盘 IO 都很正常。那问题出在哪里通过线程 dump 才发现根本原因不在连接池本身而在于业务代码里 ** 把连接池连接当成了线程池资源在用 ** ——一个接口里从 Redis 获取一批数据然后循环处理每个循环里都调用一次数据库查询。虽然单个查询很快但高并发下每个请求持有的连接时间都被拉长池子自然被耗尽。3.2 HikariCP 参数解析每个参数背后的真实影响先了解一下 HikariCP 的核心参数很多人在配置里直接抄默认值但默认值只适合低并发场景。参数默认值说明maximumPoolSize10最大连接数minimumIdle10最小空闲连接数connectionTimeout30000获取连接超时时间idleTimeout600000空闲连接存活时间maxLifetime1800000连接最大存活时间connectionTestQuery无连接测试语句关于参数选择这里分享一个我在实践中整理的较合理初始配置spring: datasource: hikari: minimum-idle: 8 maximum-pool-size: 20 connection-timeout: 5000 idle-timeout: 600000 max-lifetime: 1800000 connection-test-query: SELECT 1为什么connection-timeout要设成 5000 而不是默认的 30000因为 30 秒的等待时间在故障场景下太长了。想象一下连接池打满时每个请求都在排队等连接等待了 30 秒之后系统才有反应此时用户体验极差而且大量线程阻塞在连接获取上可能导致应用整体假死。设置 5 秒可以让快速失败配合降级逻辑系统至少能快速响应。maximum-pool-size的选择并不是越大越好。连接池大小计算公式一般是核心线程数 × (1 阻塞系数)比如说 CPU 有 8 个核心大部分操作是数据库 IO阻塞系数是 0.8那么连接池大小约为 8 × (1 0.8) ≈ 15 个连接。连接数太多反而会导致数据库端线程切换频繁性能下降。3.3 连接池打满的排查流程线程 dump 是关键如果你在生产环境遇到连接池打满我的建议是按这个顺序排查第一步查看连接池监控指标。Spring Boot Actuator 暴露了datasource相关指标hikaricp.connections.active、hikaricp.connections.awaiting、hikaricp.connections.usage。通过 Grafana 配置监控图表观察活跃连接数是否一直处于高位。第二步导出线程 dump。使用jstack 进程ID thread_dump.txt在 dump 日志里搜索HikariPool-1相关关键字看到底是哪些线程卡在connection.getConnection()以及这些线程的上层调用栈是什么。第三步分析调用时长。如果线程 dump 显示大部分线程都在同一个业务方法的调用链上说明这个方法很可能持有连接的时间超出了合理范围。我当时遇到的场景是一个批量导入接口被外部系统频繁调用每次请求要处理几千条数据每条数据都执行一次数据库插入而且没有使用批量操作。一个请求会占用一个连接长达数秒并发稍微上来20 个连接直接耗尽。3.4 优化案例把单条插入改成批量操作连接占用降了 80%这里我把当时的优化操作完整贴出来虽然代码简化了但思路不变。优化前的逻辑Transactional public void importOrders(ListOrder orders) { for (Order order : orders) { orderMapper.insert(order); } }每一次insert都会从连接池获取一个连接虽然 MyBatis 在执行后会立即释放但Transactional会强制整个方法共享一个连接所以整个导入过程持续占着一个连接不释放。遇到数据量大的时候连接占用时间很长。优化后的逻辑Transactional public void importOrders(ListOrder orders) { // 分批批量插入每批 500 条 for (int i 0; i orders.size(); i 500) { ListOrder subList orders.subList(i, Math.min(i 500, orders.size())); orderMapper.batchInsert(subList); } }MyBatis 的batchInsert使用 foreach 拼接多个 values一次 SQL 执行插入多条数据。优化前导入 5000 条数据要执行 5000 次 SQL占用连接时间长优化后只需要执行 10 次 SQL连接占用时间缩减了一个量级。同时我在接口入口加了并发控制使用信号量限制同时处理的批量导入任务数量避免外部系统疯狂调用导致资源耗尽private final Semaphore semaphore new Semaphore(5); public void importOrders(ListOrder orders) { if (!semaphore.tryAcquire()) { throw new BizException(系统繁忙请稍后再试); } try { // 业务逻辑 } finally { semaphore.release(); } }这波优化之后连接池活跃连接数从高峰期的 20 满值降到 8 左右P99 延迟从 2s 降到 400ms问题彻底解决。3.5 连接池参数调优建议不要盲目照搬连接池的参数没有一套绝对正确的配置必须根据实际业务特点来调。我之前踩过的一个坑是照搬网上资料把maximum-pool-size调到 200结果数据库连接数直接爆掉数据库端报Too many connections。一个比较实用的调整思路是数据库连接的理想配置是够用就好不要过剩。少量连接可以支撑大量并发请求因为每个连接都是复用的。高并发读多写少场景连接池可以稍微调大比如 50 个以内。写多场景优先优化 SQL 和执行方式比如批量插入、分页查询而不是单纯加大连接池。如果连接池经常被打满首先要检查的是业务代码是否有连接泄漏或长时间持有连接而不是直接调大连接池。调试时可以先打印连接池的活跃连接数和等待数确定是池子不够大还是连接被占着不放。如果是前者适当增加如果是后者调大池子只会掩盖问题。还有一个容易被忽略的参数是maxLifetime。HikariCP 官方文档建议连接最大存活时间要比数据库自身的 wait_timeout 短。比如 MySQL 的wait_timeout是 8 小时28800 秒maxLifetime应该设置在 4 到 5 分钟左右也就是 1800000ms 毫秒比较合适。这样可以避免数据库端主动断开连接后连接池还在使用过期连接导致偶发的连接不可用。3.6 踩坑心得Redis 和数据库的操作顺序要重新思考复盘这个场景我还想多提一点连接池打满往往不是连接池的错而是业务代码设计的问题。我们后来重构这个接口时把查 Redis → 比较逻辑 → 写数据库的过程拆成了两个阶段——先批量从 Redis 读取数据然后在内存中完成逻辑判断最后一次性批量写入数据库而不是在循环里反复操作 Redis 和数据库。这种批量读取 内存计算 批量写入的模式对连接池和 Redis 性能都非常友好。很多人写代码时习惯在一个循环里既查 Redis 又查数据库短时间看没问题一旦流量上来就扛不住。另外需要警惕事务方法里穿插 RPC 调用。如果Transactional方法里调用了外部 HTTP 接口或 MQ 发送且等待时间较长事务连接会被长时间占用。这时候要考虑把事务边界缩小或者在事务外完成远程调用只在最后一步开启事务写库。4. 最后的个人体会每次踩完这类基础组件的坑我都习惯把根因写在技术复盘文档里过几个月再看会发现很多问题本质上出在想当然三个字上想当然以为 Redis 缓存肯定命中了想当然以为 Kafka 的 send 就代表发送成功了想当然以为连接池不会被打满。这些问题的排查思路其实是一通百通的。先看监控指标确认现象再通过日志、线程 dump、链路追踪逐步缩小范围最后回到代码逻辑找根因加防护手段避免二次发生。这套方法论适用于所有技术栈比记住某一个组件的配置参数要重要得多。最后再分享一个很小但很实用的习惯所有与外部系统交互的调用无论 Redis 读写、Kafka 发送还是数据库连接获取时机合适都建议记录耗时和结果。出问题时没有数据可看才是最恐怖的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门