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

高热度账号冲击下的服务优化:多级缓存、分布式锁与限流实战

当你们平台的排行榜首页突然涌入一个高热度账号时服务器往往会在几分钟内出现接口超时、数据库连接打满、公告推送延迟等问题。如果这个账号恰好被大家称为“布吉岛榜一”那它一上线就意味着某个热点 Key 的访问量可能在瞬间翻几十倍甚至上百倍。本文要讨论的就是这种“高热度玩家/账号进入 EC 服务器”之后排行榜服务、公告服务和个人信息服务如何通过缓存、限流、数据库优化、监控告警等组合手段扛住突刺流量。先说明一下这里的“EC 服务器”。不同团队对 EC 的称呼并不统一有的指边缘计算节点有的叫业务服务集群也有团队把它泛化为承载具体业务逻辑的云上服务实例。无论如何理解本文的核心是当一个超大热点人物进入系统时服务器内部会经历什么以及后端开发同学可以按什么顺序去优化。“布吉岛榜一”在这里不代指某个真实玩家而是一个抽象符号——代表着平台里最受关注、访问量最大、最容易被大量用户同时查询的那条数据。这篇文章适合正在做游戏服务器、社交平台、电商排行榜、积分榜单等业务的开发者阅读。如果你是后端方向的新人可以从中理解缓存的常见问题如果你有一定开发经验可以直接对照文中的代码片段去排查自己服务的缓存击穿、热点 Key 和限流配置。文章不会只讲理论也不会只贴代码而是把从问题出现到服务恢复稳定的完整技术链路拆开尽量做到每一步都可以照着改。1. 背景与核心概念1.1 “榜一进入服务器”到底触发了什么在游戏或社区类平台中排行榜是最容易被围观的功能。平时在线人数不高时一个榜单查询接口的 QPS 可能只有个位数。但当“布吉岛榜一”这类高热度账号进入服务器系统往往会发生下面一串连锁反应大量玩家会同时刷新排行榜首页想看看榜一的实时分数。好友系统会批量拉取榜一的头像、签名、战绩摘要。大厅公告会向全网广播“榜一已上线”进一步放大访问流量。如果榜单数据存储在 MySQL 里热点行会被多个线程同时读取InnoDB 的行锁和缓存池压力会迅速上升。这些请求最终都会落到底层的热点数据上。问题是大多数服务在初期并没有为“单条数据被高频读取”做过专门设计往往用一条普通 SQL 直接查询数据库结果就是热点账号在线时数据库 CPU 飙高、连接数打满最终整个服务雪崩。所以与其问“为什么一个玩家就能拖垮服务器”不如问“为什么一个热点 Key 就能让缓存和数据库同时失效”。理解了后者就能理解大部分高性能榜单服务的核心设计思路。1.2 缓存三大问题击穿、穿透、雪崩在进入代码之前有必要把三个容易混淆的概念梳理清楚缓存穿透查询一个不存在的 Key缓存和数据库里都没有请求每次都会打到数据库。如果有人恶意循环查询不存在的用户 ID数据库会被无效查询压垮。缓存击穿一个热点 Key 突然过期恰好有大量请求同时访问这个 Key请求全部落到数据库导致数据库压力陡增。缓存雪崩大量 Key 在同一时间过期或者 Redis 实例不可用导致大批请求直接打到数据库引发整体不可用。“榜一来到 EC 服务器”这个场景最典型的是缓存击穿和热点 Key 问题但如果系统设计得不好也可能因为同一个榜单 Key 被设了相同的过期时间引发局部雪崩。后面的实战环节会分别给出对应的解决方案。1.3 为什么需要一套组合方案很多同学第一个反应是“加 Redis 缓存”。加缓存当然是对的但如果只加一层缓存仍然会存在缓存过期瞬间的击穿问题如果只做限流又可能误伤正常玩家如果只优化数据库热点行问题很难彻底解决。因此比较稳妥的做法是用多级缓存挡住绝大多数读请求用分布式锁保护缓存重建过程用布隆过滤器拦截非法 Key用限流保护下游数据库最后通过监控确认每个环节是否真正生效。这套组合方案不会因为某一个组件失效而直接崩溃系统会具备基本的自愈能力。2. 环境准备与版本说明2.1 运行环境本文示例以常见的 Java 后端技术栈为例核心依赖如下JDK 8 或 JDK 11如果你使用 JDK 17 或更高版本需要注意 Spring Boot 版本的兼容性。Spring Boot 2.7.x高版本项目可以使用 Spring Boot 3.x但部分配置项需要同步调整。MySQL 5.7 或 8.x。Redis 5 或更高版本建议 Redis 6。Maven 3.6。IDE 推荐 IntelliJ IDEA 或 Eclipse命令行工具可选 curl、redis-cli、mysql-client。版本号需要根据你的项目实际情况调整本文重点演示配置思路和代码逻辑代码中的 API 在不同版本之间差异不会太大。2.2 项目结构为方便阅读我按一个独立的排行榜服务来组织代码。完整的示例项目结构如下ec-bang-server/ ├── pom.xml └── src └── main ├── java │ └── com │ └── example │ └── ecbang │ ├── EcBangApplication.java │ ├── config │ │ ├── RedisConfig.java │ │ └── RedissonConfig.java │ ├── controller │ │ └── PlayerRankController.java │ ├── entity │ │ └── PlayerRank.java │ ├── mapper │ │ └── PlayerRankMapper.java │ ├── service │ │ ├── PlayerRankService.java │ │ └── impl │ │ └── PlayerRankServiceImpl.java │ └── common │ ├── Result.java │ └── LimitAspect.java └── resources └── application.yml2.3 Maven 依赖pom.xml 中需要引入 Web、Redis、MyBatis、MySQL、Caffeine、Redisson、Lombok 等依赖。为了控制篇幅这里只列出核心部分。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里提醒一点Redisson 的版本需要和 Spring Boot 版本匹配不能盲目使用最新版。如果你本身不想引入 Redisson也可以基于 RedisTemplate 自行实现分布式锁和布隆过滤器但会多一些底层代码。2.4 application.yml 配置server: port: 8080 spring: application: name: ec-bang-server datasource: url: jdbc:mysql://localhost:3306/ec_bang?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver cache: type: caffeine redis: host: localhost port: 6379 password: timeout: 2000ms lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 mybatis: configuration: map-underscore-to-camel-case: true重点解释几个配置项lettuce.pool.max-activeRedis 连接池最大连接数。热点场景下如果这个值太小会出现大量线程阻塞等待连接。datasource.url 中的 serverTimezone 参数不设置可能在高版本 MySQL 驱动下报错。spring.cache.typecaffeine这里把 Spring Cache 指定为 Caffeine用于实现本地缓存和 Redis 缓存形成多级关系。3. 核心问题拆解排行榜服务为什么扛不住3.1 未优化前的数据库直连方案很多小型项目最初都会采用最简单的实现方式玩家请求排行榜时直接通过 MyBatis 查询数据库。下面是一个典型的排行榜查询 Mapper。// 文件路径src/main/java/com/example/ecbang/mapper/PlayerRankMapper.java public interface PlayerRankMapper { Select(SELECT id, player_name, score, rank_no, update_time FROM player_rank ORDER BY score DESC LIMIT #{limit}) ListPlayerRank selectTopN(Param(limit) int limit); }对应的 Service 也很直白。// 文件路径src/main/java/com/example/ecbang/service/impl/PlayerRankServiceImpl.java Service public class PlayerRankServiceImpl implements PlayerRankService { Resource private PlayerRankMapper playerRankMapper; Override public ListPlayerRank getTop100() { return playerRankMapper.selectTopN(100); } }如果只承载个位数的 QPS这种写法没有任何问题。但“榜一”上线后榜单接口的 QPS 会迅速变成几百甚至上千此时每次请求都执行一次 ORDER BY score DESC 排序查询数据库的 CPU 和 IO 都会快速飙升。尤其是当 player_rank 表的数据量达到百万行后即使有复合索引高频的 SQL 解析和计划执行也会成为瓶颈。3.2 热点 Key 是如何形成的排行榜首页的数据其实具有非常高的聚合性——所有玩家看到的都是同一个“TOP100 榜单”。如果服务端把这份榜单数据加载到 Rediskey 可能长这样player:rank:top100这个 key 被全网玩家同时访问天然就是一个超级热点。当它存在且未过期时Redis 会表现得很好当它过期或者被人为删除时大量请求会绕过缓存直接打到 MySQL产生缓存击穿。更隐蔽的是每个普通玩家还有自己的个人积分详情比如“player:info:10001”。这类 key 可能因为有大量在线玩家同时查看榜一的个人信息导致原本普通的 key 也变成了热点。这就是热点 Key 的动态性它不一定在代码里写死而是由业务行为临时形成的。3.3 不同阶段的流量特征性能优化前先判断自己处于哪个阶段初级阶段并发量小数据库直连就够用不需要引入复杂缓存。中级阶段读多写少可以加 Redis 缓存解决大部分查询问题。高级阶段存在超热点 Key需要多级缓存、分布式锁、限流降级、故障演练。当“布吉岛榜一”这类角色存在时系统至少要处在中级到高级阶段之间。下面的实战部分会按这个路径逐步改造代码。4. 完整实战案例从直连数据库到多级缓存4.1 第一步给排行榜接口加 Redis 缓存先在 service 层引入 RedisTemplate实现缓存查询逻辑。这里不推荐直接在 Mapper 上加 Cacheable因为 Spring Cache 对缓存过期时间和 key 的控制不够直观并且不易处理缓存击穿所以手动 Cache-Aside 更合适。// 文件路径src/main/java/com/example/ecbang/service/impl/PlayerRankServiceImpl.java Service public class PlayerRankServiceImpl implements PlayerRankService { private static final String RANK_KEY player:rank:top100; private static final long RANK_TTL_MINUTES 5; Resource private PlayerRankMapper playerRankMapper; Resource private StringRedisTemplate stringRedisTemplate; Resource private ObjectMapper objectMapper; Override public ListPlayerRank getTop100() { String cached stringRedisTemplate.opsForValue().get(RANK_KEY); if (cached ! null) { try { ListPlayerRank list objectMapper.readValue( cached, new TypeReferenceListPlayerRank() {} ); return list; } catch (JsonProcessingException e) { // 缓存反序列化失败时不直接抛出异常而是回源数据库并重建缓存 log.error(deserialize rank cache error, e); } } ListPlayerRank list playerRankMapper.selectTopN(100); try { stringRedisTemplate.opsForValue().set( RANK_KEY, objectMapper.writeValueAsString(list), RANK_TTL_MINUTES, TimeUnit.MINUTES ); } catch (JsonProcessingException e) { log.error(serialize rank cache error, e); } return list; } }这里有几个细节需要注意使用 StringRedisTemplate 而不是 RedisTemplate可以避免 JDK 序列化带来的乱码问题。缓存反序列化失败不能直接抛异常否则会导致故障面扩大正确做法是记录日志并回源数据库。过期时间先统一设 5 分钟后面会讨论为什么要加随机偏移。经过这一步绝大多数排行榜请求都会命中 Redis数据库的压力会显著下降。但缓存击穿问题依然存在因为 5 分钟一到第一个请求还是要回源数据库重建缓存如果此刻有 1000 个并发请求数据库依然可能被打满。4.2 第二步用分布式锁解决缓存击穿解决缓存击穿的经典做法是当缓存不存在时只有拿到分布式锁的线程才能查询数据库其他线程短暂等待后重新读取缓存。下面使用 RedisTemplate 的 SETNX 命令实现一个简单的分布式锁。重点是设置过期时间必须和 SETNX 放到同一原子操作中否则极端情况下会出现死锁。// 文件路径src/main/java/com/example/ecbang/service/impl/PlayerRankServiceImpl.java private boolean tryLock(String lockKey, String requestId, long expireMillis) { Boolean result stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireMillis, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(result); } private void releaseLock(String lockKey, String requestId) { String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; stringRedisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId ); }释放锁时必须使用 Lua 脚本先判断持有者再删除否则可能释放掉别人刚获取的锁。这里需要注意即使是 Lua 脚本也只能保证在单实例 Redis 下是安全的如果使用了集群模式锁可靠性需要升级到 Redisson 或更专业的分布式锁方案。下面改造 getTop100 方法把缓存重建过程保护起来。Override public ListPlayerRank getTop100() { String cached stringRedisTemplate.opsForValue().get(RANK_KEY); if (cached ! null) { return deserialize(cached); } String lockKey lock: RANK_KEY; String requestId UUID.randomUUID().toString(); ListPlayerRank list; try { if (tryLock(lockKey, requestId, 3000)) { // 双重检查拿到锁后可能已有其他线程重建了缓存 cached stringRedisTemplate.opsForValue().get(RANK_KEY); if (cached ! null) { return deserialize(cached); } list playerRankMapper.selectTopN(100); stringRedisTemplate.opsForValue().set( RANK_KEY, serialize(list), RANK_TTL_MINUTES, TimeUnit.MINUTES ); return list; } else { // 没拿到锁的线程先休眠 50ms再重试读取缓存 Thread.sleep(50); return getTop100(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return playerRankMapper.selectTopN(100); } finally { releaseLock(lockKey, requestId); } }这段代码的关键在于“双重检查”线程拿到锁后不能马上查库而是再次检查缓存是否已经被其他线程重建。这是为了避免重复查库也是缓存击穿防护的常见写法。实际生产环境中也可以直接使用 Redisson 的 RLock它提供了看门狗续期机制能有效避免锁超时导致的重入问题。具体代码会在后面的布隆过滤器中一起使用 Redisson因此这里不再重复引入。4.3 第三步用布隆过滤器解决缓存穿透排行榜服务的查询并不只有 TOP100还会接收单个玩家信息的查询。如果客户端传了一个不存在的 playerId并且服务端没有做校验请求就会每次都落库。更有恶意攻击者可能随机生成大量不存在的 ID 去请求接口形成缓存穿透。布隆过滤器可以用来判断一个元素“一定不存在”或“可能存在”。Redis 中可以通过 Redisson 的 RBloomFilter 实现不需要自己写位图逻辑。// 文件路径src/main/java/com/example/ecbang/config/RedissonConfig.java Configuration public class RedissonConfig { Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis://localhost:6379) .setPassword() .setConnectionMinimumIdleSize(8) .setConnectionPoolSize(32); return Redisson.create(config); } Bean public RBloomFilterString playerBloomFilter(RedissonClient redissonClient) { RBloomFilterString bloomFilter redissonClient.getBloomFilter(player:bloom); bloomFilter.tryInit(1000000L, 0.01); return bloomFilter; } }默认构造参数里第一个参数是预估元素数量第二个参数是误判率。误判率越低占用的空间越大生产环境可以根据玩家规模调整。初始化后调用 add 方法把有效玩家 ID 写入过滤器查询前先调用 contains 判断。public PlayerRank getPlayerInfo(Long playerId) { if (!playerBloomFilter.contains(String.valueOf(playerId))) { return null; } String key player:info: playerId; String cached stringRedisTemplate.opsForValue().get(key); if (cached ! null) { return deserializePlayer(cached); } PlayerRank player playerRankMapper.selectByPlayerId(playerId); if (player ! null) { stringRedisTemplate.opsForValue().set(key, serializePlayer(player), 30, TimeUnit.MINUTES); } return player; }布隆过滤器可以挡住绝大部分“一定不存在”的请求。要注意的是它有误判率所以通过布隆过滤器校验后数据库仍可能查不到数据此时不要因为查不到就拒绝缓存空值。如果查询频率较高可以考虑把空结果也缓存一段时间进一步降低数据库压力。4.4 第四步引入 Caffeine 本地缓存实现多级缓存即使有了 Redis网络开销仍然存在。对于“player:rank:top100”这样的超热点 Key进一步优化方式是把它放到进程内本地缓存里让本机请求直接读取内存只有本地缓存过期时才去访问 Redis。Spring Boot 配置 Caffeine 缓存并不复杂。// 文件路径src/main/java/com/example/ecbang/config/CacheConfig.java Configuration public class CacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(localCache); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(30, TimeUnit.SECONDS)); return cacheManager; } }在 Service 中使用 Cacheable 注解标记本地缓存方法。这里只做一级本地缓存本地缓存过期时间比 Redis 短避免长时间数据不一致。Cacheable(cacheNames localCache, key player:rank:top100) public ListPlayerRank getTop100FromLocal() { return getTop100(); }如果业务对一致性的容忍度很低可以去掉本地缓存只使用 Redis或者使用 Caffeine 的 Cache 手动控制刷新。建议在多数读多写少场景下本地缓存 Redis 是性价比最高的组合。引入本地缓存后查询链路变成本地 Caffeine 缓存 ↓ 未命中 Redis 缓存 ↓ 未命中 分布式锁保护下的数据库查询这层链路能有效降低 Redis 的访问压力。对同一个 EC 服务器内的多个实例而言每个实例都有一份本地缓存能够分散热点流量。4.5 第五步Redis 限流保护数据库限流的意义在于即使缓存全部失效也不能让流量直接打崩数据库。比较简单的分布式限流方案是 Redis 固定窗口或者令牌桶。下面用一个自定义注解 Spring AOP Lua 脚本实现固定窗口限流。先定义一个限流注解。// 文件路径src/main/java/com/example/ecbang/common/RateLimit.java Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { String key() default ; long limit() default 100; long windowSeconds() default 1; }再写 Lua 限流脚本。窗口内的计数值超过阈值就拒绝访问。// 文件路径src/main/java/com/example/ecbang/common/LimitAspect.java Aspect Component public class LimitAspect { Resource private StringRedisTemplate stringRedisTemplate; private static final DefaultRedisScriptLong LIMIT_SCRIPT; static { LIMIT_SCRIPT new DefaultRedisScript(); LIMIT_SCRIPT.setScriptText( local current redis.call(INCR, KEYS[1]) if current 1 then redis.call(EXPIRE, KEYS[1], tonumber(ARGV[1])) end if current tonumber(ARGV[2]) then return 0 end return 1 ); LIMIT_SCRIPT.setResultType(Long.class); } Around(annotation(rateLimit)) public Object doLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String key rateLimit.key(); Long result stringRedisTemplate.execute( LIMIT_SCRIPT, Collections.singletonList(key), String.valueOf(rateLimit.windowSeconds()), String.valueOf(rateLimit.limit()) ); if (result null || result 0) { throw new RuntimeException(system busy, please retry later); } return joinPoint.proceed(); } }在控制器的排行榜接口上使用注解GetMapping(/rank/top100) RateLimit(key limit:rank:top100, limit 500, windowSeconds 1) public ResultListPlayerRank top100() { return Result.success(playerRankService.getTop100()); }这里的阈值需要根据压测结果调整。固定窗口实现的优点是简单缺点是窗口边界可能出现双倍流量如果不满足业务要求可以改成令牌桶或滑动窗口。限流的目的不是限制正常玩家而是保护下游数据库因此阈值一般要留 30% 的余量。4.6 第六步热点 Key 隔离与上线事件推送当“榜一”上线时公告系统需要向在线用户推送消息。这类推送接口本身不适合用普通 HTTP 轮询因为客户端会频繁建立连接产生大量无意义请求。更合适的做法是使用 WebSocket 或 SSE 推送。考虑到示例简单这里用 Spring Boot 的 SseEmitter 实现一个简易推送通道。客户端订阅后服务端在有“榜一上线”事件时主动推送。// 文件路径src/main/java/com/example/ecbang/controller/NoticeController.java RestController RequestMapping(/notice) public class NoticeController { private final CopyOnWriteArrayListSseEmitter emitters new CopyOnWriteArrayList(); GetMapping(/subscribe) public SseEmitter subscribe() { SseEmitter emitter new SseEmitter(30_000L); emitters.add(emitter); emitter.onCompletion(() - emitters.remove(emitter)); emitter.onTimeout(() - emitters.remove(emitter)); return emitter; } PostMapping(/top-player-online) public ResultString topPlayerOnline(RequestBody String playerName) { String notice {\event\:\TOP_PLAYER_ONLINE\,\player\:\ playerName \}; for (SseEmitter emitter : emitters) { try { emitter.send(SseEmitter.event().data(notice)); } catch (Exception e) { emitters.remove(emitter); } } return Result.success(ok); } }SSE 服务端推送适合“服务器到客户端”的单向通知相比 WebSocket 实现成本低很多。生产环境中如果在线人数巨大建议把通知数据写入 Redis Stream 或消息队列再由专门的通知服务分发避免像示例一样在应用内存中维护连接列表。4.7 运行与验证启动服务后可以先用 curl 验证排行榜接口。curl http://localhost:8080/rank/top100第一次调用会从数据库加载数据并写入缓存第二次调用会命中 Redis第三次调用可能命中本地缓存。为了观察效果可以在 Service 中临时加日志或者在 Redis 客户端里执行redis-cli keys *player* get player:rank:top100如果能拿到 JSON 字符串说明缓存链路已经打通。压测时可以使用 wrk 或 JMeter 模拟高并发访问wrk -t8 -c200 -d30s http://localhost:8080/rank/top100观察指标请求成功率是否接近 100%。Redis 命中率和平均耗时。MySQL 慢查询数量是否明显下降。限流触发时是否返回可预期的错误信息。如果一切正常你会看到 Redis 提供了绝大部分数据的读服务数据库的 QPS 被控制在非常低的水平即使缓存过期分布式锁也保证一次只有一个线程回源数据库。5. 数据库侧优化底层永远不能松5.1 索引设计缓存层再强大数据库依然是最终的数据地基。排行榜表的核心查询是 ORDER BY score DESC LIMIT 100最合适的索引是ALTER TABLE player_rank ADD INDEX idx_score (score DESC);如果榜单需要按分区查询例如按服或者按赛季查询索引需要相应调整。但要注意索引不是越多越好写多的表添加过多索引可能导致写入变慢生产环境要结合慢查询日志评估。5.2 分表分区当 player_rank 表的数据量超过千万级后单表即使有索引也可能会出现排序性能下降。常用的优化手段有按赛季分表比如 player_rank_202501。按业务 ID 分库分表例如根据 server_id 做水平拆分。使用 MySQL 分区表按时间范围做 RANGE 分区。分表分区会增加业务复杂度建议在数据量确实到达瓶颈时再做。初期优先做好缓存与索引已经能解决 80% 的问题。5.3 慢查询监控MySQL 开启慢查询日志后配合 mysqldumpslow 工具可以快速找到问题 SQL。mysqldumpslow -s at /var/lib/mysql/slow-query.log | head -20如果日志里频繁出现 player_rank 表的 ORDER BY 查询说明缓存可能没有生效或者缓存被频繁击穿。慢查询日志是验证优化效果的重要依据必须养成定期查看的习惯。6. 监控告警没有监控的优化都是盲人摸象6.1 Spring Boot Actuator 暴露指标先引入 Actuator 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后暴露必要端点management: endpoints: web: exposure: include: health,metrics,prometheus生产环境不建议把所有端点都暴露公网至少要加认证或内网访问限制。此时可以访问 http://localhost:8080/actuator/metrics 查看 JVM 线程、内存等基础指标。6.2 自定义关键指标缓存命中率是排行榜服务最核心的指标。可以使用 Micrometer 自定义 Counter在 Service 中手工埋点Resource private MeterRegistry meterRegistry; private void recordCacheHit(boolean hit) { meterRegistry.counter(ecbang_cache_hit_total, result, hit ? hit : miss) .increment(); }通过 Prometheus 抓取指标后在 Grafana 中配置缓存命中率、接口耗时、Redis 连接数、数据库连接池活跃数等面板。设置告警规则时可以关注缓存命中率低于 90% 且持续 5 分钟。排行榜接口 P99 耗时超过 800ms。MySQL 活跃连接数持续高于最大连接数的 70%。监控的价值在于它能告诉你优化方案有没有真正生效。没有监控的情况下压测结束以后系统很容易在真实流量下再次暴露相同问题。7. 常见问题与排查思路问题现象常见原因解决思路排行榜接口偶发超时缓存刚好过期出现缓存击穿加入分布式锁锁内双重检查缓存Redis 连接数打满热点 Key 过多每个请求都抢占连接引入本地缓存减少 Redis 访问次数数据库 CPU 突然飙高缓存穿透或缓存雪崩加布隆过滤器缓存过期时间加随机值缓存和数据库数据不一致更新数据库后未及时删除缓存更新操作后主动删缓存或延迟双删上线公告部分用户收不到SseEmitter 连接在服务端超时或被 GC改用 Redis Stream 独立通知服务限流误伤正常玩家阈值设置过低压测后按 P99 流量重新评估这里重点说下缓存和数据库不一致的问题。最简单的做法是写请求先更新 MySQL再删除 Redis 缓存读请求先读缓存缓存不存在再读 MySQL 并写回。删除缓存失败时可以借助 RocketMQ 等消息队列重试或者使用 Canal 监听 binlog 异步刷新缓存。不要用“先更新缓存再更新数据库”这种方式它会带来更多数据冲突。日常开发中如果把缓存过期时间设计成固定值比如所有 key 都是 5 分钟那么整点 5 分时会有一大批 key 集中过期造成瞬时数据库压力。解决办法是设置缓存过期时间时加入随机秒数。long ttl 300 ThreadLocalRandom.current().nextInt(60);8. 最佳实践与工程建议8.1 缓存 Key 命名规范统一命名能大幅降低排障成本。建议格式是业务域:对象名:唯一标识:附加属性。player:rank:top100 player:info:{playerId} player:score:{playerId}:season:{seasonId}不要在 key 里存带空格的 JSON不要在 value 里堆叠过大的对象。单条排行榜缓存数据超过 1MB 时要考虑拆分存储否则 Redis 网络传输耗时会被放大。8.2 生产环境变更流程对 EC 服务器做缓存改造涉及 Redis、配置、代码等多处变更上线前应遵循基本流程先在测试环境压测记录改造前后的 QPS、耗时、CPU 对比。上线时先小流量验证比如只放 10% 的流量到新服务。观察监控面板确认缓存命中率稳定后放开全量。保留回滚方案。代码和配置要做到可快速切换比如通过配置中心动态开关缓存功能。任何涉及线上数据库表结构或大表更新的操作务必先在灰度环境验证并且在低峰期执行。虽然本文例子只是加索引但“最小权限、先备份、再变更”的原则同样适用。8.3 防止本地缓存导致的内存问题本地缓存虽然能扛热点但它占用 JVM 堆内存。如果 maximumSize 设置过大可能引发 Full GC 或 OOM。建议根据机器内存和实例数量评估缓存容量。对缓存条目序列大小做限制。定期监控老年代内存和 GC 停顿。在压测环境下反复调大流量确认内存稳定后再上生产。8.4 关注压测中的冷启动问题服务刚启动时本地缓存和 Redis 缓存都是空的此时所有流量都会穿透到数据库。如果新扩容的实例直接接入生产流量可能会因为冷启动导致数据库压力突增。可以考虑预加载机制在 Spring Boot 启动完成后主动调用一次 getTop100把核心缓存提前预热。更稳妥的做法是上线脚本里先调用预热接口再挂载到负载均衡。8.5 Redis 高可用“榜一”这种热度的业务Redis 本身不能是单点。至少需要主从 哨兵模式或者使用云上的 Redis 集群。如果 Redis 挂了本地缓存还能撑一段时间但限流和分布式锁也会失效。因此高可用架构里必须有多级降级预案缓存全挂时数据库连接池限流要能兜住底线流量。9. 总结与后续方向回到开头的场景“布吉岛榜一”进入 EC 服务器暴露的其实是一个典型的热点数据治理问题。一个高热度账号本身不可怕可怕的是系统没有为热点流量设置任何缓存、锁、限流和降温机制。优化过程中我建议先按这个顺序落地确认瓶颈在哪一层加上 Redis 缓存用分布式锁保护缓存重建引入布隆过滤器防穿透再用本地缓存承担超热点 Key 的压力最后用限流和监控把系统兜住。当这些环节都完成之后你会发现系统不再害怕“某一个榜一玩家上线”因为它已经把集中流量分散到了多个层次。排行服务瞬间的高流量会被本地缓存、Redis 缓存、分布式锁、限流层层消化数据库只在真正需要时才会被触及。后续可以继续研究 Redisson 锁的看门狗机制、滑动窗口限流、Redis 集群下热点 Key 自动识别以及更完整的监控告警体系建设。如果你在改造过程中也遇到过类似的“某个热点用户一上线服务就卡顿”的问题不妨先从缓存击穿查起再用压测验证每一步的效果。优化不能一次做太多改动一项、观察一项系统才能稳定前进。
分享:

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

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