目录
[0 核心业务总览](#0 核心业务总览)
[0.1 基本框架](#0.1 基本框架)
[0.2 准备工作](#0.2 准备工作)
[1 短信登录](#1 短信登录)
[1.1 理论](#1.1 理论)
[1.2 代码](#1.2 代码)
[2 商户查询缓存](#2 商户查询缓存)
[2.1 理论](#2.1 理论)
[2.2 代码](#2.2 代码)
[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

成功!
第三步:导入前端项目


成功!至此,准备工作完毕!
1 短信登录
短信登录部分一开始写的时候采用传统 Tomcat Session 机制:用户登录后,服务器将用户信息存入 HttpSession,并通过 Cookie 向客户端返回 JSESSIONID。但是Session 存储在单机服务器内存中,无法在集群环境下共享。
用户登录在 Tomcat服务器 A,Session 是存在 Tomcat 自己的内存里的,所以访问 A 生成的 Session 只存在于 A 的内存中。下次请求被负载均衡转发到 Tomcat服务器 B,B 中没有该 Session,会判定用户未登录,导致登录状态丢失。这就是分布式系统中经典的 Session 不共享问题。
最终解决方案:基于 Redis 实现共享 Session 登录 ✅
1.1 理论
Redis优势:
- 解决了集群环境下的 Session 共享问题,所有服务器都能访问 Redis 中的会话数据。
- 支持水平扩展(启动多个 Tomcat),性能更高,且天然具备过期清理机制。
- 满足数据共享、内存存储(快)、key-value结构这三个要求。
Redis代替Session需要考虑:
- 选择合适的数据结构 :优先使用 String 或 Hash 。
短信验证码场景验证码为单个字符串用String;用户个人信息使用 Hash 结构存储,可按字段独立存取,灵活性更高。 - 选择合适的 key :按业务场景分层设计,兼顾功能与安全。
短信验证码场景Key 格式login:code:{手机号},关联手机号与临时验证码,用于登录前置校验,生命周期短(5 分钟)。
用户登录会话场景Key 格式login:token:{随机Token},关联 Token 与脱敏后的用户信息,维持分布式登录状态。
**需要返回给前端的用户信息必须脱敏;而只存在后端用于对比的手机号可以不脱敏。**Token 为无意义 UUID,与用户敏感信息无直接关联,无法被猜测或反推身份,安全性更高。 - 选择合适的存储粒度 :最小必要 + 数据脱敏,在性能与安全间取得平衡。
用户信息存储仅保留非敏感字段:id、nickName、icon 等业务必需信息。绝对排除敏感字段password、phone、身份证号等核心隐私数据。
通过 BeanUtil.copyProperties(user, UserDTO.class) 过滤敏感字段,只拷贝安全数据。性能考量减少存储数据量,提升 Redis 读写效率,同时降低数据泄露后的危害。
实现架构:
- 用户输入手机号,点击发送验证码,后端生成随机验证码(如 4~6 位)存入 Redis,Key:login:code:{手机号},并设置过期时间。
- 登录时后端根据手机号从 Redis 取出验证码,比对验证码是否正确,验证通过 → 查询用户(不存在则自动注册)。
- 生成唯一 Token(如 UUID),将用户信息(脱敏后)存入 Redis,Key 为 login:token:{token},并设置过期时间。
- 将 Token 返回给前端(存储在 Cookie 或请求头中)。
- 后续请求时,前端携带 Token,后端通过拦截器从 Redis 中查询并验证用户信息。
- 拦截器:全局拦截器(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 存在明显问题:
- 规律性太强:ID 连续递增,容易被推测出业务量、订单量,存在安全隐患。
- 受单表数据量限制:单表自增 ID 有上限,分库分表后无法保证全局唯一。
- 扩展性差:无法适配分布式多节点、多服务的架构场景。
**全局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封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

- 请求进来
- Redis 快速判断
- 库存够不够?
- 这个人是不是已经买过了?
- Redis 判断通过 → 直接返回前端 "抢购成功"
- 把下单任务扔进 阻塞队列
- 后台一个线程慢慢从队列里取任务,真正去数据库下单
即:先利用Redis完成库存余量、一人一单判断,完成抢单业务。再将下单业务放入阻塞队列,利用独立线程异步下单。
但是这样仍有问题:
- 内存限制问题
阻塞队列是放在 JVM 内存里的!请求太多时,**JVM 直接内存溢出!**项目直接崩掉!队列容量受服务器内存限制,高并发下会爆内存。
- 数据安全问题
队列在内存里,一断电 / 重启,数据全部丢失! 用户显示 "抢单成功",但数据库没有订单,严重 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范围查询。

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