Redis 实战笔记:短信登录、缓存、秒杀、分布式锁与社交功能

7612 字
38 分钟
Redis 实战笔记:短信登录、缓存、秒杀、分布式锁与社交功能

Redis 实战笔记:短信登录、缓存、秒杀、分布式锁与社交功能#

阅读提示

本文是 Redis 实战项目「黑马点评」的学习笔记,内容覆盖: 短信登录 → 商户缓存 → 优惠券秒杀 → 分布式锁 → 异步秒杀 → 达人探店 → 好友关注 → 附近商户 → 签到统计 → UV 统计。 建议先掌握 Redis 五大数据结构、SpringDataRedis 基本用法,再阅读本文。

内容概述#

本项目围绕一个本地生活服务平台展开,主要使用 Redis 解决以下业务问题:

模块Redis 技术点
短信登录Redis 共享 Session、Token
商户查询缓存缓存穿透、缓存雪崩、缓存击穿
优惠券秒杀计数器、Lua 脚本、分布式锁、消息队列
附近的商户Redis GEO
UV 统计HyperLogLog
用户签到BitMap
好友关注Set 集合交并差
达人探店List、SortedSet

一、短信登录#

1.1 导入项目#

项目采用前后端分离模式,整体请求链路如下:

手机/App → Nginx → Tomcat → MySQL / Redis

Nginx 可以作为静态资源服务器、反向代理和负载均衡器;Tomcat 负责业务逻辑;MySQL 存储核心数据;Redis 提供高性能缓存与共享数据。

项目中涉及的主要数据表:

表说明
tb_user用户表
tb_user_info用户详情表
tb_shop商户信息表
tb_shop_type商户类型表
tb_blog用户日记表(达人探店日记)
tb_follow用户关注表
tb_voucher优惠券表
tb_voucher_order优惠券订单表

导入后端项目后,修改 MySQL 和 Redis 连接信息并启动。访问:

http://localhost:8081/shop-type/list

如果能看到 JSON 数据,说明后端导入成功。

前端项目解压后,在 Nginx 目录执行:

Terminal window
start nginx.exe

访问:

http://localhost:8080/

1.2 基于 Session 实现登录流程#

发送验证码#

  1. 用户提交手机号/邮箱。
  2. 后端校验格式是否合法。
  3. 合法则生成验证码并保存。
  4. 通过短信或邮件发送验证码。

登录/注册#

  1. 用户输入账号和验证码。
  2. 后端从 Session 中取出验证码进行校验。
  3. 校验通过后根据账号查询用户。
  4. 用户不存在则创建用户。
  5. 将用户信息保存到 Session。

校验登录状态#

  1. 用户请求时携带 Cookie 中的 JsessionId。
  2. 后端通过 JsessionId 从 Session 中获取用户信息。
  3. 没有用户信息则拦截。
  4. 有用户信息则保存到 ThreadLocal 并放行。

1.3 发送邮箱验证码#

黑马点评默认使用短信验证码,但为了实际部署方便,可以改为邮箱验证码。需要导入依赖:

<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.7</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
<version>1.4</version>
</dependency>

邮件工具类核心逻辑:

public class MailUtils {
public static void sendTestMail(String email, String code) throws MessagingException {
Properties props = new Properties();
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.host", "smtp.qq.com");
props.put("mail.smtp.port", "587");
props.put("mail.user", "你的邮箱@qq.com");
props.put("mail.password", "你的SMTP授权码");
Authenticator authenticator = new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
String userName = props.getProperty("mail.user");
String password = props.getProperty("mail.password");
return new PasswordAuthentication(userName, password);
}
};
Session mailSession = Session.getInstance(props, authenticator);
MimeMessage message = new MimeMessage(mailSession);
message.setFrom(new InternetAddress(props.getProperty("mail.user")));
message.setRecipient(Message.RecipientType.TO, new InternetAddress(email));
message.setSubject("登录验证码");
message.setContent("验证码为:" + code + "(有效期一分钟)", "text/html;charset=UTF-8");
Transport.send(message);
}
public static String achieveCode() {
String[] beforeShuffle = new String[]{
"2", "3", "4", "5", "6", "7", "8", "9",
"A", "B", "C", "D", "E", "F", "G", "H",
"I", "J", "K", "L", "M", "N", "O", "P",
"Q", "R", "S", "T", "U", "V", "W", "X",
"Y", "Z", "a", "b", "c", "d", "e", "f",
"g", "h", "i", "j", "k", "l", "m", "n",
"o", "p", "q", "r", "s", "t", "u", "v",
"w", "x", "y", "z"
};
List<String> list = Arrays.asList(beforeShuffle);
Collections.shuffle(list);
StringBuilder sb = new StringBuilder();
for (String s : list) {
sb.append(s);
}
return sb.substring(3, 8);
}
}

修改 sendCode 方法:

@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) throws MessagingException {
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
session.setAttribute(phone, code);
log.info("发送登录验证码:{}", code);
MailUtils.sendTestMail(phone, code);
return Result.ok();
}

修改 login 方法:

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
String code = loginForm.getCode();
Object cacheCode = session.getAttribute(phone);
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确!!");
}
if (code == null || cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail("验证码不一致!!");
}
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
if (user == null) {
user = createUserWithPhone(phone);
}
session.setAttribute("user", user);
return Result.ok();
}

1.4 登录拦截与隐藏敏感信息#

创建 LoginInterceptor:

public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
User user = (User) session.getAttribute("user");
if (user == null) {
response.setStatus(401);
return false;
}
UserHolder.saveUser(user);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserHolder.removeUser();
}
}

为了避免返回用户密码等敏感信息,创建 UserDTO:

@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}

修改登录逻辑,将 User 转为 UserDTO:

UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user", userDTO);

修改拦截器:

UserDTO user = (UserDTO) session.getAttribute("user");

1.5 Session 共享问题#

每个 Tomcat 都有自己独立的 Session。如果用户第一次访问 Tomcat1,第二次被负载均衡到 Tomcat2,Tomcat2 上没有 Session,就会导致登录状态失效。

早期解决方案是 Session 拷贝,但存在两个问题:

  • 每台服务器都有完整 Session 数据,服务器压力大。
  • Session 拷贝可能存在延迟。

因此,使用 Redis 替代 Session。Redis 数据本身是共享的。

1.6 基于 Redis 实现短信登录#

设计 Key 时需要注意:

  • Key 要有唯一性。
  • Key 要方便携带。
  • 不要直接使用手机号等敏感信息作为 Key。

整体流程:

  1. 用户提交账号和验证码。
  2. 校验验证码。
  3. 根据账号查询用户,不存在则创建。
  4. 生成随机 Token。
  5. 将用户信息以 Hash 结构存入 Redis。
  6. 返回 Token 给前端。
  7. 后续请求携带 Token,从 Redis 获取用户信息。

修改 sendCode:

@PostMapping("/code")
public Result sendCode(@RequestParam("phone") String phone) throws MessagingException {
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确");
}
String code = MailUtils.achieveCode();
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone,
code,
LOGIN_CODE_TTL,
TimeUnit.MINUTES
);
log.info("发送登录验证码:{}", code);
return Result.ok();
}

修改 login:

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm) {
String phone = loginForm.getPhone();
String code = loginForm.getCode();
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (RegexUtils.isEmailInvalid(phone)) {
return Result.fail("邮箱格式不正确!!");
}
if (code == null || cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码不一致!!");
}
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone);
User user = userService.getOne(queryWrapper);
if (user == null) {
user = createUserWithPhone(phone);
}
String token = UUID.randomUUID().toString();
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
return Result.ok(token);
}

1.7 登录状态刷新#

如果只在需要拦截的路径上刷新 Token TTL,那么用户访问不需要拦截的路径时,Token 不会续期。因此需要两个拦截器:

  • RefreshTokenInterceptor:拦截所有路径,刷新 Token TTL。
  • LoginInterceptor:只负责判断用户是否登录。

RefreshTokenInterceptor:

public class RefreshTokenInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserHolder.removeUser();
}
}

LoginInterceptor:

public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (UserHolder.getUser() == null) {
response.setStatus(401);
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserHolder.removeUser();
}
}

配置类:

@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
}
}

二、商户查询缓存#

2.1 什么是缓存#

缓存是数据交换的缓冲区,通常用于降低数据库访问压力、提高读写效率、降低响应时间。

缓存的作用:

  • 降低后端负载。
  • 提高读写效率,降低响应时间。

缓存的成本:

  • 数据一致性成本。
  • 代码维护成本。
  • 运维成本。

2.2 添加商户缓存#

查询商户时,先从 Redis 查询,未命中再查数据库,并将结果写入 Redis。

@GetMapping("/{id}")
public Result queryShopById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}

Service 实现:

@Override
public Result queryById(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
Shop shop = getById(id);
if (shop == null) {
return Result.fail("店铺不存在!!");
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
jsonStr,
CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
return Result.ok(shop);
}

2.3 缓存更新策略#

策略说明一致性维护成本
内存淘汰Redis 内存不足时自动淘汰差无
超时剔除设置 TTL,到期删除一般低
主动更新修改数据库时更新或删除缓存好高

业务建议:

  • 低一致性需求:使用内存淘汰或超时剔除。
  • 高一致性需求:主动更新,并以超时剔除兜底。

2.4 数据库与缓存一致性#

常见方案:

  1. Cache Aside Pattern:人工编码,更新数据库后删除缓存。
  2. Read/Write Through Pattern:缓存与数据库整合为一个服务。
  3. Write Behind Caching Pattern:只操作缓存,异步写数据库。

实际项目中推荐 Cache Aside Pattern:先操作数据库,再删除缓存。

实现商户缓存双写一致:

@PutMapping
public Result updateShop(@RequestBody Shop shop) {
return shopService.update(shop);
}

Service:

@Override
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空");
}
updateById(shop);
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}

2.5 缓存穿透#

缓存穿透:请求的数据在缓存和数据库中都不存在,请求会频繁打到数据库。

解决方案:

  • 缓存空对象。
  • 布隆过滤。
  • 增强 id 复杂度。
  • 做好数据格式校验。
  • 加强用户权限校验。
  • 热点参数限流。

缓存空对象实现:

@Override
public Result queryById(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
if (shopJson != null) {
return Result.fail("店铺不存在!!");
}
Shop shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
"",
CACHE_NULL_TTL,
TimeUnit.MINUTES
);
return Result.fail("店铺不存在!!");
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
jsonStr,
CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
return Result.ok(shop);
}

2.6 缓存雪崩#

缓存雪崩:同一时间段大量缓存 Key 同时失效,或 Redis 服务宕机,导致大量请求到达数据库。

解决方案:

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

2.7 缓存击穿#

缓存击穿:高并发访问且缓存重建业务复杂的 Key 突然失效,大量请求瞬间打到数据库。

解决方案:

方案优点缺点
互斥锁没有额外内存消耗、保证一致性、实现简单线程需要等待、性能受影响、可能死锁
逻辑过期线程无需等待、性能较好不保证一致性、有额外内存消耗、实现复杂

互斥锁解决缓存击穿#

private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}

查询逻辑:

@Override
public Shop queryWithMutex(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isNotBlank(shopJson)) {
return JSONUtil.toBean(shopJson, Shop.class);
}
if (shopJson != null) {
return null;
}
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean flag = tryLock(lockKey);
if (!flag) {
Thread.sleep(50);
return queryWithMutex(id);
}
shop = getById(id);
if (shop == null) {
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
"",
CACHE_NULL_TTL,
TimeUnit.MINUTES
);
return null;
}
String jsonStr = JSONUtil.toJsonStr(shop);
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
jsonStr,
CACHE_SHOP_TTL,
TimeUnit.MINUTES
);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return shop;
}

逻辑过期解决缓存击穿#

封装逻辑过期数据:

@Data
public class RedisData<T> {
private LocalDateTime expireTime;
private T data;
}

写入逻辑过期数据:

public void saveShop2Redis(Long id, Long expireSeconds) {
Shop shop = getById(id);
RedisData<Shop> redisData = new RedisData<>();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
stringRedisTemplate.opsForValue().set(
CACHE_SHOP_KEY + id,
JSONUtil.toJsonStr(redisData)
);
}

查询逻辑:

private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
@Override
public Shop queryWithLogicalExpire(Long id) {
String json = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
if (StrUtil.isBlank(json)) {
return null;
}
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
JSONObject shopJson = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return shop;
}
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
if (flag) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
return shop;
}

2.8 封装缓存工具类#

创建 CacheClient:

@Slf4j
@Component
public class CacheClient {
private final StringRedisTemplate stringRedisTemplate;
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
public CacheClient(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public void set(String key, Object value, Long time, TimeUnit timeUnit) {
stringRedisTemplate.opsForValue().set(
key,
JSONUtil.toJsonStr(value),
time,
timeUnit
);
}
public void setWithLogicExpire(String key, Object value, Long time, TimeUnit timeUnit) {
RedisData<Object> redisData = new RedisData<>();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(timeUnit.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 timeUnit
) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
R r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(
key,
"",
CACHE_NULL_TTL,
TimeUnit.MINUTES
);
return null;
}
this.set(key, r, time, timeUnit);
return r;
}
public <R, ID> R queryWithMutex(
String keyPrefix,
ID id,
Class<R> type,
Function<ID, R> dbFallback,
Long time,
TimeUnit timeUnit
) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
if (json != null) {
return null;
}
R r = null;
String lockKey = LOCK_SHOP_KEY + id;
try {
boolean flag = tryLock(lockKey);
if (!flag) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, id, type, dbFallback, time, timeUnit);
}
r = dbFallback.apply(id);
if (r == null) {
stringRedisTemplate.opsForValue().set(
key,
"",
CACHE_NULL_TTL,
TimeUnit.MINUTES
);
return null;
}
this.set(key, r, time, timeUnit);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
return r;
}
public <R, ID> R queryWithLogicalExpire(
String keyPrefix,
ID id,
Class<R> type,
Function<ID, R> dbFallback,
Long time,
TimeUnit timeUnit
) {
String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isBlank(json)) {
return null;
}
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
if (expireTime.isAfter(LocalDateTime.now())) {
return r;
}
String lockKey = LOCK_SHOP_KEY + id;
boolean flag = tryLock(lockKey);
if (flag) {
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
R tmp = dbFallback.apply(id);
this.setWithLogicExpire(key, tmp, time, timeUnit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
return r;
}
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
}

三、优惠券秒杀#

3.1 Redis 实现全局唯一 ID#

数据库自增 ID 存在两个问题:

  • ID 规律太明显,容易泄露业务信息。
  • 单表数据量受限,分库分表后 ID 不能重复。

全局 ID 生成器需要满足:

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

ID 结构:

部分位数说明
符号位1 bit永远为 0
时间戳31 bit以秒为单位,可用约 69 年
序列号32 bit秒内计数器

实现:

@Component
public class RedisIdWorker {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public static final Long BEGIN_TIMESTAMP = 1640995200L;
public static final Long COUNT_BIT = 32L;
public long nextId(String keyPrefix) {
LocalDateTime now = LocalDateTime.now();
long currentSecond = now.toEpochSecond(ZoneOffset.UTC);
long timeStamp = currentSecond - BEGIN_TIMESTAMP;
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue()
.increment("inc:" + keyPrefix + ":" + date);
return timeStamp << COUNT_BIT | count;
}
}

3.2 添加优惠券#

优惠券分为平价券和特价券。特价券需要记录库存、开始时间、结束时间。

新增秒杀券:

@PostMapping("/seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}

Service:

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
save(voucher);
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
stringRedisTemplate.opsForValue().set(
SECKILL_STOCK_KEY + voucher.getId(),
voucher.getStock().toString()
);
}

3.3 实现秒杀下单#

基础流程:

  1. 查询优惠券。
  2. 判断秒杀是否开始。
  3. 判断秒杀是否结束。
  4. 判断库存是否充足。
  5. 扣减库存。
  6. 创建订单。
  7. 返回订单 ID。

基础代码:

@Override
public Result seckillVoucher(Long voucherId) {
LambdaQueryWrapper<SeckillVoucher> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SeckillVoucher::getVoucherId, voucherId);
SeckillVoucher seckillVoucher = seckillVoucherService.getOne(queryWrapper);
if (LocalDateTime.now().isBefore(seckillVoucher.getBeginTime())) {
return Result.fail("秒杀还未开始,请耐心等待");
}
if (LocalDateTime.now().isAfter(seckillVoucher.getEndTime())) {
return Result.fail("秒杀已经结束!");
}
if (seckillVoucher.getStock() < 1) {
return Result.fail("优惠券已被抢光了哦");
}
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
Long userId = UserHolder.getUser().getId();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}

3.4 超卖问题#

超卖问题属于多线程安全问题。常见解决方案:

  • 悲观锁:认为线程安全问题一定会发生,操作前加锁。
  • 乐观锁:认为线程安全问题不一定发生,更新时判断数据是否被修改。

本项目可以使用库存作为版本号:

boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();

只要库存大于 0,就允许扣减,可以避免大量失败。

3.5 一人一单#

需求:同一个优惠券,一个用户只能抢一张。

初步实现:

Long userId = UserHolder.getUser().getId();
int count = query()
.eq("voucher_id", voucherId)
.eq("user_id", userId)
.count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}

由于并发情况下仍然可能重复下单,需要使用锁。

将一人一单逻辑抽取为 createVoucherOrder:

@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
int count = query()
.eq("voucher_id", voucherId)
.eq("user_id", userId)
.count();
if (count > 0) {
return Result.fail("你已经抢过优惠券了哦");
}
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}

调用时需要通过代理对象调用,确保事务生效:

Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}

启动类开启:

@EnableAspectJAutoProxy(exposeProxy = true)

3.6 集群环境下的并发问题#

单机 synchronized 只能锁住当前 JVM。集群部署时,不同 Tomcat 有不同 JVM,锁对象不是同一个,因此会失效。

解决方案:使用分布式锁。


四、分布式锁#

4.1 分布式锁基本要求#

  • 可见性:多个线程都能看到相同结果。
  • 互斥:程序串行执行。
  • 高可用:程序不易崩溃。
  • 高性能:加锁和释放锁性能要好。
  • 安全性:异常时能自动释放锁。

常见实现:

方案互斥高可用高性能安全性
MySQL数据库锁好一般断开连接自动释放
RedisSETNX好好超时释放
Zookeeper节点唯一性好一般临时节点自动释放

4.2 Redis 分布式锁核心思路#

获取锁:

Terminal window
SET lock thread01 NX EX 10

释放锁:

Terminal window
DEL lock

实现接口:

public interface ILock {
boolean tryLock(long timeoutSec);
void unlock();
}

实现类:

public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private final String name;
private final StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
long threadId = Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

业务使用:

SimpleRedisLock redisLock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = redisLock.tryLock(120);
if (!isLock) {
return Result.fail("不允许抢多张优惠券");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
redisLock.unlock();
}

4.3 分布式锁误删问题#

如果线程 1 阻塞导致锁 TTL 到期,线程 2 获取锁;线程 1 恢复后直接删除锁,就会误删线程 2 的锁。

解决方案:锁中存入线程标识,释放时判断是否属于自己。

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}

4.4 Lua 脚本保证原子性#

判断锁标识和删除锁必须是原子操作,否则仍可能误删。

Lua 脚本 unlock.lua:

local key = KEYS[1]
local threadId = ARGV[1]
if (redis.call('get', key) == threadId) then
return redis.call('del', key)
end
return 0

Java 调用:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId()
);
}

4.5 Redisson#

基于 SETNX 的分布式锁存在以下问题:

  • 不可重入。
  • 不可重试。
  • 超时释放可能导致锁失效。
  • 主从一致性存在问题。

Redisson 提供了完整解决方案。

导入依赖:

<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>

配置:

@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword("root");
return Redisson.create(config);
}
}

使用:

@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws InterruptedException {
RLock lock = redissonClient.getLock("anyLock");
boolean success = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (success) {
try {
System.out.println("执行业务");
} finally {
lock.unlock();
}
}
}

替换秒杀业务中的锁:

@Resource
private RedissonClient redissonClient;
RLock redisLock = redissonClient.getLock("order:" + userId);
boolean isLock = redisLock.tryLock();
if (!isLock) {
return Result.fail("不允许抢多张优惠券");
}
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
redisLock.unlock();
}

Redisson 可重入锁使用 Hash 结构记录线程标识与重入次数,并通过 WatchDog 机制自动续期。

Redisson MultiLock 用于主从一致性场景:多个独立 Redis 节点都加锁成功,才算加锁成功。


五、秒杀优化#

5.1 异步秒杀思路#

原始下单流程中,很多步骤需要访问数据库,并且串行执行,性能较差。

优化思路:

  1. 将库存判断、一人一单判断放到 Redis 中完成。
  2. 使用 Lua 脚本保证判断与扣减库存的原子性。
  3. 判断成功后,将下单信息放入消息队列。
  4. 后台线程异步消费消息,完成数据库下单。

5.2 Redis 完成秒杀资格判断#

Lua 脚本 seckill.lua:

local voucherId = ARGV[1]
local userId = ARGV[2]
local id = ARGV[3]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1
end
if (redis.call('sismember', orderKey, userId) == 1) then
return 2
end
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', id)
return 0

业务逻辑:

@Override
public Result seckillVoucher(Long voucherId) {
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
UserHolder.getUser().getId().toString(),
String.valueOf(orderId)
);
if (result.intValue() != 0) {
return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
}
proxy = (IVoucherOrderService) AopContext.currentProxy();
return Result.ok(orderId);
}

5.3 基于阻塞队列实现异步秒杀#

创建阻塞队列:

private final BlockingQueue<VoucherOrder> orderTasks =
new ArrayBlockingQueue<>(1024 * 1024);

将订单加入队列:

VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setId(orderId);
orderTasks.add(voucherOrder);

线程池消费:

private static final ExecutorService SECKILL_ORDER_EXECUTOR =
Executors.newSingleThreadExecutor();
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
VoucherOrder voucherOrder = orderTasks.take();
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("订单处理异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
RLock redisLock = redissonClient.getLock("order:" + userId);
boolean isLock = redisLock.tryLock();
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
redisLock.unlock();
}
}
阻塞队列的问题
  • 受 JVM 内存限制,高并发时可能内存溢出。
  • 服务宕机时,队列中的订单可能丢失。

5.4 Redis 消息队列#

方案消息持久化阻塞读取消息确认消息回溯
List支持支持不支持不支持
PubSub不支持支持不支持不支持
Stream支持支持支持支持

基于 List#

Terminal window
LPUSH queue msg
BRPOP queue

优点:

  • 利用 Redis 存储,不受 JVM 内存上限限制。
  • 基于 Redis 持久化机制,数据安全性有保障。
  • 可以满足消息有序性。

缺点:

  • 无法避免消息丢失。
  • 只支持单消费者。

基于 PubSub#

Terminal window
SUBSCRIBE channel
PUBLISH channel msg
PSUBSCRIBE pattern

优点:支持多生产、多消费。

缺点:不支持持久化、可能丢消息、消息堆积有上限。

基于 Stream#

发送消息:

Terminal window
XADD users * name jack age 21

读取消息:

Terminal window
XREAD COUNT 1 BLOCK 2000 STREAMS users $

创建消费者组:

Terminal window
XGROUP CREATE stream.orders g1 0 MKSTREAM

消费者组读取:

Terminal window
XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >

确认消息:

Terminal window
XACK stream.orders g1 消息ID

Stream 特点:

  • 消息可回溯。
  • 多消费者争抢消息。
  • 可阻塞读取。
  • 无消息漏读风险。
  • 有消息确认机制。

5.5 基于 Stream 实现异步秒杀#

Lua 脚本中直接写入 Stream:

redis.call('xadd', 'stream.orders', '*',
'userId', userId,
'voucherId', voucherId,
'id', id)

消费者处理:

private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
List<MapRecord<String, Object, Object>> list =
stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) {
continue;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
value,
new VoucherOrder(),
true
);
handleVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(
"stream.orders",
"g1",
record.getId()
);
} catch (Exception e) {
log.error("处理订单异常", e);
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
List<MapRecord<String, Object, Object>> list =
stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
if (list == null || list.isEmpty()) {
break;
}
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
value,
new VoucherOrder(),
true
);
handleVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge(
"stream.orders",
"g1",
record.getId()
);
} catch (Exception e) {
log.error("处理 pending-list 异常", e);
}
}
}
}

六、达人探店#

6.1 发布探店笔记#

保存探店笔记:

@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
blogService.save(blog);
return Result.ok(blog.getId());
}

上传图片:

@PostMapping("blog")
public Result uploadImage(@RequestParam("file") MultipartFile image) {
try {
String originalFilename = image.getOriginalFilename();
String fileName = createNewFileName(originalFilename);
image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));
return Result.ok(fileName);
} catch (IOException e) {
throw new RuntimeException("文件上传失败", e);
}
}

6.2 查看探店笔记#

@GetMapping("/{id}")
public Result queryById(@PathVariable Integer id) {
return blogService.queryById(id);
}

Service:

@Override
public Result queryById(Integer id) {
Blog blog = getById(id);
if (blog == null) {
return Result.fail("评价不存在或已被删除");
}
queryBlogUser(blog);
isBlogLiked(blog);
return Result.ok(blog);
}

6.3 点赞功能#

最初实现只是简单给点赞数 +1,会导致用户无限点赞。

使用 Redis Set 或 SortedSet 判断是否已点赞。

@Override
public Result likeBlog(Long id) {
Long userId = UserHolder.getUser().getId();
String key = BLOG_LIKED_KEY + id;
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
if (score == null) {
boolean success = update()
.setSql("liked = liked + 1")
.eq("id", id)
.update();
if (success) {
stringRedisTemplate.opsForZSet().add(
key,
userId.toString(),
System.currentTimeMillis()
);
}
} else {
boolean success = update()
.setSql("liked = liked - 1")
.eq("id", id)
.update();
if (success) {
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}

判断当前用户是否点赞:

private void isBlogLiked(Blog blog) {
UserDTO userDTO = UserHolder.getUser();
if (userDTO == null) {
return;
}
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet()
.score(key, userDTO.getId().toString());
blog.setIsLike(score != null);
}

6.4 点赞排行榜#

查询点赞 Top5:

@GetMapping("/likes/{id}")
public Result queryBlogLikes(@PathVariable Integer id) {
return blogService.queryBlogLikes(id);
}

Service:

@Override
public Result queryBlogLikes(Integer id) {
String key = BLOG_LIKED_KEY + id;
Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);
if (top5 == null || top5.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idsStr = StrUtil.join(",", ids);
List<UserDTO> userDTOS = userService.query()
.in("id", ids)
.last("order by field(id," + idsStr + ")")
.list()
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(userDTOS);
}

七、好友关注与 Feed 流#

7.1 关注与取消关注#

判断是否关注:

@GetMapping("/or/not/{id}")
public Result isFollow(@PathVariable("id") Long followUserId) {
return followService.isFollow(followUserId);
}

关注/取关:

@PutMapping("/{id}/{isFollow}")
public Result follow(
@PathVariable("id") Long followUserId,
@PathVariable("isFollow") Boolean isFollow
) {
return followService.follow(followUserId, isFollow);
}

Service:

@Override
public Result isFollow(Long followUserId) {
Long userId = UserHolder.getUser().getId();
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId)
.eq(Follow::getFollowUserId, followUserId);
int count = count(queryWrapper);
return Result.ok(count > 0);
}
@Override
public Result follow(Long followUserId, Boolean isFollow) {
Long userId = UserHolder.getUser().getId();
String key = "follows:" + userId;
if (isFollow) {
Follow follow = new Follow();
follow.setUserId(userId);
follow.setFollowUserId(followUserId);
boolean success = save(follow);
if (success) {
stringRedisTemplate.opsForSet().add(key, followUserId.toString());
}
} else {
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getUserId, userId)
.eq(Follow::getFollowUserId, followUserId);
boolean success = remove(queryWrapper);
if (success) {
stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
}
}
return Result.ok();
}

7.2 共同关注#

利用 Redis Set 求交集:

@GetMapping("/common/{id}")
public Result followCommons(@PathVariable Long id) {
return followService.followCommons(id);
}

Service:

@Override
public Result followCommons(Long id) {
Long userId = UserHolder.getUser().getId();
String key1 = "follows:" + userId;
String key2 = "follows:" + id;
Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
if (intersect == null || intersect.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
List<UserDTO> users = userService.listByIds(ids)
.stream()
.map(user -> BeanUtil.copyProperties(user, UserDTO.class))
.collect(Collectors.toList());
return Result.ok(users);
}

7.3 Feed 流#

Feed 流有两种模式:

  • Timeline:按发布时间排序,适合好友/关注场景。
  • 智能排序:根据算法推荐用户感兴趣的内容。

Timeline 三种实现方案:

方案说明优点缺点
拉模式读时拉取关注人的发件箱节约空间有延迟,关注多时压力大
推模式写时推送到粉丝收件箱时效快内存压力大
推拉结合普通人推、大V拉折中实现复杂

7.4 推送到粉丝收件箱#

保存笔记后,推送到粉丝的 SortedSet:

@Override
public Result saveBlog(Blog blog) {
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
save(blog);
LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Follow::getFollowUserId, user.getId());
List<Follow> follows = followService.list(queryWrapper);
for (Follow follow : follows) {
Long userId = follow.getUserId();
String key = FEED_KEY + userId;
stringRedisTemplate.opsForZSet().add(
key,
blog.getId().toString(),
System.currentTimeMillis()
);
}
return Result.ok(blog.getId());
}

7.5 滚动分页查询收件箱#

Feed 流数据不断更新,不能使用传统分页,需要使用滚动分页。

请求参数:

  • lastId:上一次查询的最小时间戳。
  • offset:偏移量。

通用结果类:

@Data
public class ScrollResult {
private List<?> list;
private Long minTime;
private Integer offset;
}

Controller:

@GetMapping("/of/follow")
public Result queryBlogOfFollow(
@RequestParam("lastId") Long max,
@RequestParam(value = "offset", defaultValue = "0") Integer offset
) {
return blogService.queryBlogOfFollow(max, offset);
}

Service:

@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
Long userId = UserHolder.getUser().getId();
String key = FEED_KEY + userId;
Set<ZSetOperations.TypedTuple<String>> typedTuples =
stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
if (typedTuples == null || typedTuples.isEmpty()) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
ids.add(Long.valueOf(tuple.getValue()));
long time = tuple.getScore().longValue();
if (time == minTime) {
os++;
} else {
minTime = time;
os = 1;
}
}
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = query()
.in("id", ids)
.last("order by field(id," + idStr + ")")
.list();
for (Blog blog : blogs) {
queryBlogUser(blog);
isBlogLiked(blog);
}
ScrollResult r = new ScrollResult();
r.setList(blogs);
r.setOffset(os);
r.setMinTime(minTime);
return Result.ok(r);
}

八、附近商户#

8.1 GEO 基本用法#

命令说明
GEOADD添加地理坐标
GEODIST计算两点距离
GEOHASH返回 Geohash 字符串
GEOPOS返回坐标
GEORADIUS根据圆心和半径查询(6.2 后废弃)
GEOSEARCH在指定范围内搜索(6.2 新功能)
GEOSEARCHSTORE搜索并存储结果

示例:

Terminal window
GEOADD china 13.361389 38.115556 "shanghai" 15.087269 37.502669 "beijing"
GEODIST china beijing shanghai km
GEOSEARCH china FROMLONLAT 15 37 BYRADIUS 200 km ASC WITHCOORD WITHDIST

8.2 导入店铺数据到 GEO#

@Test
public void loadShopData() {
List<Shop> shopList = shopService.list();
Map<Long, List<Shop>> map = shopList.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
Long typeId = entry.getKey();
List<Shop> shops = entry.getValue();
String key = SHOP_GEO_KEY + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations =
new ArrayList<>(shops.size());
for (Shop shop : shops) {
locations.add(new RedisGeoCommands.GeoLocation<>(
shop.getId().toString(),
new Point(shop.getX(), shop.getY())
));
}
stringRedisTemplate.opsForGeo().add(key, locations);
}
}

8.3 实现附近商户功能#

SpringDataRedis 2.3.9 不支持 Redis 6.2 的 GEOSEARCH,需要升级:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>

Controller:

@GetMapping("/of/type")
public Result queryShopByType(
@RequestParam("typeId") Integer typeId,
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam(value = "x", required = false) Double x,
@RequestParam(value = "y", required = false) Double y
) {
return shopService.queryShopByType(typeId, current, x, y);
}

Service:

@Override
public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
if (x == null || y == null) {
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
return Result.ok(page.getRecords());
}
int from = (current - 1) * SystemConstants.MAX_PAGE_SIZE;
int end = current * SystemConstants.MAX_PAGE_SIZE;
String key = SHOP_GEO_KEY + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results =
stringRedisTemplate.opsForGeo().search(
key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
.includeDistance()
.limit(end)
);
if (results == null) {
return Result.ok(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() < from) {
return Result.ok(Collections.emptyList());
}
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
list.stream().skip(from).forEach(result -> {
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
distanceMap.put(shopIdStr, result.getDistance());
});
String idsStr = StrUtil.join(",", ids);
List<Shop> shops = query()
.in("id", ids)
.last("ORDER BY FIELD(id," + idsStr + ")")
.list();
for (Shop shop : shops) {
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return Result.ok(shops);
}

九、用户签到#

9.1 BitMap 介绍#

BitMap 使用二进制位记录业务状态,签到为 1,未签到为 0。

Redis 中 BitMap 基于 String 实现,最大 512M,即 2^32 个 bit。

常用命令:

命令说明
SETBIT向指定位置存入 0 或 1
GETBIT获取指定位置的 bit 值
BITCOUNT统计值为 1 的 bit 数量
BITFIELD操作 bit 数组指定位置
BITOP多个 BitMap 位运算
BITPOS查找第一个 0 或 1

9.2 实现签到功能#

@PostMapping("/sign")
public Result sign() {
return userService.sign();
}

Service:

@Override
public Result sign() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}

9.3 签到统计#

统计截止当前时间的连续签到天数:

@GetMapping("/sign/count")
public Result signCount() {
return userService.signCount();
}

Service:

@Override
public Result signCount() {
Long userId = UserHolder.getUser().getId();
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = USER_SIGN_KEY + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
.valueAt(0)
);
if (result == null || result.isEmpty()) {
return Result.ok(0);
}
int count = 0;
Long num = result.get(0);
while (true) {
if ((num & 1) == 0) {
break;
}
count++;
num >>>= 1;
}
return Result.ok(count);
}

十、UV 统计#

10.1 HyperLogLog#

  • UV:Unique Visitor,独立访客量,同一用户一天内多次访问只记录一次。
  • PV:Page View,页面访问量,每访问一次记录一次。

HyperLogLog 是概率算法,用于统计大规模集合基数,内存占用极低,单个 HLL 小于 16KB,误差约 0.81%。

常用命令:

命令说明
PFADD key element ...添加元素
PFCOUNT key ...统计基数
PFMERGE destkey sourcekey ...合并多个 HLL

测试百万数据:

@Test
public void testHyperLogLog() {
String[] users = new String[1000];
int j = 0;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
users[j] = "user_" + i;
if (j == 999) {
stringRedisTemplate.opsForHyperLogLog().add("HLL", users);
}
}
Long count = stringRedisTemplate.opsForHyperLogLog().size("HLL");
System.out.println("count = " + count);
}

百万数据测试结果误差很小,内存占用仅约 12KB,非常适合 UV 统计场景。


总结#

本文围绕黑马点评项目,串联了 Redis 在实际业务中的典型用法:

  • 使用 Redis 替代 Session,实现 Token 登录。
  • 使用缓存解决商户查询性能问题,并处理缓存穿透、雪崩、击穿。
  • 使用 Redis 全局 ID、Lua 脚本、分布式锁、消息队列优化优惠券秒杀。
  • 使用 Set、SortedSet 实现点赞、关注、共同关注和 Feed 流。
  • 使用 GEO 实现附近商户查询。
  • 使用 BitMap 实现签到统计。
  • 使用 HyperLogLog 实现 UV 统计。
下一步建议

可以继续深入学习 Redis 持久化、主从复制、哨兵、分片集群,以及 Redisson 在生产环境中的锁续期、联锁、读写锁等高级特性。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Redis 实战笔记:短信登录、缓存、秒杀、分布式锁与社交功能
https://firefly.cuteleaf.cn/posts/learn-redis-ex/
作者
幽流啊飘
发布于
2025-08-12
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
幽流啊飘
Hello, I'm 幽流啊飘.
公告
欢迎来到我的博客!
分类
标签
最新动态
站点统计
文章
2
分类
1
标签
9
总字数
9,419
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Cloudflare Pages
博客版本
Firefly v6.16.8
文章许可
CC BY-NC-SA 4.0
文章目录