黑马程序员Redis--实战篇(黑马点评)

目录

[0 核心业务总览](#0 核心业务总览)

[0.1 基本框架](#0.1 基本框架)

[0.2 准备工作](#0.2 准备工作)

第一步:导入SQL文件

第二步:idea打开项目框架hm-dianping

第三步:导入前端项目

[1 短信登录](#1 短信登录)

[1.1 理论](#1.1 理论)

Redis优势:

Redis代替Session需要考虑:

实现架构:

实现细节:

[1.2 代码](#1.2 代码)

controller.UserController

service.impl.UserServiceImpl

utils.RefreshTokenInterceptor

utils.LoginInterceptor

config.MvcConfig

[2 商户查询缓存](#2 商户查询缓存)

[2.1 理论](#2.1 理论)

缓存更新策略:

缓存与数据库的双写一致:

缓存穿透

缓存雪崩

缓存击穿

封装Redis工具类

[2.2 代码](#2.2 代码)

ShopTypeController

ShopTypeServiceImpl

CacheClient

ShopController

ShopServiceImpl

[3 优惠券秒杀](#3 优惠券秒杀)

[3.1 Redis实现全局唯一id](#3.1 Redis实现全局唯一id)

[3.2 库存超卖问题](#3.2 库存超卖问题)

[3.3 一人一单功能](#3.3 一人一单功能)

[3.4 集群下的线程并发安全问题](#3.4 集群下的线程并发安全问题)

[3.5 Redisson](#3.5 Redisson)

[3.6 Redis优化秒杀](#3.6 Redis优化秒杀)

[3.7 Redis消息队列实现异步秒杀](#3.7 Redis消息队列实现异步秒杀)

[4 达人探店](#4 达人探店)

[5 好友关注](#5 好友关注)

[5.1 关注和取关(Set)](#5.1 关注和取关(Set))

[5.2 共同关注(Set 交集)](#5.2 共同关注(Set 交集))

[5.3 消息推送------Feed流(Zset)](#5.3 消息推送——Feed流(Zset))

[6 附近商铺](#6 附近商铺)

[7 用户签到](#7 用户签到)

[8 UV统计](#8 UV统计)


本笔记不再显示一步一步代码过程,主要集中于Redis原理知识以及一些实践需要注意的地方。

0 核心业务总览

模块 核心 Redis 技术 业务场景
短信登录 共享 Session(Redis 存储 Session) 实现分布式环境下的用户登录状态共享
商户查询缓存 缓存使用技巧、解决缓存雪崩 / 穿透问题 优化商户查询性能,防止缓存异常导致数据库雪崩
优惠券秒杀 计数器、Lua 脚本、分布式锁、三种消息队列 实现高并发下的秒杀扣减、防超卖、异步下单
附近的商户 GeoHash 地理位置计算 实现基于地理位置的商户检索与排序
UV 统计 HyperLogLog 基数统计 高效统计独立访客数量,节省内存
用户签到 Bitmap 位图统计 实现用户连续签到、签到统计等功能
好友关注 Set 集合(关注 / 取关 / 共同关注)、消息推送 实现社交关系链与信息流推送
达人探店 List(点赞列表)、SortedSet(点赞排行榜) 实现探店笔记的点赞记录与热度排行

0.1 基本框架

后续tomcat可能会扩展为Tomcat 集群。原因:分担并发压力、保证服务不宕机、实现系统高可用、支持业务不断扩容。

0.2 准备工作

第一步:导入SQL文件

DataGrip的localhost里创建数据库 hmdp,右键 → SQL Scripts → Run SQL Script → hmdp.sql

第二步:idea打开项目框架hm-dianping

jdk8 → 修改application.yaml文件中的mysql、redis地址信息

把未识别的 pom.xml 添加maven module:

File → Project Structure → Modules → 点 + → Import Module → 选中 pom.xml

运行起来后浏览器打开 http://localhost:8081/shop-type/list

成功!

第三步:导入前端项目

浏览器访问http://127.0.0.1:8080

成功!至此,准备工作完毕!

1 短信登录

短信登录部分一开始写的时候采用传统 Tomcat Session 机制:用户登录后,服务器将用户信息存入 HttpSession,并通过 Cookie 向客户端返回 JSESSIONID。但是Session 存储在单机服务器内存中,无法在集群环境下共享

用户登录在 Tomcat服务器 A,Session 是存在 Tomcat 自己的内存里的,所以访问 A 生成的 Session 只存在于 A 的内存中。下次请求被负载均衡转发到 Tomcat服务器 B,B 中没有该 Session,会判定用户未登录,导致登录状态丢失。这就是分布式系统中经典的 Session 不共享问题。

最终解决方案:基于 Redis 实现共享 Session 登录

1.1 理论

Redis优势:

  1. 解决了集群环境下的 Session 共享问题,所有服务器都能访问 Redis 中的会话数据。
  2. 支持水平扩展(启动多个 Tomcat),性能更高,且天然具备过期清理机制。
  3. 满足数据共享、内存存储(快)、key-value结构这三个要求。

Redis代替Session需要考虑:

  1. 选择合适的数据结构优先使用 String 或 Hash
    短信验证码场景验证码为单个字符串用String;用户个人信息使用 Hash 结构存储,可按字段独立存取,灵活性更高。
  2. 选择合适的 key :按业务场景分层设计,兼顾功能与安全。
    短信验证码场景Key 格式login:code:{手机号},关联手机号与临时验证码,用于登录前置校验,生命周期短(5 分钟)。
    用户登录会话场景Key 格式login:token:{随机Token},关联 Token 与脱敏后的用户信息,维持分布式登录状态。
    **需要返回给前端的用户信息必须脱敏;而只存在后端用于对比的手机号可以不脱敏。**Token 为无意义 UUID,与用户敏感信息无直接关联,无法被猜测或反推身份,安全性更高。
  3. 选择合适的存储粒度 :最小必要 + 数据脱敏,在性能与安全间取得平衡。
    用户信息存储仅保留非敏感字段:id、nickName、icon 等业务必需信息。绝对排除敏感字段password、phone、身份证号等核心隐私数据。
    通过 BeanUtil.copyProperties(user, UserDTO.class) 过滤敏感字段,只拷贝安全数据。性能考量减少存储数据量,提升 Redis 读写效率,同时降低数据泄露后的危害。

实现架构:

  1. 用户输入手机号,点击发送验证码,后端生成随机验证码(如 4~6 位)存入 Redis,Key:login:code:{手机号},并设置过期时间。
  2. 登录时后端根据手机号从 Redis 取出验证码,比对验证码是否正确,验证通过 → 查询用户(不存在则自动注册)。
  3. 生成唯一 Token(如 UUID),将用户信息(脱敏后)存入 Redis,Key 为 login:token:{token},并设置过期时间。
  4. 将 Token 返回给前端(存储在 Cookie 或请求头中)。
  5. 后续请求时,前端携带 Token,后端通过拦截器从 Redis 中查询并验证用户信息。
  6. 拦截器:全局拦截器(Order0)统一获取请求 Token,从 Redis 查询用户信息并刷新有效期保存到 ThreadLocal,登录拦截器(Order1)再校验用户是否存在完成权限控制。

实现细节:

1.2 代码

controller.UserController

java 复制代码
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    @Resource
    private IUserInfoService userInfoService;

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

    /**
     * 登录功能
     *
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
        // 实现登录功能
        return userService.login(loginForm, session);
    }
}

service.impl.UserServiceImpl

java 复制代码
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号是否合法,不合法fail
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号码不合法!");
        }

        // 2.合法创建一个随机6位验证码
        String code = RandomUtil.randomNumbers(6);

        // 3.存入redis类型String
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

        // 4.返回验证码(日志虚假),返回执行成功ok
        log.debug("发送验证码成功,验证码{}", code);
        return Result.ok();
    }

    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号码不合法!");
        }

        // 2.校验验证码,若不合法则报错
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)) {
            return Result.fail("验证码过期或不正确!");
        }

        // 3.合法,根据手机号查询用户
        User user = query().eq("phone", phone).one();

        // 4.用户不存在,创建新用户,保存至数据库
        if (user == null) {
            user = createUserWithPhone(phone);
        }

        // 5.保存用户到redis,类型Hash
        // 5.1 生成随机token作为登录令牌
        String token = UUID.randomUUID().toString(true);

        // 5.2 将user转化为Hash
        // 只存一部分信息,全部返回user有风险
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

        // 5.3 存到redis,设置有效期
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 此处可以发现只在存的时候设置了有效期
        // 但其实我们希望每次有访问页面的时候有效期都刷新为30min --> 拦截器

        // 6. 返回token
        return Result.ok(token);
    }

    private User createUserWithPhone(String phone) {
        // 1.创建用户
        User user = new User();
        user.setPhone(phone);
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));

        // 2.保存用户
        save(user);
        return user;
    }
}

utils.RefreshTokenInterceptor

java 复制代码
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    // 校验
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 1. 获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // token为空,直接放行
            return true;
        }

        // 2. 基于token获取redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        if (userMap.isEmpty()) {
            // 用户不存在,直接放行
            return true;
        }

        // 4. 将查询到的Hash转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 5. 存在,保存到ThreadLocal(在当前线程里存数据 → 整个线程随处取,别的线程拿不到。)
        UserHolder.saveUser(userDTO);

        // 6. 刷新token有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 7. 放行
        return true;
    }

    // 销毁
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

utils.LoginInterceptor

java 复制代码
@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判断是否需要拦截 (ThreadLocal中是否有用户)
        if (UserHolder.getUser() == null) {
            // 没有用户,需要拦截,设置状态码
            // 401 就是 HTTP 状态码:未授权 / 未登录
            response.setStatus(401);
            // 拦截
            return false;
        }
        return true;
    }
}

config.MvcConfig

java 复制代码
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private RefreshTokenInterceptor refreshTokenInterceptor;

    @Resource
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // token刷新的拦截器,order为0,先执行
        registry.addInterceptor(refreshTokenInterceptor).order(0);

        // 登录拦截器,只负责根据ThreadLocal中是否有用户来判断是否需要拦截
        registry.addInterceptor(loginInterceptor)
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
    }
}

2 商户查询缓存

缓存是数据交换的缓冲区(Cache),作为存储数据的临时场所,通常具备较高的读写性能;它的核心作用是降低后端负载、提升读写效率并缩短响应时间,但同时也会带来数据一致性、代码维护与运维等方面的成本。

2.1 理论

缓存更新策略:

  • 内存淘汰( 一致性差、无维护成本**)**:依赖 Redis 自身内存淘汰机制,内存不足时自动清理数据,下次查询时更新缓存,适合低一致性场景(如店铺类型查询)。
  • 超时剔除( 一致性一般、维护成本低**)**:给缓存数据设置 TTL,到期自动删除,下次查询时更新。
  • 主动更新( 一致性最好、维护成本最高**)**:在修改数据库时同步更新缓存,适合高一致性场景(如店铺详情查询),通常会搭配超时剔除作为兜底方案。

缓存与数据库的双写一致:

-- 删除缓存还是更新缓存?

  • 更新缓存:每次更新数据库时同步更新缓存,会产生大量无效写操作(若数据频繁更新但很少被查询,缓存更新无意义),还会增加缓存写冲突的风险。
  • 删除缓存 ❤:更新数据库时让缓存失效,仅在查询时按需重建缓存,能有效减少无效 IO,是更推荐的做法。

-- 如何保证缓存与数据库操作的原子性?事务。

-- 先操作缓存还是先操作数据库?

  • 先删除缓存,再操作数据库:可能导致查询时缓存未重建、数据库未更新,出现短暂脏读。
  • 先操作数据库,再删除缓存 ❤:能最大程度保证最终一致性,但极端情况下仍会有短暂不一致,可通过超时过期重试机制兜底。

-- 推荐流程

  • 读操作:先读缓存 → 未命中则读数据库并回写缓存,设定超时时间
  • 写操作:先更新数据库 → 再删除缓存
  • 原子性保障:单体用本地事务,分布式用分布式事务方案

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。恶意攻击时,不断发起这样的请求,给数据库带来巨大压力。

解决方案:缓存空对象(额外内存消耗)、布隆过滤(实现复杂、存在误判)、增强id的复杂度避免被猜测id规律、做好数据的基础格式校验。

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:给不同的Key的TTL添加随机值、利用Redis集群提高服务的可用性、给缓存业务添加降级限流策略、给业务添加多级缓存。

缓存击穿

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

解决方案:互斥锁(没有额外的内存消耗、保证一致性、实现简单、需等待性能受影响)、逻辑过期(有额外内存消耗、不保证一致性、实现复杂、不等待性能较好)。

利用互斥锁解决缓存击穿问题

利用逻辑过期解决缓存击穿问题

封装Redis工具类

基于StringRedisTemplate封装一个缓存工具类,满足下列需求:

方法1_set:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置TTL过期时间。

方法2_setWithLogicalExpire:将任意Java对象序列化为json并存储在string类型的key中,并且可以设置逻辑过期时间,用于处理缓存击穿问题。

方法3_queryWithPassThrough:根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值的方式解决缓存穿透问题。

方法4_queryWithLogicalExpire:根据指定的key查询缓存,并反序列化为指定类型,需要利用逻辑过期解决缓存击穿问题。

2.2 代码

ShopTypeController

java 复制代码
@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    @GetMapping("list")
    public Result queryTypeList() {
        return typeService.queryTypeList();
    }
}

ShopTypeServiceImpl

java 复制代码
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result queryTypeList() {
        // 1.查redis,查到的话转为ShopType并返回
        String shopTypeJson = stringRedisTemplate.opsForValue().get(RedisConstants.SHOP_GEO_KEY);
        if (StrUtil.isNotBlank(shopTypeJson)) return Result.ok(JSONUtil.toList(shopTypeJson, ShopType.class));

        // 2.未查到去数据库查
        List<ShopType> shopTypeList = query().orderByAsc("sort").list();

        // 3.数据库未查到,返回fail
        if (shopTypeList == null) return Result.fail("未查到类型数据!");

        // 4.数据库查到,转为json存入redis,并返回
        stringRedisTemplate.opsForValue().set(RedisConstants.SHOP_GEO_KEY, JSONUtil.toJsonStr(shopTypeList));
        return Result.ok(shopTypeList);
    }
}

CacheClient

java 复制代码
@Slf4j
@Component
public class CacheClient {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));

        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    /*
    缓存穿透
     */
    public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从Redis查,若存在,直接返回
        String json = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(json)) {
            return JSONUtil.toBean(json, type);
        }
        // 此时有可能是"",有可能是null,判断命中的是否是空值""
        if (json != null) {
            return null;
        }

        // 2.不存在,数据库查
        R r = dbFallback.apply(id);
        // 3.数据库不存在,将空值写入redis(缓存穿透),返回fail
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }

        // 4.数据库存在,写入redis
        this.set(key, r, time, unit);
        // 5. 返回
        return r;
    }

    // 线程池
    public static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /*
    逻辑过期解决缓存击穿
     */
    public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从Redis查,若未命中,直接返回null
        String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        if (StrUtil.isBlank(json)) {
            return null;
        }
        // 2.查到了,看是否过期,未过期直接返回商铺信息
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        JSONObject jsonObject = (JSONObject) redisData.getData();
        R r = JSONUtil.toBean(jsonObject, type);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (expireTime.isAfter(LocalDateTime.now())) {
            return r;
        }

        // 3.过期了,需要缓存重建,获取互斥锁
        boolean isLock = tryLock(LOCK_SHOP_KEY + id);
        // 4.获取成功,DoubleCheck,开启独立线程实现缓存重建,存新的店铺信息
        if (isLock) {
            json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
            if (StrUtil.isNotBlank(json)) {
                redisData = JSONUtil.toBean(json, RedisData.class);
                jsonObject = (JSONObject) redisData.getData();
                r = JSONUtil.toBean(jsonObject, type);
                return r;
            }

            CACHE_REBUILD_EXECUTOR.submit(() -> {
                // 缓存重建
                try {
                    R r1 = dbFallback.apply(id);
                    this.setWithLogicalExpire(key,r1,time,unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    unlock(LOCK_SHOP_KEY + id);
                }
            });
        }
        // 5.返回过期的商铺信息
        return r;
    }

    private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.MINUTES);
        return BooleanUtil.isTrue(flag);
    }

    private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }
}

ShopController

java 复制代码
@RestController
@RequestMapping("/shop")
public class ShopController {

    @Resource
    public IShopService shopService;

    /**
     * 根据id查询商铺信息
     *
     * @param id 商铺id
     * @return 商铺详情数据
     */
    @GetMapping("/{id}")
    public Result queryShopById(@PathVariable("id") Long id) {
        return shopService.queryById(id);
    }

    /**
     * 更新商铺信息
     *
     * @param shop 商铺数据
     * @return 无
     */
    @PutMapping
    public Result updateShop(@RequestBody Shop shop) {
        return shopService.updateShop(shop);
    }

}

ShopServiceImpl

java 复制代码
@Service
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private CacheClient cacheClient;

    @Override
    public Result queryById(Long id) {
        // 缓存穿透
        // Shop shop = cacheClient.queryWithPassThrough(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        // 互斥锁解决缓存击穿
        // Shop shop = queryWithMutex(id);

        // 逻辑过期解决缓存击穿
        Shop shop = cacheClient.queryWithLogicalExpire(CACHE_SHOP_KEY, id, Shop.class, this::getById, CACHE_SHOP_TTL, TimeUnit.MINUTES);

        if (shop == null) {
            return Result.fail("店铺不存在!");
        }
        return Result.ok(shop);
    }

    @Override
    @Transactional
    public Result updateShop(Shop shop) {
        // 1.更新数据库
        Long id = shop.getId();
        if (id == null) return Result.fail("店铺id不能为空!");
        updateById(shop);
        // 2.删掉缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);

        return Result.ok();
    }
}

3 优惠券秒杀

以优惠券订单表 tb_voucher_order 为例,自增 ID 存在明显问题:

  1. 规律性太强:ID 连续递增,容易被推测出业务量、订单量,存在安全隐患。
  2. 受单表数据量限制:单表自增 ID 有上限,分库分表后无法保证全局唯一。
  3. 扩展性差:无法适配分布式多节点、多服务的架构场景。

**全局ID生成器:**分布式系统中用于生成跨节点、跨服务唯一标识符的工具,避免数据库自增 ID 在分布式场景下的局限性。

  • 唯一性:保证在整个系统中不会出现重复 ID,是最基础要求。
  • 高性能:生成速度快、并发能力强,能应对高并发场景(如秒杀订单)。
  • 高可用:不依赖单点,故障时仍能正常生成 ID,保证系统稳定。
  • 递增性:通常要求趋势递增,便于数据库索引优化、提升查询效率。
  • 安全性:ID 无明显规律,防止被恶意枚举、爬取数据(如订单 ID)。

3.1 Redis实现全局唯一id

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

  • 符号位:1bit,永远为0,代表正数。
  • 时间戳:31bit,以秒为单位,可以使用69年。
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID。

Radis的Key:每天一个key,方便统计订单量。

String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM::dd"));

long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

3.2 库存超卖问题

**悲观锁:**认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。

**乐观锁 ❤:**认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。否则,认为发生了安全问题,此时可以重试或异常。性能高,但是存在成功率低的问题。

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法:先查版本号,更新时判断版本没变才允许更新,同时版本 + 1。
  • CAS法:更新时直接对比原值是否没变,没变就更新,相当于用数据本身当版本。

3.3 一人一单功能

一人一单的核心是禁止同一个用户并发创建多个订单, 同一个用户,同时发起 2/3/5 个请求 (比如疯狂点击、网络重发、工具刷接口),而悲观锁会以用户 ID 为维度加锁 ,让同一用户的所有请求必须排队执行 :前一个请求完成 "查订单→判重→下单" 的完整流程并释放锁后,下一个请求才能执行,此时再查 → 已经有订单 → 直接拒绝,从根源上杜绝高并发下用户重复下单;而乐观锁只适用于数据更新场景 ,无法控制新增订单的插入操作,根本拦不住一人多单的问题,所以一人一单必须用悲观锁保证绝对安全。

通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

3.4 集群下的线程并发安全问题

既然集群模式下,加锁失效是因为不同的JVM监控的是不同的锁,那只要在此基础上,在外面有一个统一的、公共的锁就可以解决了。

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。

获取锁:

  • 互斥:确保只能有一个线程获取锁
  • 非阻塞:尝试一次,成功返回true,失败返回false

释放锁:

  • 手动释放
  • 超时释放

但是原始锁只判断「锁是否存在」,不判断「锁是不是自己的」,可能会导致 A 线程删了 B 线程的锁

  • 加锁存标识:用 UUID 作为线程唯一标识,存入 Redis 锁的 value
  • 解锁校验标识:先查锁的 value,和当前线程标识一致才删锁,不一致不删

即使加了标识校验,还有一个隐藏问题:「判断 + 删除」不是原子操作

最终解决方案用 Lua 脚本实现「查询 + 删除」的原子操作,Redis 会保证 Lua 脚本的原子性执行,彻底解决问题。

但即使是这样,也会有新的问题,以下是基于原始 SETNX 分布式锁的 4 大致命缺陷:

问题 核心痛点 引发的后果
不可重入 同一个线程无法多次获取同一把锁 递归调用或内部方法调用时,直接死锁。
不可重试 获取锁失败只尝试一次,无等待机制 并发高时,大量请求直接失败,系统吞吐量低。
超时释放隐患 业务执行耗时过长,导致锁被自动释放 引发锁误删(A 线程删 B 线程锁),并发安全失效。
主从一致性 集群模式下主节点宕机,数据未同步到从节点 导致锁丢失,引发超卖 / 并发安全问题。

一站式解决所有问题的官方框架------Redisson

3.5 Redisson

Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间

3.6 Redis优化秒杀

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
  1. 请求进来
  2. Redis 快速判断
    • 库存够不够?
    • 这个人是不是已经买过了?
  3. Redis 判断通过 → 直接返回前端 "抢购成功"
  4. 把下单任务扔进 阻塞队列
  5. 后台一个线程慢慢从队列里取任务,真正去数据库下单

即:先利用Redis完成库存余量、一人一单判断,完成抢单业务。再将下单业务放入阻塞队列,利用独立线程异步下单。

但是这样仍有问题:

  1. 内存限制问题

阻塞队列是放在 JVM 内存里的!请求太多时,**JVM 直接内存溢出!**项目直接崩掉!队列容量受服务器内存限制,高并发下会爆内存。

  1. 数据安全问题

队列在内存里,一断电 / 重启,数据全部丢失! 用户显示 "抢单成功",但数据库没有订单,严重 bug!内存数据不持久化,服务重启就丢数据,不安全。

解决方法:用 消息队列 (RabbitMQ / RocketMQ / Kafka)代替内存阻塞队列!

3.7 Redis消息队列实现异步秒杀

消息队列(Message Queue),存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)(快递柜)
  • 生产者:发送消息到消息队列(快递员)
  • 消费者:从消息队列获取消息并处理消息(消费者)

乍一看,JVM 队列 和 MQ 都是 "存任务、慢慢消费",好像一样,为什么用MQ更好呢?是因为MQ拥有更强大的功能:

  • 不占 JVM 内存,不会 OOM
  • 持久化存储,服务重启不丢消息
  • 分布式架构,支持高并发
  • 功能强大(重试、死信、限流、削峰)

Redis 提供 两种持久化

RDB(快照):定时把内存数据保存成文件。

AOF(日志):每执行一条写命令就记录。

RDB 快照 AOF 日志
保存内容 数据本身 写命令
恢复速度 极快 重启时,直接加载保存的.rdb文件恢复数据。 较慢 重启时,重新执行一遍所有命令,恢复数据。
文件体积
数据安全 一般(丢一段时间数据) 10:00 拍快照 10:05 断电 10:00~10:05 这 5 分钟数据全部丢失! 高(最多丢 1 秒) appendfsync always 每次写命令都立即落盘(最安全,最慢) appendfsync everysec 每秒落盘一次(推荐!最多丢1秒数据)
性能 较低
生产推荐 配合 AOF 使用 推荐开启

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列,使用LPUSH和BRPOP来实现阻塞效果。
  • PubSub(发布订阅):基本的点对点消息模型,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
  • Stream ❤:比较完善的消息队列模型。消息可回溯。可以多消费者争抢消息,加快消费速度。可以阻塞读取。没有消息漏读的风险。有消息确认机制,保证消息至少被消费一次。

4 达人探店

黑马点评的"达人探店"功能需要实时展示达人动态、店铺热度和排行榜,用户行为(浏览、点赞、收藏)频繁变化且访问量大,Redis 用 Set 存用户点赞,去重又快速,ZSet 存排行榜,按热度分数自动排序,Hash 存店铺或达人信息,快速查改。内存存储保证高并发读写低延迟,同时减轻数据库压力,适合这种动态频繁更新的功能。

需求: 同一个用户只能点赞一次,再次点击则取消点赞(判断字段Blog类的isLike属性)

实现步骤:

  • 给Blog类中添加一个isLike字段,标示是否被当前用户点赞。
  • 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1。
  • 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段。
  • 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段。

优化--点赞排行榜 在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:ZSet ,Score存时间戳。

5 好友关注

5.1 关注和取关(Set)

使用Set的原因:

  • 自动去重:保证一个用户不会重复关注同一个人
  • 判断是否关注:SISMEMBER,时间复杂度 O(1)
  • 增删操作:SADD / SREM,效率高

5.2 共同关注(Set 交集)

利用Redis中两个 Set 求交集SINTER,实现共同关注功能,避免数据库 join,提高性能。在博主个人页面展示出当前用户与博主的共同好友,除此之外还可以实现社交推荐(你可能认识的人)。

5.3 消息推送------Feed流(Zset)

为什么 Feed 流不用 MySQL + limit 分页,而一定要 Redis?

因为MySQL 的分页模型不适合 Feed 流这种"高并发 + 动态数据 + 实时排序"的场景,并且limit 分页在大数据量和动态数据场景下性能差且不稳定(在 Feed 流这种动态数据场景下,会因为新数据插入导致分页不稳定,出现重复或丢失的问题),同时,Feed 流查询还涉及关注关系的 IN 查询和排序,数据库压力较大。而 Redis 的 ZSet 能提供稳定且高效的时间流分页。

消息推送/关注推送,也叫做Feed流,直译为投喂。为用户持续的提供"沉浸式"的体验,通过无限下拉刷新获取新的信息。

用ZSet,因为 Feed流需要:按时间排序、取前N条、支持分页。

ZSet天然满足,score=时间戳,ZREVRANGE获取前 N 条、ZREVRANGEBYSCORE范围查询。

  1. Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈。优点:信息全面,不会有缺失,并且实现也相对简单。缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低。
  2. 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户。优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷。缺点:如果算法不精准,可能起到反作用。

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式

Timeline模式的实现方案有三种: 拉模式(读扩散)、推模式(写扩散)、推拉结合(读写混合,兼具推和拉两种模式的优点)。

拉模式(读扩散) 推模式(写扩散) 推拉结合(读写混合)
流程 查询自己关注的人 再去查这些人发布的内容 合并排序 查A的粉丝(fans:A) 遍历粉丝 推送到每个粉丝的收件箱 普通用户:推送到每个粉丝的收件箱 大V用户:读取时再查关注的人内容
核心思想 用户读取时再查关注的人发送的内容 发内容时推给粉丝 普通用户推,大V用拉
写比例 高,写放大(粉丝多很慢)
读比例 高,查询慢(要查很多人)
用户读取延迟
缺点 查询次数多 排序复杂 延迟高 写放大 (粉丝多 → 写很多次) 大V会有性能问题 实现复杂
使用场景 很少使用 用户量少(小系统)、没有大 V 过千万的用户量(大规模系统),有大 V

本项目使用基于推模式实现关注推送功能,因为系统规模较小,粉丝数量有限,写扩散成本可控。优先保证读性能和用户体验。实现简单,适合业务场景。

Feed流的分页问题:Feed流中的数据是动态的,会不断更新,所以数据的角标也在变化,offset 会发生偏移,因此不能采用传统的分页模式,否则可能会导致数据重复。

解决------Feed流的滚动分页(Zset)

XML 复制代码
ZREVRANGEBYSCORE key max min LIMIT offset count
  • max:上一次查询的最小时间戳
  • min:0
  • offset:偏移量(解决相同时间戳问题)
  • count:查询条数

重点------相同时间戳问题:Redis score 是毫秒级,同一毫秒内可能有多个用户发帖,推模式下,同一时间批量写入。如果每次查询仅用Score作为筛选的话,Redis 会把相同 Score 的数据再查一遍,比如blog1 (100)、blog2 (90)、blog3 (90)、blog4 (90)、blog5 (80)、blog6 (80),每一页仅读三条数据,第一次读blog123,那下次再用90这个Score去卡,就会重复读出blog234。

因此我们需要一个偏移量 offset 用来记录要跳过多少数据(当时间戳相同的时候,要"跳过已经读过的那些"),第一次查询发现重复的数据有两条,那下一次查询 offset = 2,返回blog456。offset 用来跳过上一次已经读过的、相同 score 的数据。

总结:在 Feed 流中使用 ZSet 做滚动分页时,score 是时间戳,但时间戳可能重复,如果只用 max 作为游标,会导致下一页重复读取相同 score 的数据。

因此需要引入 offset,用来记录当前页中相同时间戳的数量,下一次查询时通过 LIMIT offset count 跳过这些数据,从而避免重复。

要用Zset,而不是List,因为List 是顺序结构,底层是链表结构,只能按 "下标位置" 取数据,无法按 "时间范围" 控制读取;而 Feed 流是动态数据,新数据插入会导致索引偏移,"下标位置" 会不断变化,导致分页错乱。而 Zset 的排序依据是score(时间戳),而不是位置,因此更适合实现滚动分页。

6 附近商铺

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。

7 用户签到

BitMap:把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。

BitMap的操作命令有:

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)
  • BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

8 UV统计

UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。

PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。

HyperLogLog的作用:做海量数据的统计工作。

HyperLogLog的优点:内存占用极低、性能非常好。

HyperLogLog的缺点:有一定的误差。

相关推荐
清水白石0082 小时前
Python 编程全景解析:四大核心容器的性能较量、语义之美与高阶实战
开发语言·数据库·python
2401_878530212 小时前
深入理解Python的if __name__ == ‘__main__‘
jvm·数据库·python
zz-zjx2 小时前
harbor使用外置db,redis,存储(minio)通过pigsty安装(单机)
数据库·redis·缓存
踩着两条虫2 小时前
VTJ.PRO 在线应用开发平台的数据库与基础设施
数据库·架构·nestjs
深蓝轨迹2 小时前
黑马点评--达人探店模块
java·spring boot·redis
!停2 小时前
C++入门基础—类和对象3
java·数据库·c++
llilian_163 小时前
ptp从时钟 ptp授时模块 如何挑选PTP从时钟授时协议模块 ptp从时钟模块
数据库·功能测试·单片机·嵌入式硬件·测试工具
municornm3 小时前
【MySQL】to_date()日期转换
数据库·mysql
流星白龙3 小时前
【MySQL】6.MySQL基本查询(1)
数据库·windows·mysql