ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

eladmin后台系统Redis深度实践:从缓存配置到分布式锁与集群部署

eladmin后台系统Redis深度实践:从缓存配置到分布式锁与集群部署 如果你问我后台管理系统里最容易被低估的中间件是什么我会说Redis。很多团队把Redis当成一个“放Token的缓存库”就完事了但实际上在eladmin这种基于Spring Boot的后台管理系统里Redis能承担的角色远比你想象得多。从用户登录态、验证码、在线用户到字典缓存、分布式锁、接口限流计数器再到集群部署后的Session协调它几乎参与了业务链路的每一层。我最早接手团队eladmin项目时Redis已经配好了但一查监控全是问题——key乱码、缓存不生效、分布式锁偶尔失效、主从切换后计数器归零。这篇文章我把这几轮踩坑和重构的过程完整写出来包含配置、序列化、缓存注解、分布式锁、故障处理和部署升级路线适合正在用或准备用eladmin做二次开发的朋友参考。先说个结论eladmin本身自带一套缓存机制很多基础代码里也预留了Redis的入口但默认状态下的使用深度远远不够。如果你只是把它当成“验证码临时存储”那你既没发挥Redis的价值也迟早会在高并发或集群场景下栽跟头。接下来我按实际项目推进的顺序来讲。1. eladmin为什么需要Redis从登录态到缓存的架构动因1.1 eladmin原生架构下的存储瓶颈eladmin的默认设计里用户认证走的是JWT也就是服务端签发一个Token前端每次请求都带上后端通过拦截器解析鉴权。好处是服务端无状态但问题也很明显Token一旦签发服务端没法主动让它失效。你改了用户权限、禁用了账号、踢了下线只要Token没到期用户依然能访问接口。这是我在实际项目里最痛苦的体验之一。解决思路很简单把Token的状态存到Redis里。用户登录后生成一个会话KeyJWT信息、用户基本信息、权限快照都放进去鉴权时先查Redis里的“会话是否还在”。这样账号禁用、权限变动、强制下线都能实时生效。eladmin的登录逻辑本身是基于在线用户管理的如果配合Redis做会话存储整个认证链路的可控性会提升一个档次。1.2 Redis在eladmin里的四种典型角色我把Redis在一个成熟eladmin项目里承担的工作分成四类缓存层字典数据、部门树、角色权限、菜单、系统配置项这类读多写少的数据全部放进Redis降低数据库压力。会话与临时数据登录Token、验证码、短信验证码、文件上传分片信息、导入导出的临时文件索引。分布式协调定时任务防重执行、库存扣减、操作幂等、分布式锁。计数器与限流登录失败次数统计、短信发送频率控制、接口调用频控依赖Redis的原子自增操作。这四类场景在eladmin里几乎都能找到对应的业务入口。你会发现Redis在后台系统里不是“可选项”而是“基础设施”。1.3 引入Redis前后的一次对比我团队当时做了一个很小的压测同样的字典查询接口数据库直连查询平均12ms命中Redis缓存之后平均0.8msQPS从900左右升到接近7000。这个差距在单机环境不明显但一旦上了集群、数据量上来缓存层的存在就是生死线。而且Redis本身是单线程模型IO多路复用性能非常稳定配合Spring Boot Cache抽象层几乎不用在业务代码里写很重的缓存逻辑。提示eladmin的依赖里其实已经引入了spring-boot-starter-data-redis但很多分支版本没有把序列化器配好。第一步不是写业务而是把RedisTemplate的序列化配置修正否则后面全是乱码和ClassCastException。2. RedisTemplate序列化配置接入eladmin的第一个分水岭2.1 默认JDK序列化带来的乱码问题我见过太多eladmin项目Redis里长这样一堆以\xAC\xED\x00\x05t...开头的keyvalue用Redis Desktop Manager打开全是乱码。这是因为Spring Boot默认的RedisTemplate用的是JdkSerializationRedisSerializer它会把对象序列化成二进制字节不仅肉眼不可读还会因为类结构变化导致反序列化失败。另外JDK序列化会附带大量类元信息一条简单的用户缓存可能膨胀到几KB内存浪费严重。这在开发阶段还能忍上了生产就是事故。比如你发版时改了实体类的字段旧缓存的二进制流反序列化直接抛异常缓存层级崩溃数据库瞬间被打爆。2.2 用GenericJackson2JsonRedisSerializer做全局序列化eladmin的RedisConfig应该重写两个BeanRedisTemplate和StringRedisTemplate。核心思路是key序列化用StringRedisSerializer保证可读性。value序列化用GenericJackson2JsonRedisSerializer把对象转成JSON字符串存储兼容性好且能通过class字段保存类型信息反序列化时不丢类型。hash的key和value也分别指定序列化器。一个我实际在用的配置如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 使用 String 序列化 StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有个细节GenericJackson2JsonRedisSerializer默认会把类型信息写在JSON的class字段里。如果你缓存的对象里有接口类型、父类引用或者泛型反序列化时这一手非常关键。但它也不是万能的遇到LocalDateTime这类Java 8时间类型时容易出问题一个常见的坑是序列化后变成数组格式。我在eladmin里处理这种场景时会在ObjectMapper里注册JavaTimeModule并调整日期序列化的格式避免前后端解析不一致。2.3 缓存key的设计规范序列化只是第一步key的命名同样重要。Redis是键值存储没有表结构约束key设计是否规范直接决定后续运维的体验。我在eladmin里定的规范是业务域:模块:唯一标识例如auth:token:{userId}用户会话sys:dict:{dictName}字典缓存sys:dept:tree部门树缓存lock:task:{taskName}定时任务的分布式锁这样用Redis Desktop Manager或者命令行SCAN匹配前缀时非常清晰也能通过命名空间做批量清理。另外key尽量短一些能用ID就不拼长字符串节省内存也提高查询效率。2.4 配置完成后的最小验证配置改完后不要急着写业务先做一个最小验证SpringBootTest public class RedisTest { Resource private RedisTemplateString, Object redisTemplate; Test public void test() { redisTemplate.opsForValue().set(auth:token:1, hello-redis); Object value redisTemplate.opsForValue().get(auth:token:1); System.out.println(value); } }跑通这一步再去Redis客户端看key是否可读、value是不是JSON字符串。如果看到类似hello-redis的文本内容就说明序列化配置生效了。提示不要用RedisTemplate直接存泛型对象比如ListUser如果JSON里只存了class到List内部元素类型容易丢失。稳妥做法是自定义一个带类型包装的DTO或者用redisTemplate.opsForValue().set(key, JSON.toJSONString(list))配合反序列化手动指定类型。3. 缓存注解和RedisUtils两种缓存方式的边界3.1 Cacheable适合哪些业务eladmin里大量使用Spring Cache的注解缓存比如Cacheable、CacheEvict。注解方式的好处是侵入性小业务代码里不用手写存取逻辑。适合以下几类字典类型列表按类型名缓存。部门树、菜单树按用户ID缓存。系统配置项按配置键缓存。角色权限集合按角色ID缓存。一个典型例子Cacheable(value sys:dict, key #type, unless #result null) public ListDictDetail getDictDetails(String type) { // 数据库查询 }这样第一次查询后结果进入Redis后续请求直接命中缓存。但注解缓存有几个隐藏问题我后面还会讲失效时间不好精细控制、缓存击穿场景下注解帮不上忙、更新时机容易乱。3.2 eladmin里的RedisUtils手动缓存eladmin原生的RedisUtils工具类是非常实用的封装它把常用的set、get、delete、exists、expire都包了一遍并在设置值时自动指定过期时间。对于简单的数据我建议直接用这个工具类而不是每个地方都写redisTemplate.opsForValue().xxx()。手动缓存适合以下场景数据短生命周期比如登录验证码5分钟有效。需要精细控制过期时间。缓存数据需要在业务中频繁更新比如在线用户状态。数据缓存与数据库更新之间需要手动维护一致性注解方式很难表达“先改库再删缓存”的顺序。举个eladmin里的实际例子登录成功之后把用户信息塞进Redis设置30分钟过期每次请求时刷新过期时间实现“滑动续期”。这个需求用Cacheable做不到因为注解缓存不支持动态刷新TTL但RedisUtils可以轻松完成。3.3 缓存更新机制优先删而不是更新这是我吃了亏才明白的道理。很多人写完缓存后想当然地认为数据库更新后把Redis值也更新就好了。但缓存更新的路数很多组合方式容易出错。比如先更新数据库还是先更新缓存、失败了怎么办、并发下会不会读到旧值。eladmin业务里我统一采用“先更新数据库再删除缓存”的策略。下一请求发现缓存未命中再回源数据库重建缓存。这个策略在大多数读多写少的后台系统里足够用而且天然避免了“写缓存失败导致数据不一致”的问题。删除缓存这个动作本身也是幂等的就算删晚了也只是多一次数据库查询。3.4 字典缓存的完整案例以eladmin的字典模块为例它的查询频率很高但字典数据基本稳定。如果每次查字典都走数据库查询接口响应会受DB性能影响。我的处理方式启动时预热项目启动后调用一次字典查询接口把常用字典写进Redis。查询时先查缓存命中直接返回。后台编辑字典后调用删除缓存接口保证下次查询回源。这里有一个细节分页查询的字典列表不适合直接整体缓存因为分页排序条件太多缓存命中率低还会造成缓存key爆炸。我只对“按字典名称查询所有明细”这种固定场景做缓存列表页不缓存Excel导出也不缓存。缓存不是越多越好命中率低的缓存不仅浪费内存还增加了维护成本。4. 分布式锁从缓存组件到协调组件4.1 为什么单机锁不够eladmin里的定时任务、库存扣减类操作在单机部署时用Synchronized或ReentrantLock没问题。但项目上了集群同样的定时任务会在每个节点各执行一遍造成重复数据。比如定时同步订单状态如果两台机器同时跑数据库可能被更新两次产生脏数据。单机锁只对本进程生效这时就需要一个跨进程的锁分布式锁。Redis实现分布式锁是最常见的方案因为它本身是共享存储所有节点都能访问且提供了原子操作。4.2 用Redis手写一个可用的分布式锁最简单可靠的方案就是基于SET key value NX EX seconds这个命令能同时保证“key不存在才设置”和“过期时间自动设置”。用RedisTemplate实现如下public boolean tryLock(String key, String requestId, long expireSeconds) { // NX: 不存在才设置 EX: 设置过期时间 // 注意 SET_IF_ABSENT NX, SET_ABSENT_TIME EX Boolean result redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(result); } public boolean releaseLock(String key, String requestId) { // 使用Lua脚本保证“校验持有者再删除”是原子操作 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); Long result redisTemplate.execute(redisScript, List.of(key), requestId); return Long.valueOf(1).equals(result); }这里有两个关键点requestId可以是UUID是锁持有者的唯一标识。释放锁时必须校验这个标识防止“自己不持有的锁被自己删掉”。用Lua脚本包裹“检查-删除”操作保证原子性。如果拆成两步可能在GET之后、DEL之前锁到期被另一个线程抢走然后你把别人的锁删了。4.3 锁的续期问题和Redisson选择手写锁有个麻烦业务执行时间超过锁的过期时间怎么办比如加锁时设了30秒但业务跑了40秒锁自动释放另一个线程进来了。解决思路是续期——在锁即将到期但业务还没结束时自动延长过期时间。这个逻辑很烦不建议手写。我在这块直接引入了Redisson它内置了看门狗机制默认锁续期到30秒每10秒检查一次业务没结束就自动续期业务结束就释放锁。代码反而简单很多RLock lock redissonClient.getLock(lock:task:syncOrder); boolean isLocked lock.tryLock(3, 10, TimeUnit.SECONDS); if (isLocked) { try { // 业务逻辑 } finally { lock.unlock(); } }引入Redisson的代价是加一个依赖和一点内存但省去了自己处理续期、重入、异常释放的杂活。eladmin里如果已经有Redisson依赖优先用它的tryLock如果没有手写一个简单的也够用。提示手写锁务必设置过期时间绝不能只用setIfAbsent(key, value)不带过期时间。一旦分布式锁的key没有过期时间而持有锁的服务宕机这个锁就永远解不开了下游业务全部卡死。4.4 锁定在eladmin里的实际场景我在eladmin里落地分布式锁最多的场景是定时任务。eladmin有定时任务管理模块可以动态启停任务但任务本身可能被多个节点同时触发。处理方式任务执行入口先尝试获取锁。抢到锁的节点执行任务没抢到的节点直接跳过。执行完成后主动释放锁。另外批量导入导出、文件分片合并、订单状态机转换这类场景也需要锁。核心思路是凡是“多实例下同一时间只允许一个执行”的操作都值得用分布式锁。5. 缓存三大故障现场穿透、击穿、雪崩的解决实录5.1 缓存穿透不存在的key疯狂打库eladmin里权限校验接口如果被恶意刷每次都会携带一个不存在的用户ID查数据库Redis里始终没有缓存数据库压力直线上升。这种查询一个“必然不存在的数据”的现象就是缓存穿透。最常用的两个办法缓存空值查询结果为空也写缓存设置一个较短的过期时间比如60秒这样后续相同Key不会打到DB。但要注意空值过多会占用内存需要设置过期时间。布隆过滤器把所有合法的用户ID加载到布隆过滤器查询前先判断ID是否存在不存在直接返回。适用于ID集合较小且相对固定的场景。我在eladmin里处理验证码校验接口时用的就是缓存空值方案验证码错误就缓存一个错误标记过期时间比验证码本身还短。这样既保护了数据库又不会影响正常用户的重新发送。5.2 缓存击穿热点key瞬间过期打崩DB缓存击穿和穿透容易混淆击穿指的是某个热点key在过期瞬间大量并发请求同时回源数据库。比如eladmin的系统配置缓存如果刚好在零点过期所有请求都去查数据库一次就够数据库喝一壶。解决手段通常有两种互斥重建用分布式锁包住回源逻辑只允许一个线程回源数据库写缓存其他线程等待缓存重建完成。缺点是锁等待会增加接口耗时。逻辑过期缓存中不设置物理过期时间而是存一个逻辑过期戳。查询时发现戳已经过期就异步重建。优点是接口永远有数据可返回但实现稍复杂。在eladmin的字典缓存里我采用的是折中方案热点key的物理过期时间拉长到小时级后端修改字典后通过ActiveMQ或者直接调用删除缓存接口来维护一致性避免“自然过期”引发击穿。这个方案简单但依赖“删除操作必须可靠”。5.3 缓存雪崩大量key同一时刻失效雪崩和击穿的区别在于影响范围。雪崩是大批key在同一时间过期或者Redis服务整体不可用导致数据库瞬间被冲垮。解决办法我总结三条过期时间加随机抖动比如基础过期时间300秒实际设置时加一个0到60秒的随机数避免同一秒集体失效。eladmin里的缓存工具类我已经统一封装了这个逻辑。多级缓存配合Redis之上加一层本地缓存Caffeine本地缓存失效再查RedisRedis失效再查DB。这样即使Redis短时间不可用本地缓存还能扛一部分。Redis高可用部署主从加哨兵或者Redis Cluster避免单点故障。这个我放在第6节详细讲。5.4 曾经踩过的坑INCR在分布式环境下的“不准”热词里有一条“redis incr不准”我在eladmin里也遇到过。场景是短信验证码发送频率限制Long count redisTemplate.opsForValue().increment(limit:sms: phone); redisTemplate.expire(limit:sms: phone, 60, TimeUnit.SECONDS);这段代码在单机Redis下没有问题。但在主从架构下主机自增后同步给从机如果主机宕机、从机顶上自增的值可能丢失或重复导致计数不准。解决方案有几个使用Redis Cluster的单一分片锁住key的哈希槽保证只有一台机器处理该key。频率限制场景可以接受误差时改成SETNX 过期时间判断逻辑。或者在Redis事务Pipeline中执行减少错误概率。另外INCR和EXPIRE是两个独立命令如果想让key自动过期最好用Lua脚本把两个操作包起来保证原子性否则极端情况下可能key永不过期计数累加超过预期。提示eladmin的接口限流功能如果用了IP计数千万不要用get再set的复合操作必须用原生INCR。两步操作在并发下会丢失增量计数这是非常隐蔽的一类bug。6. 从单机到集群eladmin上线后的Redis部署升级路线6.1 单机版够用吗开发环境和日活几千的小项目单机Redis完全够用。eladmin这种后台系统数据库字段多、接口读写比例高单台8G内存的Redis实例支撑几百个并发是没问题的。但是单机的问题在于故障域。Redis宕机所有缓存全部失效数据库被瞬时打满整个系统雪崩。而且一台机器上Redis进程如果OOMRedis会直接拒绝写入进而影响所有依赖Redis的业务。因此正规一点的项目都会考虑主从。6.2 主从加哨兵怎么配更稳主从模式解决的是“单点故障”哨兵解决的是“自动故障转移”。我在eladmin生产环境用的一主二从加三哨兵架构主节点负责读写。从节点负责读扩展和备份。哨兵监控主从状态主节点宕机后自动从从节点选举出一个新主节点修改客户端配置的地址指向新主节点。Spring Boot里配置Sentinel模式的地址很简单spring: data: redis: sentinel: master: mymaster nodes: - 192.168.1.10:26379 - 192.168.1.11:26379 - 192.168.1.12:26379注意哨兵模式下应用连接的是哨兵地址而不是Redis节点地址。哨兵会告诉你当前可写的主节点是谁。这块如果配错应用会一直连不上Redis日志里反复报Connection refused。主从复制还有一个核心点需要注意replica-read-only建议设置为yes。从节点只做读写操作全部走主节点否则可能会发生数据不一致。6.3 什么时候值得上ClusterRedis Sentinel解决的是“故障转移”但单节点的内存容量上限还在。当缓存数据量大到单节点内存装不下或者写入并发超过单实例上限时就该考虑Redis Cluster。Cluster采用分片存储每个key根据CRC16算法映射到16384个哈希槽中的某一个然后哈希槽分配给不同节点。好处是容量和性能都线性扩展坏处是配置复杂、客户端要支持Cluster模式且Redis Cluster不支持多key操作除非这些key在同一个Hash标签下。eladmin项目的缓存体量通常到不了Cluster的规模但如果未来做多租户、数据量暴涨可以提前把key的Hash Tag设计好比如把所有需要一起操作的key都加上{user:1}这样的Hash前缀确保它们落在同一个槽里。这个设计越早做越省事。6.4 Docker部署Redis时的常见错误很多团队用Docker部署Redis热词里提到的“docker search redis request returned 500 internal server error”我碰到过多次。这个报错通常是Docker Desktop的引擎没起来或者API版本不兼容解决办法是重启Docker Desktop或者检查Docker引擎的版本。真正进入docker pull redis阶段之后还需要注意挂载持久化目录比如/data防止容器重启后数据丢失。指定Redis密码和配置文件不要用默认无密码的配置。如果宿主机和Redis容器不在同一网络需要注意防火墙和端口映射。用Docker Compose部署一主二从加哨兵集群是本地复现和学习的好方式比手动敲命令省心很多。但这个更多是运维话题本文就不展开了。7. 体检清单慢查询、大key和热点key的排查手段7.1 Redis慢查询日志怎么看Redis的慢查询日志和MySQL的slow query log思路类似。配置项slowlog-log-slower-than设置超时阈值单位微秒slowlog-max-len设置日志条数。执行SLOWLOG GET可以查看最近慢查询命令。eladmin的缓存基本都是小Key很少有长命令但如果有KEYS *这类命令出现在慢查询里基本可以断定代码有问题。Redis是单线程的一个耗时的KEYS命令会阻塞所有客户端响应线上绝不能执行。我见过有人用KEYS auth:token:*来批量清理用户会话导致Redis卡了几秒所有依赖缓存的接口全部超时。正确做法是用SCAN游标遍历分批次删除。7.2 大key是隐藏的内存炸弹大key通常指value特别大的key比如一个Hash里塞了几十万个字段或者一个String存了几MB的数据。大key会导致Redis内存碎片增加、删除/序列化耗时变长还会在主从复制时造成网络带宽压力。在eladmin里最常见的两个大key场景缓存的用户权限集合没有控制大小一个超管用户的权限菜单串成一个大JSON。字典缓存把所有数据一股脑塞进一个key而不是按类型拆分。排查手段用redis-cli --bigkeys它会扫描找出大key并给出类型和建议。治理方式很直接大key拆小key、续期策略调整、数据结构优化。比如权限集合按角色拆分字典按类型拆分。7.3 内存淘汰策略的选择Redis内存写满后怎么办配置maxmemory-policy决定了淘汰策略。后台管理系统里我推荐volatile-lru只在设置了过期时间的key里淘汰最近最少使用的数据。这样业务缓存是“可淘汰”的但正在使用的会话数据不会被强制清掉。相比之下allkeys-lru会对所有key做淘汰包括你不想丢的业务缓存可能导致关键数据意外消失。还有一点maxmemory不要设成Redis所在服务器的全部内存要留出一部分给操作系统、其他进程防止Redis OOM把整台服务器压垮。我一般给Redis分配机器内存的60%-70%左右。7.4 可视化工具和日常巡检建议命令行工具就是redis-cli图形化工具我常用Another Redis Desktop Manager和Redis Insight。选工具主要看是否支持连接哨兵、Cluster、SSH隧道。日常巡检建议做成定时脚本每天扫一遍INFO memory查看内存使用率。INFO replication查看主从复制状态确认没有断连。INFO stats看连接数和命中率。SLOWLOG GET 50检查慢命令。这些指标配合Prometheus和Grafana可以做到很完善的监控但哪怕没有专业监控系统定时敲一下命令也比完全不看强一百倍。Redis挂了不可怕可怕的是你不知道它什么时候开始变慢、内存什么时候开始飙升。结尾经验这套东西从头到尾梳理下来我觉得在eladmin里用好Redis的关键只有两点一是序列化和key设计必须在一开始就定好规范这个决定后续所有缓存代码的体验二是把分布式锁、缓存击穿、集群部署这些“进阶能力”当作标配去考虑而不是等出了事故再补。我经历过Redis配置乱码导致生产环境接口直接500的夜晚也经历过分布式锁误删把定时任务数据弄乱的尴尬。现在已经养成的习惯是任何redisTemplate的读写都先问自己“key规范吗序列化对吗过期时间设了吗这行代码在集群下还成立吗”这四个问题能挡住绝大部分线上事故。希望这篇实践对正在折腾eladmin的朋友有帮助。
返回列表