【技术派后端篇】技术派中基于 Redis 的缓存实践

在互联网应用追求高并发和高可用的背景下,缓存对于提升程序性能至关重要。相较于本地缓存 Guava Cache 和 Caffeine,Redis 具有显著优势。Redis 支持集群和分布式部署,能横向扩展缓存容量和负载能力,适应大型分布式系统的缓存需求;支持数据持久化存储,可将缓存数据存于磁盘,保障数据不丢失;支持多种数据结构,如字符串、哈希表、列表、集合和有序集合等,提供更灵活的缓存能力;具备主从同步和哨兵机制,实现高可用性和容错能力;还提供丰富的数据处理命令,如排序、聚合、管道和 Lua 脚本等,便于高效处理缓存数据。

Redis 本质上是一个开源的基于内存的 NoSQL 数据库,更适合作为数据库前的缓存层组件。它支持多种数据结构,如 String、List、Set、Hash、ZSet 等,并且支持数据持久化,通过快照和日志将内存数据保存到硬盘,重启后可再次加载使用,其主从复制、哨兵等特性使其成为广受欢迎的缓存中间件。

在 Java 后端开发中,Redis 是面试常考的技术栈之一,是开发四大件(Java 基础、Spring Boot、MySQL、Redis)之一,因此在开发和学习中应扎实掌握 Redis 相关知识。

1 Redis 与 Spring Boot 的整合

  1. 添加依赖:在 pom.xml 中添加 Redis 依赖,Spring Boot 默认使用 Lettuce 作为 Redis 连接池,可避免频繁创建和销毁连接,提升应用性能和可靠性。

    xml 复制代码
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  2. 配置 Redis:在 application.yml 中进行配置,本地配置指定 host 和 port 即可,生产环境配置可参考相关教程。

    yaml 复制代码
    redis:
      host: localhost
      port: 6379
      password:
  3. 启动 Redis 服务 :以Window系统为例,使用终端工具启动 Redis 服务,可通过 redis-cli ping 检查 Redis 服务是否安装和运行正常,使用 redis-server 启动服务,默认端口为 6379。

  4. 编写测试类 :编写 Redis 测试类 RedisTemplateDemo,快速验证 Redis 在项目中的可用性。

    java 复制代码
    @SpringBootTest(classes = QuickForumApplication.class)
    public class RedisTemplateDemo {
        @Autowired
        private RedisTemplate<Object, Object> redisTemplate;
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Test
        public void testPut() {
            redisTemplate.opsForValue().set("itwanger", "沉默王二");
            stringRedisTemplate.opsForList().rightPush("girl", "陈清扬");
            stringRedisTemplate.opsForList().rightPush("girl", "小转玲");
            stringRedisTemplate.opsForList().rightPush("girl", "茶花女");
        }
    
        @Test
        public void testGet() {
            Object value = redisTemplate.opsForValue().get("itwanger");
            System.out.println(value);
            List<String> girls = stringRedisTemplate.opsForList().range("girl", 0, -1);
            System.out.println(girls);
        }
    }

@SpringBootTest(classes = QuickForumApplication.class) 注解指定项目启动类,@Autowired 注解注入 RedisTemplateStringRedisTemplateRedisTemplate 可操作任意类型数据,StringRedisTemplate 仅能操作字符串类型数据。testPut() 方法分别操作 Redis 中的字符串和列表类型数据,testGet() 方法获取相应数据。

2 在 Spring Boot 中使用 Redis 操作不同数据结构

  1. 字符串 :注入 RedisTemplate,使用 opsForValue() 方法获取操作对象,通过 set() 设置键值对,get() 获取值。

    java 复制代码
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void set(String key, Object value) {
        redisTemplate.opsForValue().set(key, value);
    }
    
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }
  2. 列表 :注入 StringRedisTemplate,利用 opsForList() 方法获取操作对象,rightPush() 向列表右侧添加元素,range() 获取指定下标范围元素。

    java 复制代码
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    public void push(String key, String value) {
        stringRedisTemplate.opsForList().rightPush(key, value);
    }
    
    public List<String> range(String key, int start, int end) {
        return stringRedisTemplate.opsForList().range(key, start, end);
    }
  3. 哈希 :使用 opsForHash() 方法获取操作对象,put() 添加字段和值,get() 获取指定字段值。

    java 复制代码
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void hset(String key, String field, Object value) {
        redisTemplate.opsForHash().put(key, field, value);
    }
    
    public Object hget(String key, String field) {
        return redisTemplate.opsForHash().get(key, field);
    }
  4. 集合 :通过 opsForSet() 方法获取操作对象,add() 添加元素,members() 获取所有元素。

    java 复制代码
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    public void sadd(String key, String value) {
        stringRedisTemplate.opsForSet().add(key, value);
    }
    
    public Set<String> smembers(String key) {
        return stringRedisTemplate.opsForSet().members(key);
    }
  5. 有序集合 :使用 opsForZSet() 方法获取操作对象,add() 添加元素和分值,range() 获取指定下标范围元素。

    java 复制代码
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    public void zadd(String key, String value, double score) {
        redisTemplate.opsForZSet().add(key, value, score);
    }
    
    public Set<Object> zrange(String key, long start, long end) {
        return redisTemplate.opsForZSet().range(key, start, end);
    }

3 技术派中的 Redis 实例应用

技术派使用 Redis 缓存用户 session 信息和 sitemap(用于帮助搜索引擎更好索引网站内容的 XML 文件)。

3.1 RedisClient 类

基于 RedisTemplate 封装,简化使用成本。代码路径:com.github.paicoding.forum.core.cache.RedisClient

ForumCoreAutoConfig 配置类通过构造方法注入 RedisTemplate,并调用 RedisClient.register(redisTemplate) 注册到 RedisClient 中,RedisTemplate 由 Spring Boot 自动配置机制注入。

java 复制代码
private static RedisTemplate<String, String> template;

public static void register(RedisTemplate<String, String> template) {
    RedisClient.template = template;
}
java 复制代码
public class ForumCoreAutoConfig {
    @Autowired
    private ProxyProperties proxyProperties;

    public ForumCoreAutoConfig(RedisTemplate<String, String> redisTemplate) {
        RedisClient.register(redisTemplate);
    }

3.2 用户 session 相关操作

  • 验证码校验成功后,调用 RedisClientsetStrWithExpire 方法存储 session,该方法使用 RedisTemplateexecute 方法,通过 RedisCallback 接口的 doInRedis 方法执行 Redis 命令,调用 RedisConnectionsetEx 方法设置键值对及过期时间。
java 复制代码
/**
 * 带过期时间的缓存写入
 *
 * @param key
 * @param value
 * @param expire s为单位
 * @return
 */
public static Boolean setStrWithExpire(String key, String value, Long expire) {
    return template.execute(new RedisCallback<Boolean>() {
        @Override
        public Boolean doInRedis(RedisConnection redisConnection) throws DataAccessException {
            return redisConnection.setEx(keyBytes(key), expire, valBytes(value));
        }
    });
}
  • 用户登出时,调用 del 方法删除 session。
java 复制代码
public static void del(String key) {
    template.execute((RedisCallback<Long>) con -> con.del(keyBytes(key)));
}
  • 用户登录时,调用 getStr 方法根据 session 获取用户 ID。
java 复制代码
public static String getStr(String key) {
    return template.execute((RedisCallback<String>) con -> {
        byte[] val = con.get(keyBytes(key));
        return val == null? null : new String(val);
    });
}

具体调用代码位于com.github.paicoding.forum.service.user.service.help.UserSessionHelper

java 复制代码
/**
 * 使用jwt来存储用户token,则不需要后端来存储session了
 */
@Slf4j
@Component
public class UserSessionHelper {
    @Component
    @Data
    @ConfigurationProperties("paicoding.jwt")
    public static class JwtProperties {
        /**
         * 签发人
         */
        private String issuer;
        /**
         * 密钥
         */
        private String secret;
        /**
         * 有效期,毫秒时间戳
         */
        private Long expire;
    }

    private final JwtProperties jwtProperties;

    private Algorithm algorithm;
    private JWTVerifier verifier;

    public UserSessionHelper(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
        algorithm = Algorithm.HMAC256(jwtProperties.getSecret());
        verifier = JWT.require(algorithm).withIssuer(jwtProperties.getIssuer()).build();
    }

    public String genSession(Long userId) {
        // 1.生成jwt格式的会话,内部持有有效期,用户信息
        String session = JsonUtil.toStr(MapUtils.create("s", SelfTraceIdGenerator.generate(), "u", userId));
        String token = JWT.create().withIssuer(jwtProperties.getIssuer()).withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getExpire()))
                .withPayload(session)
                .sign(algorithm);

        // 2.使用jwt生成的token时,后端可以不存储这个session信息, 完全依赖jwt的信息
        // 但是需要考虑到用户登出,需要主动失效这个token,而jwt本身无状态,所以再这里的redis做一个简单的token -> userId的缓存,用于双重判定
        RedisClient.setStrWithExpire(token, String.valueOf(userId), jwtProperties.getExpire() / 1000);
        return token;
    }

    public void removeSession(String session) {
        RedisClient.del(session);
    }

    /**
     * 根据会话获取用户信息
     *
     * @param session
     * @return
     */
    public Long getUserIdBySession(String session) {
        // jwt的校验方式,如果token非法或者过期,则直接验签失败
        try {
            DecodedJWT decodedJWT = verifier.verify(session);
            String pay = new String(Base64Utils.decodeFromString(decodedJWT.getPayload()));
            // jwt验证通过,获取对应的userId
            String userId = String.valueOf(JsonUtil.toObj(pay, HashMap.class).get("u"));

            // 从redis中获取userId,解决用户登出,后台失效jwt token的问题
            String user = RedisClient.getStr(session);
            if (user == null || !Objects.equals(userId, user)) {
                return null;
            }
            return Long.valueOf(user);
        } catch (Exception e) {
            log.info("jwt token校验失败! token: {}, msg: {}", session, e.getMessage());
            return null;
        }
    }
}

3.3 sitemap 相关操作

  • 获取 sitemap 时,调用 hGetAll 方法,使用 RedisTemplateexecute 方法执行 Redis 命令,通过 RedisCallback 接口的 doInRedis 方法调用 RedisConnectionhGetAll 方法获取哈希表所有字段和值,并进行类型转换后返回。
java 复制代码
    public static <T> Map<String, T> hGetAll(String key, Class<T> clz) {
        Map<byte[], byte[]> records = template.execute((RedisCallback<Map<byte[], byte[]>>) con -> con.hGetAll(keyBytes(key)));
        if (records == null) {
            return Collections.emptyMap();
        }

        Map<String, T> result = Maps.newHashMapWithExpectedSize(records.size());
        for (Map.Entry<byte[], byte[]> entry : records.entrySet()) {
            if (entry.getKey() == null) {
                continue;
            }

            result.put(new String(entry.getKey()), toObj(entry.getValue(), clz));
        }
        return result;
    }
  • 添加文章时,调用 hSet 方法,使用 RedisTemplateexecute 方法,通过 RedisCallback 接口的 doInRedis 方法,根据值的类型转换为字符串后调用 RedisConnectionhSet 方法设置哈希表字段值,返回设置结果。
java 复制代码
public static <T> T hGet(String key, String field, Class<T> clz) {
    return template.execute((RedisCallback<T>) con -> {
        byte[] records = con.hGet(keyBytes(key), valBytes(field));
        if (records == null) {
            return null;
        }

        return toObj(records, clz);
    });
}
  • 移除文章时,调用 hDel 方法,使用 RedisTemplateexecute 方法,通过 RedisCallback 接口的 doInRedis 方法调用 RedisConnectionhDel 方法删除字段,返回删除结果。
java 复制代码
public static <T> Boolean hDel(String key, String field) {
    return template.execute(new RedisCallback<Boolean>() {
        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
            return connection.hDel(keyBytes(key), valBytes(field)) > 0;
        }
    });
}
  • 初始化 sitemap 时,先调用 del 方法,再调用 hMSet 方法,hMSet 方法使用 RedisTemplateexecute 方法,通过 RedisCallback 接口的 doInRedis 方法调用 RedisConnectionhMSet 方法一次性设置多个哈希表字段值。
java 复制代码
/**
 * fixme: 加锁初始化,更推荐的是采用分布式锁
 */
private synchronized void initSiteMap() {
    long lastId = 0L;
    RedisClient.del(SITE_MAP_CACHE_KEY);
    while (true) {
        List<SimpleArticleDTO> list = articleDao.getBaseMapper().listArticlesOrderById(lastId, SCAN_SIZE);
        // 刷新文章的统计信息
        list.forEach(s -> countService.refreshArticleStatisticInfo(s.getId()));

        // 刷新站点地图信息
        Map<String, Long> map = list.stream().collect(Collectors.toMap(s -> String.valueOf(s.getId()), s -> s.getCreateTime().getTime(), (a, b) -> a));
        RedisClient.hMSet(SITE_MAP_CACHE_KEY, map);
        if (list.size() < SCAN_SIZE) {
            break;
        }
        lastId = list.get(list.size() - 1).getId();
    }
}

为提升搜索引擎对技术派的收录,开发了 sitemap 自动生成工具,在 SitemapServiceImpl 中通过定时任务每天 5:15 分刷新站点地图,确保数据一致性。

java 复制代码
 /**
  * 采用定时器方案,每天5:15分刷新站点地图,确保数据的一致性
  */
 @Scheduled(cron = "0 15 5 * * ?")
 public void autoRefreshCache() {
     log.info("开始刷新sitemap.xml的url地址,避免出现数据不一致问题!");
     refreshSitemap();
     log.info("刷新完成!");
 }

4 关于 RedisTemplate 的 execute 方法

RedisTemplateexecute(RedisCallback<T> action) 方法用于执行任意 Redis 命令,接收 RedisCallback 接口作为参数,将 Redis 连接传递给回调接口执行命令。

java 复制代码
@Nullable
public <T> T execute(RedisCallback<T> action) {
    return this.execute(action, this.isExposeConnection());
}

以下是测试用例示例:

java 复制代码
@Test
public void testExecute() {
    redisTemplate.execute(new RedisCallback<Object>() {
        @Override
        public Object doInRedis(RedisConnection connection) throws DataAccessException {
            connection.set("itwanger".getBytes(), "沉默王二".getBytes());
            byte[] value = connection.get("itwanger".getBytes());
            String strValue = new String(value);
            System.out.println(strValue);
            return null;
        }
    });
}

5 总结

Redis 是高性能的内存数据存储系统,支持多种数据结构。在 Spring Boot 中使用 Redis 作为缓存可提升应用性能和响应速度,通过 spring-boot-starter-data-redis 整合 Redis 操作简便。可使用 RedisTemplate 执行各种 Redis 命令,技术派使用 Redis 缓存 session 和 sitemap,应用了字符串和哈希表数据结构。未来将进一步介绍 Redis 其他数据结构和高级功能,如发布/订阅、事务、Lua 脚本等。

6 参考链接

  1. 技术派Redis的缓存示例
  2. 项目仓库(GitHub)
  3. 项目仓库(码云)
相关推荐
!!!52534 分钟前
MongoDB 集合名称映射问题
数据库·mongodb
Z_z在努力1 小时前
【Redis】Redis 特性
数据库·redis
@PHARAOH2 小时前
WHAT - 静态资源缓存穿透
缓存
QD.Joker2 小时前
Django ORM 定义模型
数据库·django
声声codeGrandMaster3 小时前
django之数据的翻页和搜索功能
数据库·后端·python·mysql·django
爱码驱动3 小时前
MySQL快速入门篇---库的操作
数据库·mysql
HackerKevn3 小时前
【上海大学数据库原理实验报告】MySQL数据库的C/S模式部署
数据库·mysql
normaling3 小时前
九,Redis通过BitMap实现用户签到
redis
normaling3 小时前
五,redis实现优惠卷秒杀(消息队列,分布式锁,全局id生成器,lua脚本)
redis
normaling3 小时前
七,Redis实现共同关注和关注推送
redis