• Redis 学习笔记 3:黑马点评


    Redis 学习笔记 3:黑马点评

    准备工作

    需要先导入项目相关资源:

    启动后端代码和 Nginx

    短信登录

    发送验证码

    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    @Log4j2
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    
        @Override
        public Result sendCode(String phone, HttpSession session) {
            if (RegexUtils.isPhoneInvalid(phone)) {
                return Result.fail("不是合法的手机号!");
            }
            String code = RandomUtil.randomNumbers(6);
            session.setAttribute("code", code);
            // 发送短信
            log.debug("发送短信验证码:{}", code);
            return Result.ok();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    登录

    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // 实现登录功能
        return userService.login(loginForm, session);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 验证手机号和验证码
        if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
            return Result.fail("手机号不合法!");
        }
        String code = (String) session.getAttribute("code");
        if (code == null || !code.equals(loginForm.getCode())) {
            return Result.fail("验证码不正确!");
        }
        // 检查用户是否存在
        QueryWrapper<User> qw = new QueryWrapper<>();
        qw.eq("phone", loginForm.getPhone());
        User user = this.baseMapper.selectOne(qw);
        if (user == null) {
            user = this.createUserByPhone(loginForm.getPhone());
        }
        // 将用户信息保存到 session
        session.setAttribute("user", user);
        return Result.ok();
    }
    
    private User createUserByPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        user.setNickName("user_" + RandomUtil.randomString(5));
        this.baseMapper.insert(user);
        return user;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    统一身份校验

    定义拦截器

    public class LoginInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 从 session 获取用户信息
            HttpSession session = request.getSession();
            User user = (User) session.getAttribute("user");
            if (user == null) {
                response.setStatus(401);
                return false;
            }
            // 将用户信息保存到 ThreadLocal
            UserDTO userDTO = new UserDTO();
            userDTO.setIcon(user.getIcon());
            userDTO.setId(user.getId());
            userDTO.setNickName(user.getNickName());
            UserHolder.saveUser(userDTO);
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    添加拦截器:

    @Configuration
    public class WebMVCConfig implements WebMvcConfigurer {
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new LoginInterceptor())
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    使用 Redis 存储验证码和用户信息

    用 Session 存储验证码和用户信息的系统,无法进行横向扩展,因为多台 Tomcat 无法共享 Session。如果改用 Redis 存储就可以解决这个问题。

    修改后的 UserService:

    @Log4j2
    @Service
    public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    
        @Override
        public Result sendCode(String phone, HttpSession session) {
            if (RegexUtils.isPhoneInvalid(phone)) {
                return Result.fail("不是合法的手机号!");
            }
            String code = RandomUtil.randomNumbers(6);
            stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL);
            // 发送短信
            log.debug("发送短信验证码:{}", code);
            return Result.ok();
        }
    
        @Override
        public Result login(LoginFormDTO loginForm, HttpSession session) {
            // 验证手机号和验证码
            if (RegexUtils.isPhoneInvalid(loginForm.getPhone())) {
                return Result.fail("手机号不合法!");
            }
            String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
            if (code == null || !code.equals(loginForm.getCode())) {
                return Result.fail("验证码不正确!");
            }
            // 检查用户是否存在
            QueryWrapper<User> qw = new QueryWrapper<>();
            qw.eq("phone", loginForm.getPhone());
            User user = this.baseMapper.selectOne(qw);
            if (user == null) {
                user = this.createUserByPhone(loginForm.getPhone());
            }
            // 将用户信息保存到 session
            String token = UUID.randomUUID().toString(true);
            UserDTO userDTO = new UserDTO();
            BeanUtils.copyProperties(user, userDTO);
            try {
                stringRedisTemplate.opsForValue().set(LOGIN_USER_KEY + token,
                        OBJECT_MAPPER.writeValueAsString(userDTO), LOGIN_USER_TTL);
            } catch (JsonProcessingException e) {
                e.printStackTrace();
                throw new RuntimeException(e);
            }
            return Result.ok(token);
        }
    
        private User createUserByPhone(String phone) {
            User user = new User();
            user.setPhone(phone);
            user.setNickName("user_" + RandomUtil.randomString(5));
            this.baseMapper.insert(user);
            return user;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58

    修改后的登录校验拦截器:

    public class LoginInterceptor implements HandlerInterceptor {
        private final StringRedisTemplate stringRedisTemplate;
        private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    
        public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 从头信息获取 token
            String token = request.getHeader("Authorization");
            if (ObjectUtils.isEmpty(token)) {
                // 缺少 token
                response.setStatus(401);
                return false;
            }
            // 从 Redis 获取用户信息
            String jsonUser = this.stringRedisTemplate.opsForValue().get(LOGIN_USER_KEY + token);
            UserDTO userDTO = OBJECT_MAPPER.readValue(jsonUser, UserDTO.class);
            if (userDTO == null) {
                response.setStatus(401);
                return false;
            }
            // 将用户信息保存到 ThreadLocal
            UserHolder.saveUser(userDTO);
            // 刷新 token 有效期
            stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    还需要添加一个更新用户信息有效期的拦截器:

    public class RefreshTokenInterceptor implements HandlerInterceptor {
        private StringRedisTemplate stringRedisTemplate;
    
        public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
            this.stringRedisTemplate = stringRedisTemplate;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 如果请求头中有 token,且 redis 中有 token 相关的用户信息,刷新其有效期
            String token = request.getHeader("Authorization");
            if (ObjectUtils.isEmpty(token)) {
                return true;
            }
            if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(LOGIN_USER_KEY + token))) {
                stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL);
            }
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    添加这个新的拦截器,并且确保其位于登录验证拦截器之前:

    @Configuration
    public class WebMVCConfig implements WebMvcConfigurer {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                    .addPathPatterns("/**");
            registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                    .excludePathPatterns(
                            "/shop/**",
                            "/voucher/**",
                            "/shop-type/**",
                            "/upload/**",
                            "/blog/hot",
                            "/user/code",
                            "/user/login");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    商户查询

    缓存

    对商户类型查询使用 Redis 缓存以提高查询效率:

    @Service
    public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
    
        @Override
        public Result queryTypeList() {
            String jsonTypeList = stringRedisTemplate.opsForValue().get(CACHE_TYPE_LIST_KEY);
            if (!StringUtils.isEmpty(jsonTypeList)) {
                List<ShopType> typeList = JSONUtil.toList(jsonTypeList, ShopType.class);
                return Result.ok(typeList);
            }
            List<ShopType> typeList = this
                    .query().orderByAsc("sort").list();
            if (!typeList.isEmpty()){
                stringRedisTemplate.opsForValue().set(CACHE_TYPE_LIST_KEY, JSONUtil.toJsonStr(typeList), CACHE_TYPE_LIST_TTL);
            }
            return Result.ok(typeList);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    对商户详情使用缓存:

    @Service
    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
        private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
    
        @Override
        public Result queryById(Long id) {
            // 先从 Redis 中查询
            String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            if (!StringUtils.isEmpty(jsonShop)) {
                Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
                return Result.ok(shop);
            }
            // Redis 中没有,从数据库查
            Shop shop = this.getById(id);
            if (shop != null) {
                jsonShop = JSONUtil.toJsonStr(shop);
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
            }
            return Result.ok(shop);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    缓存更新策略

    在编辑商户信息时,将对应的缓存删除:

    @Override
    public Result update(Shop shop) {
        if (shop.getId() == null) {
            return Result.fail("商户id不能为空");
        }
        // 更新商户信息
        this.updateById(shop);
        // 删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    缓存穿透

    缓存穿透指如果请求的数据在缓存和数据库中都不存在,就不会生成缓存数据,每次请求都不会使用缓存,会对数据库造成压力。

    可以通过缓存空对象的方式解决缓存穿透问题。

    在查询商铺信息时缓存空对象:

    @Override
    public Result queryById(Long id) {
        // 先从 Redis 中查询
        String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (!StringUtils.isEmpty(jsonShop)) {
            Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
            return Result.ok(shop);
        }
        // Redis 中没有,从数据库查
        Shop shop = this.getById(id);
        if (shop != null) {
            jsonShop = JSONUtil.toJsonStr(shop);
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
            return Result.ok(shop);
        } else {
            // 缓存空对象到缓存中
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);
            return Result.fail("店铺不存在");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    在这里,缓存中的空对象用空字符串代替,并且将缓存存活时间设置为一个较短的值(比如说2分钟)。

    在从缓存中查询到空对象时,返回商铺不存在:

    @Override
    public Result queryById(Long id) {
        // 先从 Redis 中查询
        String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (!StringUtils.isEmpty(jsonShop)) {
            Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
            return Result.ok(shop);
        }
        // 如果从缓存中查询到空对象,表示商铺不存在
        if ("".equals(jsonShop)) {
            return Result.fail("商铺不存在");
        }
        // ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    缓存击穿

    缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

    常见的解决方案有两种:

    • 互斥锁
    • 逻辑过期

    可以利用 Redis 做互斥锁来解决缓存击穿问题:

    @Override
    public Result queryById(Long id) {
        //        return queryWithCachePenetration(id);
        return queryWithCacheBreakdown(id);
    }
    
    /**
         * 用 Redis 创建互斥锁
         *
         * @param name 锁名称
         * @return 成功/失败
         */
    private boolean lock(String name) {
        Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(name, "1", Duration.ofSeconds(10));
        return BooleanUtil.isTrue(result);
    }
    
    /**
         * 删除 Redis 互斥锁
         *
         * @param name 锁名称
         */
    private void unlock(String name) {
        stringRedisTemplate.delete(name);
    }
    
    /**
         * 查询店铺信息-缓存击穿
         *
         * @param id
         * @return
         */
    private Result queryWithCacheBreakdown(Long id) {
        // 先查询是否存在缓存
        String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (!StringUtils.isEmpty(jsonShop)) {
            Shop shop = JSONUtil.toBean(jsonShop, Shop.class);
            return Result.ok(shop);
        }
        // 如果从缓存中查询到空对象,表示商铺不存在
        if ("".equals(jsonShop)) {
            return Result.fail("商铺不存在");
        }
        // 缓存不存在,尝试获取锁,并创建缓存
        final String lockName = "lock:shop:" + id;
        try {
            if (!lock(lockName)){
                // 获取互斥锁失败,休眠一段时间后重试
                Thread.sleep(50);
                return queryWithCacheBreakdown(id);
            }
            // 获取互斥锁成功,创建缓存
            // 模拟长时间才能创建缓存
            Thread.sleep(100);
            Shop shop = this.getById(id);
            if (shop != null) {
                jsonShop = JSONUtil.toJsonStr(shop);
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, jsonShop, CACHE_SHOP_TTL);
                return Result.ok(shop);
            } else {
                // 缓存空对象到缓存中
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL);
                return Result.fail("店铺不存在");
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 释放锁
            unlock(lockName);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71

    下面是用逻辑过期解决缓存击穿问题的方式。

    首先需要将热点数据的缓存提前写入 Redis(缓存预热):

    public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
        /**
         * 创建店铺缓存
         *
         * @param id       店铺id
         * @param duration 缓存有效时长
         */
        public void saveShopCache(Long id, Duration duration) {
            Shop shop = getById(id);
            RedisCache<Shop> redisCache = new RedisCache<>();
            redisCache.setExpire(LocalDateTime.now().plus(duration));
            redisCache.setData(shop);
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisCache));
        }
        // ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    @SpringBootTest
    class HmDianPingApplicationTests {
        @Autowired
        private ShopServiceImpl shopService;
    
        @Test
        public void testSaveShopCache(){
            shopService.saveShopCache(1L, Duration.ofSeconds(1));
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    @Data
    public class RedisCache<T> {
        private LocalDateTime expire; //逻辑过期时间
        private T data; // 数据
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    Redis 中的缓存信息包含两部分:过期时间和具体信息。大致如下:

    {
        "data": {
            "area": "大关",
            "openHours": "10:00-22:00",
            "sold": 4215,
            // ...
        },
        "expire": 1708258021725
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    且其 TTL 是-1,也就是永不过期。

    具体的缓存读取和重建逻辑:

    /**
         * 用逻辑过期解决缓存击穿问题
         *
         * @return
         */
    private Result queryWithLogicalExpiration(Long id) {
        //检查缓存是否存在
        String jsonShop = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (StringUtils.isEmpty(jsonShop)) {
            // 缓存不存在
            return Result.fail("店铺不存在");
        }
        // 缓存存在,检查是否过期
        RedisCache<Shop> redisCache = JSONUtil.toBean(jsonShop, new TypeReference<RedisCache<Shop>>() {
        }, true);
        if (redisCache.getExpire().isBefore(LocalDateTime.now())) {
            // 如果过期,尝试获取互斥锁
            final String LOCK_NAME = LOCK_SHOP_KEY + id;
            if (lock(LOCK_NAME)) {
                // 获取互斥锁后,单独启动线程更新缓存
                CACHE_UPDATE_ES.execute(() -> {
                    try {
                        // 模拟缓存重建的延迟
                        Thread.sleep(200);
                        saveShopCache(id, Duration.ofSeconds(1));
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    } finally {
                        unlock(LOCK_NAME);
                    }
                });
            }
        }
        // 无论是否过期,返回缓存对象中的信息
        return Result.ok(redisCache.getData());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36

    封装 Redis 缓存工具类

    可以对对 Redis 缓存相关逻辑进行封装,可以避免在业务代码中重复编写相关逻辑。封装后分别对应以下方法:

    • 设置缓存数据(TTL)
    • 设置缓存数据(逻辑过期时间)
    • 从缓存获取数据(用空对象解决缓存穿透问题)
    • 从缓存获取数据(用互斥锁解决缓存击穿问题)
    • 从缓存获取数据(用逻辑过期解决缓存击穿问题)

    工具类的完整代码可以参考这里

    本文的完整示例代码可以从这里获取。

    参考资料

  • 相关阅读:
    Linux nload显示当前的网络使用情况
    HTTP复习(二)
    【牛客编程题】python入门103题(输入&类型,字符串&列表&字典&元组,运算&条件&循环,函数&类&正则)
    谷歌云的利润增长才刚刚开始
    发布文章到wordpress
    Linux Debian12使用git将本地项目上传到码云(gitee)远程仓库
    DHTMLX Gantt 8.0.5 Crack -甘特图
    微信小程序:超强大微信小程序源码下载内含几十款功能王者战力查询,游戏扫码登录,王者巅峰信息查询等等支持流量主收益和CPS收益
    可重构柔性装配产线:为工业制造领域注入了新的活力
    进程线程深入理解
  • 原文地址:https://blog.csdn.net/hy6533/article/details/136234183