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 / RedisNginx 可以作为静态资源服务器、反向代理和负载均衡器;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 目录执行:
start nginx.exe访问:
http://localhost:8080/1.2 基于 Session 实现登录流程
发送验证码
- 用户提交手机号/邮箱。
- 后端校验格式是否合法。
- 合法则生成验证码并保存。
- 通过短信或邮件发送验证码。
登录/注册
- 用户输入账号和验证码。
- 后端从 Session 中取出验证码进行校验。
- 校验通过后根据账号查询用户。
- 用户不存在则创建用户。
- 将用户信息保存到 Session。
校验登录状态
- 用户请求时携带 Cookie 中的
JsessionId。 - 后端通过
JsessionId从 Session 中获取用户信息。 - 没有用户信息则拦截。
- 有用户信息则保存到
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:
@Datapublic 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。
整体流程:
- 用户提交账号和验证码。
- 校验验证码。
- 根据账号查询用户,不存在则创建。
- 生成随机 Token。
- 将用户信息以 Hash 结构存入 Redis。
- 返回 Token 给前端。
- 后续请求携带 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(); }}配置类:
@Configurationpublic 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 实现:
@Overridepublic 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 数据库与缓存一致性
常见方案:
- Cache Aside Pattern:人工编码,更新数据库后删除缓存。
- Read/Write Through Pattern:缓存与数据库整合为一个服务。
- Write Behind Caching Pattern:只操作缓存,异步写数据库。
实际项目中推荐 Cache Aside Pattern:先操作数据库,再删除缓存。
实现商户缓存双写一致:
@PutMappingpublic Result updateShop(@RequestBody Shop shop) { return shopService.update(shop);}Service:
@Override@Transactionalpublic 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 复杂度。
- 做好数据格式校验。
- 加强用户权限校验。
- 热点参数限流。
缓存空对象实现:
@Overridepublic 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);}查询逻辑:
@Overridepublic 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;}逻辑过期解决缓存击穿
封装逻辑过期数据:
@Datapublic 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);
@Overridepublic 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@Componentpublic 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 | 秒内计数器 |
实现:
@Componentpublic 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@Transactionalpublic 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 实现秒杀下单
基础流程:
- 查询优惠券。
- 判断秒杀是否开始。
- 判断秒杀是否结束。
- 判断库存是否充足。
- 扣减库存。
- 创建订单。
- 返回订单 ID。
基础代码:
@Overridepublic 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:
@Transactionalpublic 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 | 数据库锁 | 好 | 一般 | 断开连接自动释放 |
| Redis | SETNX | 好 | 好 | 超时释放 |
| Zookeeper | 节点唯一性 | 好 | 一般 | 临时节点自动释放 |
4.2 Redis 分布式锁核心思路
获取锁:
SET lock thread01 NX EX 10释放锁:
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) + "-";
@Overridepublic 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);}
@Overridepublic 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)endreturn 0Java 调用:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class);}
@Overridepublic 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>配置:
@Configurationpublic 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); }}使用:
@Resourceprivate RedissonClient redissonClient;
@Testvoid 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(); } }}替换秒杀业务中的锁:
@Resourceprivate 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 异步秒杀思路
原始下单流程中,很多步骤需要访问数据库,并且串行执行,性能较差。
优化思路:
- 将库存判断、一人一单判断放到 Redis 中完成。
- 使用 Lua 脚本保证判断与扣减库存的原子性。
- 判断成功后,将下单信息放入消息队列。
- 后台线程异步消费消息,完成数据库下单。
5.2 Redis 完成秒杀资格判断
Lua 脚本 seckill.lua:
local voucherId = ARGV[1]local userId = ARGV[2]local id = ARGV[3]
local stockKey = 'seckill:stock:' .. voucherIdlocal orderKey = 'seckill:order:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then return 1end
if (redis.call('sismember', orderKey, userId) == 1) then return 2end
redis.call('incrby', stockKey, -1)redis.call('sadd', orderKey, userId)redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', id)
return 0业务逻辑:
@Overridepublic 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();
@PostConstructprivate 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
LPUSH queue msgBRPOP queue优点:
- 利用 Redis 存储,不受 JVM 内存上限限制。
- 基于 Redis 持久化机制,数据安全性有保障。
- 可以满足消息有序性。
缺点:
- 无法避免消息丢失。
- 只支持单消费者。
基于 PubSub
SUBSCRIBE channelPUBLISH channel msgPSUBSCRIBE pattern优点:支持多生产、多消费。
缺点:不支持持久化、可能丢消息、消息堆积有上限。
基于 Stream
发送消息:
XADD users * name jack age 21读取消息:
XREAD COUNT 1 BLOCK 2000 STREAMS users $创建消费者组:
XGROUP CREATE stream.orders g1 0 MKSTREAM消费者组读取:
XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >确认消息:
XACK stream.orders g1 消息IDStream 特点:
- 消息可回溯。
- 多消费者争抢消息。
- 可阻塞读取。
- 无消息漏读风险。
- 有消息确认机制。
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 发布探店笔记
保存探店笔记:
@PostMappingpublic 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:
@Overridepublic 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 判断是否已点赞。
@Overridepublic 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:
@Overridepublic 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:
@Overridepublic 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);}
@Overridepublic 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:
@Overridepublic 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:
@Overridepublic 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:偏移量。
通用结果类:
@Datapublic 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:
@Overridepublic 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 | 搜索并存储结果 |
示例:
GEOADD china 13.361389 38.115556 "shanghai" 15.087269 37.502669 "beijing"GEODIST china beijing shanghai kmGEOSEARCH china FROMLONLAT 15 37 BYRADIUS 200 km ASC WITHCOORD WITHDIST8.2 导入店铺数据到 GEO
@Testpublic 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:
@Overridepublic 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:
@Overridepublic 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:
@Overridepublic 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 |
测试百万数据:
@Testpublic 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 在生产环境中的锁续期、联锁、读写锁等高级特性。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!




