-
- 本项目(新哥点评)是一个以社交平台为核心的轻电商项目。其主要围绕着 Redis 各种技术展开,并且项目中也包含了许多电商的业务场景,如短信登录、商户查询缓存、优惠劵秒杀、附近的商户、UV统计、用户签到、好友关注、达人探店等。可以说,此项目把 Redis 中各种各样的数据结构、各种各样的应用姿势“玩”了个遍。
-
- 共享session登录问题
- 这部分会使用Redis来处理Session不共享问题。
- 但其实我在之前的瑞吉外卖的项目优化部分就做过了,用Redis替换session来存储邮箱验证码。
- 商户查询缓存
- 这部分要理解缓存击穿,缓存穿透,缓存雪崩等问题,对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容。
- 优惠劵秒杀
- 这部分我们可以学会Redis的计数器功能,结合Lua脚本完成高性能的Redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列。
- 达人探店
- 基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能。
- 好友关注
- 基于Set集合的关注、取消关注,共同关注等等功能。
- 附近商户
- 利用Redis的GEOHash结构来完成对于地理坐标的操作。
- 用户签到
- 使用Redis的BitMap数据实现签到以及统计功能。
- UV统计
- 主要是使用Redis的HyperLogLog结构来完成统计功能。
- 共享session登录问题
-
- 后端
- SpringMVC框架、SpringBoot框架
- Mysql数据库
- Mybatis Plus工具
- Maven工具
- Redis缓存
- Lua脚本
- 前端
- Nginx服务器
- HTML、CSS、JS技术
- VUE框架
- Element-UI框架
- 调试、测试
- Postman
- JMeter
- 后端
-
- 当我们发送验证码时,以手机号为key,存储验证码(String)
- 登录验证通过后,以随机token为key,存储用户数据(Hash)
- 每次发送请求时,需要进行身份校验,并且还需要刷新token有效期,这些步骤适合放在拦截器中处理
-
@Override public Result sendCode(String phone, HttpSession session) { // 1.校验手机号 if (RegexUtils.isPhoneInvalid(phone)){ // 2.如果不符合,返回错误消息 return Result.fail("手机号码格式错误!"); } // 3.符合,生成验证码 String code = RandomUtil.randomNumbers(6); // 4.保存验证码到 redis stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES); // 5.发送验证码 log.debug("发送短信验证码成功,验证码:{}",code); // 返回ok return Result.ok(); } -
@Override public Result login(LoginFormDTO loginForm, HttpSession session) { // 1.校验手机号 String phone = loginForm.getPhone(); if (RegexUtils.isPhoneInvalid(phone)){ // 2.如果不符合,返回错误消息 return Result.fail("手机号码格式错误!"); } // 3.从redis获取验证码并校验 String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone); String code = loginForm.getCode(); if (cacheCode == null || !cacheCode.equals(code)){ // 3.1.不一致,报错 return Result.fail("验证码错误"); } // 4.一致,根据手机号查询用户 User user = query().eq("phone", phone).one(); // 5.判断用户是否存在 if (user == null){ // 6.不存在,创建新用户并保存 user = createUserWithPhone(phone); } // 7.保存用户信息到 redis 中 // 7.1.随机生成token,作为登录令牌 String token = UUID.randomUUID().toString(true); // 7.2.将User对象转为HashMap存储 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())); // 7.3.存储 String tokenKey = LOGIN_USER_KEY + token; stringRedisTemplate.opsForHash().putAll(tokenKey,userMap); // 7.4.设置token有效期 stringRedisTemplate.expire(tokenKey,LOGIN_USER_TTL,TimeUnit.MINUTES); // 8.返回token return Result.ok(token); } -
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1.获取请求头中的token String token = request.getHeader("authorization"); if (StrUtil.isBlank(token)){ return true; } // 2.基于token获取redis中的用户 String key = RedisConstants.LOGIN_USER_KEY + token; Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key); // 3.判断用户是否存在 if (userMap.isEmpty()){ return true; } // 4.将查询到的Hash数据转为UserDTO对象 UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false); // 5.存在,保存用户信息到ThreadLocal,方便在后面service中拿出使用 UserHolder.saveUser(userDTO); // 6.刷新token有效期 stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES); // 7.放行 return true; } -
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 判断是否需要拦截(ThreadLocal中是否有用户) if (UserHolder.getUser() == null){ // 没有,需要拦截,设置状态码 response.setStatus(401); // 拦截 return false; } // 有用户,则放行 return true; }
-
-
- 使用缓存空对象来解决缓存穿透问题
- 使用给不同Key的TTL添加随机值来解决缓存雪崩问题
- 分别使用逻辑锁和逻辑过期来解决缓存击穿问题
-
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); // 2.判断是否存在 if (StrUtil.isNotBlank(json)){ // 3.存在,直接返回 return JSONUtil.toBean(json,type); } // 判断命中的是否是空值 if (json != null){ // 返回一个错误信息 return null; } // 4.不存在,根据id查询数据库 R r = dbFallback.apply(id); // 5.不存在,返回错误 if (r == null){ // 将空值写入redis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在,写入redis this.set(key,r,time,unit); // 7.返回 return r; } -
public Shop queryWithMutex(Long id){ String key = CACHE_SHOP_KEY + id; // 1.从redis查询商铺缓存 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isNotBlank(shopJson)){ // 3.存在,直接返回 return JSONUtil.toBean(shopJson, Shop.class); } // 判断命中的是否是空值 if (shopJson != null){ // 返回一个错误信息 return null; } // 4.实现缓存重建 // 4.1.获取互斥锁 String lockKey = "lock:shop:" + id; Shop shop = null; try { boolean isLock = tryLock(lockKey); // 4.2.判断是否获取成功 if (!isLock){ // 4.3.失败,则休眠并重试 Thread.sleep(50); return queryWithMutex(id); } // 4.4.成功,根据id查询数据库 shop = getById(id); // 模拟重建延时 Thread.sleep(200); // 5.不存在,返回错误 if (shop == null){ // 将空值写入redis stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES); // 返回错误信息 return null; } // 6.存在,写入redis stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); }finally { // 7.释放互斥锁 unlock(lockKey); } // 8.返回 return shop; } -
private 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查询商铺缓存 String json = stringRedisTemplate.opsForValue().get(key); // 2.判断是否存在 if (StrUtil.isBlank(json)){ // 3.未命中,直接返回 return null; } // 4.命中,需先把json反序列化为对象 RedisData redisData = JSONUtil.toBean(json, RedisData.class); R r = JSONUtil.toBean((JSONObject) redisData.getData(), type); LocalDateTime expireTime = redisData.getExpireTime(); // 5.判断是否过期 if (expireTime.isAfter((LocalDateTime.now()))) { // 5.1.未过期,直接返回店铺信息 return r; } // 5.2.已过期,需要缓存重建 // 6.缓存重建 // 6.1.获取互斥锁 String lockKey = LOCK_SHOP_KEY + id; boolean isLock = tryLock(lockKey); // 6.2.判断是否获取锁成功 if (isLock){ // 6.3.成功,开启独立线程,实现缓存重建 CACHE_REBUILD_EXECUTOR.submit(() ->{ try { // 查询数据库 R r1 = dbFallback.apply(id); // 写入redis this.setWithLogicalExpire(key,r1,time,unit); } catch (Exception e) { throw new RuntimeException(e); }finally { // 释放锁 unlock(lockKey); } }); } // 6.4.返回过期的店铺信息 return r; }
-
-
- 借助redis的incr指令并且拼接一些其他信息实现全局唯一ID
- 使用乐观锁的CAS方法的改良法解决库存超卖问题
- 使用悲观锁实现一人一单功能
-
@Component public class RedisIdWorker { /** * 开始时间戳,这里设定的是2022.01.01 00:00:00 */ private static final long BEGIN_TIMESTAMP = 1640995200L; /** * 序列号的位数 */ private static final int COUNT_BITS = 32; private StringRedisTemplate stringRedisTemplate; public RedisIdWorker(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } public long nextId(String keyPrefix){ // 1.生成时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; // 2.生成序列号 // 2.1.获取当前日期,精确到天 String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd")); // 2.2.自增长 long count = stringRedisTemplate.opsForValue().increment("irc:" + keyPrefix + ":" + data); // 3.拼接并返回 return timestamp << COUNT_BITS | count; } } -
@Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { // 5.一人一单(悲观锁) Long userId = voucherOrder.getUserId(); // 5.1.查询订单 int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count(); // 5.2.判断是否存在 if (count > 0) { // 用户已经购买过了 log.error("用户已经购买过一次!"); return; } // 6.扣减库存(乐观锁-CAS方案) boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") // set stock = stock - 1 .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0 .update(); if (!success) { // 扣减失败 log.error("库存不足!"); return; } // 7.创建订单 save(voucherOrder); }
-
-
- 使用redis的SETNX方法实现分布式锁
- 使用线程标识解决分布式锁误删问题
- 采用Lua脚本实现释放锁功能解决多条命令原子性问题
-
private static final String KEY_PREFIX = "lock:"; private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; @Override public boolean tryLock(long timeoutSec) { // 获取线程标识 String threadId = ID_PREFIX + Thread.currentThread().getId(); // 获取锁,使用SETNX方法进行加锁,同时设置过期时间,防止死锁 Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS); //自动拆箱可能会出现null,这样写更稳妥 return Boolean.TRUE.equals(success); } -
private static final String KEY_PREFIX = "lock:"; private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-"; 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() { // 调用lua脚本 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId()); }-- unlock.lua-释放锁Lua脚本 -- 比较线程标识与锁中的标识是否一致 if(redis.call('get',KEYS[1]) == ARGV[1]) then -- 释放锁 del key return redis.call('del',KEYS[1]) end return 0
-
-
- 新增秒杀优惠券的同时,将优惠券的信息保存到Redis中,并采用Lua脚本完成秒杀资格判断
- 基于阻塞队列完成独立线程异步下单功能
- 基于Redis的Stream数据结构实现消息队列进一步完善线程异步下单模块
-
@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); // 保存秒杀库存到Redis中 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(),voucher.getStock().toString()); }-- seckill.lua-秒杀优化Lua脚本 -- 1.参数列表 -- 1.1.优惠券id local voucherId = ARGV[1] -- 1.2.用户id local userId = ARGV[2] -- 1.3.订单id local orderId = ARGV[3] -- 2.数据key -- 2.1.库存key local stockKey = 'seckill:stock:' .. voucherId -- 2.2.订单key local orderKey = 'seckill:order:' .. voucherId -- 3.脚本业务 -- 3.1.判断库存是否充足 if (tonumber(redis.call('get',stockKey)) <= 0 ) then -- 3.2.库存不足,返回1 return 1 end -- 3.3.判断用户是否下单 SISMEMBER orderKey userId if (redis.call('sismember',orderKey,userId) == 1) then -- 3.4.存在,说明是重复下单,返回2 return 2 end -- 3.5.扣库存 incrby stockKey -1 redis.call('incrby',stockKey,-1) -- 3.6.下单(保存用户) sadd orderKey userId redis.call('sadd',orderKey,userId) -- 3.7.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId) return 0 -
// 创建阻塞队列 private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); // 创建线程任务 private class VoucherOrderHandler implements Runnable{ @Override public void run() { while (true){ try { // 1.获取队列中的订单信息 VoucherOrder voucherOrder = orderTasks.take(); // 2.创建订单 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("处理订单异常",e); } } } }private void handleVoucherOrder(VoucherOrder voucherOrder) { // 1.获取用户 Long userId = voucherOrder.getUserId(); // 2.创建锁对象 RLock lock = redissonClient.getLock("lock:order:" + userId); // 3.获取锁 boolean isLock = lock.tryLock(); // 4.判断是否获取锁成功 if (!isLock) { // 获取锁失败,返回错误或重试 log.error("不允许重复下单"); return; } try { proxy.createVoucherOrder(voucherOrder); } finally { // 释放锁 lock.unlock(); } } -
private static final DefaultRedisScript<Long> SECKILL_SCRIPT; static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } // 创建线程池 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); // 类初始化完成后执行这个方法 @PostConstruct private void init(){ SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } // 创建线程任务 private class VoucherOrderHandler implements Runnable{ String queueName = "stream.orders"; @Override public void run() { while (true){ try { // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders > List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), StreamOffset.create(queueName, ReadOffset.lastConsumed()) ); // 2.判断消息获取是否成功 if (list == null || list.isEmpty()){ // 2.1.如果获取失败,说明没有消息,继续下一次循环 continue; } // 3.解析消息中的订单信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true); // 4.如果获取成功,可以下单 handleVoucherOrder(voucherOrder); // 5.ACK确认 SACK stream.order g1 id stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId()); } catch (Exception e) { log.error("处理订单异常",e); handlePendingList(); } } } private void handlePendingList() { while (true){ try { // 1.获取pending-list中的订单信息 XREADG ROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0 List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read( Consumer.from("g1", "c1"), StreamReadOptions.empty().count(1), StreamOffset.create(queueName, ReadOffset.from("0")) ); // 2.判断消息获取是否成功 if (list == null || list.isEmpty()){ // 2.1.如果获取失败,说明pending-list没有异常消息,结束循环 break; } // 3.解析消息中的订单信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> values = record.getValue(); VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true); // 4.如果获取成功,可以下单 handleVoucherOrder(voucherOrder); // 5.ACK确认 SACK stream.order g1 id stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId()); } catch (Exception e) { log.error("处理pending-list订单异常",e); try { Thread.sleep(20); } catch (InterruptedException ex) { ex.printStackTrace(); } } } } }private IVoucherOrderService proxy; @Override public Result seckillVoucher(Long voucherId) { // 获取用户 Long userId = UserHolder.getUser().getId(); // 获取订单id long orderId = redisIdWorker.nextId("order"); // 1.执行lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString(),String.valueOf(orderId) ); // 2.判断结果是为0 int r = result.intValue(); if(r != 0){ // 2.1.不为0,代表没有购买资格 return Result.fail(r == 1 ? "库存不足" : "不能重复下单"); } // 3.获取代理对象(事务想要生效,还得利用代理来生效) proxy = (IVoucherOrderService) AopContext.currentProxy(); // 4.返回订单id return Result.ok(orderId); }
-
-
- 编写文件上传代码实现发布探店笔记功能
- 利用Redis中的set集合来完成笔记点赞功能
- 使用SortedSet(ZSet)完善笔记点赞和点赞排行榜功能
-
@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)); // 返回结果 log.debug("文件上传成功,{}", fileName); return Result.ok(fileName); } catch (IOException e) { throw new RuntimeException("文件上传失败", e); } }private String createNewFileName(String originalFilename) { // 获取后缀 String suffix = StrUtil.subAfter(originalFilename, ".", true); // 生成目录 String name = UUID.randomUUID().toString(); int hash = name.hashCode(); int d1 = hash & 0xF; int d2 = (hash >> 4) & 0xF; // 判断目录是否存在 File dir = new File(SystemConstants.IMAGE_UPLOAD_DIR, StrUtil.format("/blogs/{}/{}", d1, d2)); if (!dir.exists()) { dir.mkdirs(); } // 生成文件名 return StrUtil.format("/blogs/{}/{}/{}.{}", d1, d2, name, suffix); } -
@Override public Result likeBlog(Long id) { // 1.获取登录用户 Long userId = UserHolder.getUser().getId(); // 2.判断当前登录用户是否已经点赞 String key = BLOG_LIKED_KEY + id; Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString()); if (score == null){ // 3.如果未点赞,可以点赞 // 3.1.数据库点赞数 + 1 boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update(); // 3.2.保存用户到Redis的ZSet集合 zadd key value score if (isSuccess){ stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis()); } }else { // 4.如果已点赞,取消点赞 // 4.1.数据库点赞数 - 1 boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update(); // 4.2.把用户从Redis的ZSet集合移除 if (isSuccess){ stringRedisTemplate.opsForZSet().remove(key,userId.toString()); } } return Result.ok(); } -
@Override public Result queryBlogLikes(Long id) { String key = BLOG_LIKED_KEY + id; // 1.查询top5的点赞用户 zrange key 0 4 Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4); if (top5 == null || top5.isEmpty()) { return Result.ok(Collections.emptyList()); } // 2.解析出其中的用户id List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList()); String idStr = StrUtil.join(",", ids); // 3.根据用户id查询用户 WHERE id IN (5,1) ORDER BY FIELD(id,5,1) List<UserDTO> userDTOS = userService.query() .in("id",ids).last("ORDER BY FIELD(id," + idStr + ")").list() .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); // 4.返回 return Result.ok(userDTOS); }
-
-
- 利用Redis中的set集合实现关注和取关以及共同关注功能
- 采用SortedSet(ZSet)结构并结合推模式实现发布博客功能
- 根据SortedSet可以按照score值排序完成滚动分页实现拉取博客的功能
-
@Override public Result follow(Long followUserId, Boolean isFollow) { // 获取登录用户 Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; // 1.判断到底是关注还是取关 if (isFollow){ // 2.关注,新增数据 Follow follow = new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess = save(follow); if (isSuccess){ // 把关注用户的id,放入redis的set集合 sadd userId followUserId stringRedisTemplate.opsForSet().add(key,followUserId.toString()); } }else { // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ? boolean isSuccess = remove(new QueryWrapper<Follow>() .eq("user_id", userId).eq("follow_user_id", followUserId)); if (isSuccess){ // 把关注用户的id从Redis集合中移除 stringRedisTemplate.opsForSet().remove(key,followUserId.toString()); } } return Result.ok(); }@Override public Result followCommons(Long id) { // 1.获取当前用户 Long userId = UserHolder.getUser().getId(); String key = "follows:" + userId; // 2.求交集 String key2 = "follows:" + id; Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2); if (intersect == null || intersect.isEmpty()){ // 无交集 return Result.ok(Collections.emptyList()); } // 3.解析id集合 List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList()); // 4.查询用户 List<UserDTO> users = userService.listByIds(ids) .stream() .map(user -> BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(users); } -
@Override public Result saveBlog(Blog blog) { // 1.获取登录用户 UserDTO user = UserHolder.getUser(); blog.setUserId(user.getId()); // 2.保存探店笔记 boolean isSuccess = save(blog); if (!isSuccess){ return Result.fail("新增笔记失败!"); } // 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ? List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list(); // 4.推送笔记id给所有粉丝 for (Follow follow : follows){ // 4.1.获取粉丝id Long userId = follow.getUserId(); // 4.2.推送 String key = FEED_KEY + userId; stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis()); } // 5.返回id return Result.ok(blog.getId()); } -
@Override public Result queryBlogOfFollow(Long max, Integer offset) { // 1.获取当前用户 Long userId = UserHolder.getUser().getId(); // 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count String key = FEED_KEY + userId; Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, max, offset, 2); // 3.非空判断 if (typedTuples == null || typedTuples.isEmpty()){ return Result.ok(); } // 4.解析数据:blogId、minTime(时间戳)、offset ArrayList<Object> ids = new ArrayList<>(typedTuples.size()); long minTime = 0; int os = 1; for (ZSetOperations.TypedTuple<String> tuple : typedTuples){ // 4.1.获取id ids.add(Long.valueOf(tuple.getValue())); // 4.2.获取分数()时间戳 long time = tuple.getScore().longValue(); if (time == minTime){ os++; }else { minTime = time; os = 1; } } // 5.根据id查询blog String idStr = StrUtil.join(",",ids); List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for(Blog blog : blogs){ // 5.1.查询blog有关的用户 queryBlogUser(blog); // 5.2.查询blog是否被点赞 isBlogLiked(blog); } // 6.封装并返回 ScrollResult r = new ScrollResult(); r.setList(blogs); r.setOffset(os); r.setMinTime(minTime); return Result.ok(r); }
-
-
- 使用Redis中的GEO数据结构实现附近商户功能
-
@Test void loadShopData(){ // 1.查询店铺信息 List<Shop> list = shopService.list(); // 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合 Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId)); // 3.分批完成写入Redis for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) { // 3.1.获取类型id Long typeId = entry.getKey(); String key = SHOP_GEO_KEY + typeId; // 3.2.获取同类型的店铺的集合 List<Shop> value = entry.getValue(); List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size()); // 3.3.写入redis GEOADD key 经度 纬度 member for (Shop shop : value) { // stringRedisTemplate.opsForGeo().add(key,new Point(shop.getX(),shop.getY()),shop.getId().toString()); locations.add(new RedisGeoCommands.GeoLocation<>( shop.getId().toString(), new Point(shop.getX(),shop.getY()) )); } stringRedisTemplate.opsForGeo().add(key,locations); } } -
@Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { // 1.判断是否需要根据坐标查询 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()); } // 2.计算分页参数 int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE; int end = current * SystemConstants.DEFAULT_PAGE_SIZE; // 3.查询redis、按照距离排序、分页。结果:shopId、distance String key = SHOP_GEO_KEY +typeId; GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE .search( key, GeoReference.fromCoordinate(x, y), new Distance(5000), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeCoordinates().limit(end) ); // 4.解析出id 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()); } // 4.1.截取 from ~ end 的部分 List<Long> ids = new ArrayList<>(list.size()); Map<String,Distance> distanceMap = new HashMap<>(list.size()); list.stream().skip(from).forEach( result ->{ // 4.2.获取店铺id String shopIdStr = result.getContent().getName(); ids.add(Long.valueOf(shopIdStr)); // 4.3.获取距离 Distance distance = result.getDistance(); distanceMap.put(shopIdStr,distance); }); // 5.根据id查询Shop String idStr = StrUtil.join(",", ids); List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list(); for (Shop shop : shops) { shop.setDistance(distanceMap.get(shop.getId().toString()).getValue()); } // 6.返回 return Result.ok(shops); }
-
- 使用Redis中的GEO数据结构实现附近商户功能
-
- 使用Redis中的BitMap数据结构实现用户签到和签到统计功能
-
@Override public Result sign() { // 1.获取当前登录用户 Long userId = UserHolder.getUser().getId(); // 2.获取日期 LocalDateTime now = LocalDateTime.now(); // 3.拼接key String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix; // 4.获取今天是本月的第几天 int dayOfMonth = now.getDayOfMonth(); // 5.写入Redis SETBIT key offset 1 stringRedisTemplate.opsForValue().setBit(key,dayOfMonth - 1,true); return Result.ok(); } -
@Override public Result signCount() { // 1.获取当前登录用户 Long userId = UserHolder.getUser().getId(); // 2.获取日期 LocalDateTime now = LocalDateTime.now(); // 3.拼接key String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM")); String key = USER_SIGN_KEY + userId + keySuffix; // 4.获取今天是本月的第几天 int dayOfMonth = now.getDayOfMonth(); // 5.获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0 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); } Long num = result.get(0); if (num == null || num == 0){ return Result.ok(0); } // 6.循环遍历 int count = 0; while(true) { // 让这个数字与1做与运算,得到数字的最后一个bit位 // 判断这个bit位是否为0 if ((num & 1) == 0){ // 如果为0,说明未签到,结束 break; }else{ // 如果不为0,说明已签到,计数器+1 count++; } // 把数字右移一位,抛弃最后一个bit位,继续下一个bit位 num >>>= 1; } return Result.ok(count); }
-
- 使用Redis中的BitMap数据结构实现用户签到和签到统计功能
-
- 使用Redis中的HyperLogLog数据结构实现海量数据统计的功能
-
@Test void testHyperLogLog(){ String[] values = new String[1000]; int j = 0; for (int i = 0; i < 1000000; i++) { j = i % 1000; values[j] = "user_" + i; if (j == 999){ // 发送到Redis stringRedisTemplate.opsForHyperLogLog().add("hl2",values); } } // 统计数量 Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2"); System.out.println("count = " + count); }
-
- 使用Redis中的HyperLogLog数据结构实现海量数据统计的功能
-
-
public class RedisConstants { public static final String LOGIN_CODE_KEY = "login:code:"; public static final Long LOGIN_CODE_TTL = 2L; public static final String LOGIN_USER_KEY = "login:token:"; public static final Long LOGIN_USER_TTL = 30L; public static final Long CACHE_NULL_TTL = 2L; public static final Long CACHE_SHOP_TTL = 30L; public static final String CACHE_SHOP_KEY = "cache:shop:"; public static final String LOCK_SHOP_KEY = "lock:shop:"; public static final Long LOCK_SHOP_TTL = 10L; public static final String SECKILL_STOCK_KEY = "seckill:stock:"; public static final String BLOG_LIKED_KEY = "blog:liked:"; public static final String FEED_KEY = "feed:"; public static final String SHOP_GEO_KEY = "shop:geo:"; public static final String USER_SIGN_KEY = "sign:"; }public abstract class RegexPatterns { /** * 手机号正则 */ public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$"; /** * 邮箱正则 */ public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$"; /** * 密码正则。4~32位的字母、数字、下划线 */ public static final String PASSWORD_REGEX = "^\\w{4,32}$"; /** * 验证码正则, 6位数字或字母 */ public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$"; }public class SystemConstants { public static final String IMAGE_UPLOAD_DIR = "E:\\git Repositories\\Hm-Dianping\\nginx-1.18.0\\html\\hmdp\\imgs\\"; public static final String USER_NICK_NAME_PREFIX = "user_"; public static final int DEFAULT_PAGE_SIZE = 5; public static final int MAX_PAGE_SIZE = 10; } -
public class RegexUtils { /** * 是否是无效手机格式 * @param phone 要校验的手机号 * @return true:符合,false:不符合 */ public static boolean isPhoneInvalid(String phone){ return mismatch(phone, RegexPatterns.PHONE_REGEX); } /** * 是否是无效邮箱格式 * @param email 要校验的邮箱 * @return true:符合,false:不符合 */ public static boolean isEmailInvalid(String email){ return mismatch(email, RegexPatterns.EMAIL_REGEX); } /** * 是否是无效验证码格式 * @param code 要校验的验证码 * @return true:符合,false:不符合 */ public static boolean isCodeInvalid(String code){ return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX); } // 校验是否不符合正则格式 private static boolean mismatch(String str, String regex){ if (StrUtil.isBlank(str)) { return true; } return !str.matches(regex); } } -
public class UserHolder { private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>(); public static void saveUser(UserDTO user){ tl.set(user); } public static UserDTO getUser(){ return tl.get(); } public static void removeUser(){ tl.remove(); } }
-
-
此项目过程详细知识可以去看我的Redis总结:REDIS
-
我觉得这个项目挺适合有了一定Java后端开发经验且正在学习Redis技术的同学们练手的。首先其使用了前后端分离的模式,前端使用了Nginx做代理服务器、后端则使用了SpringBoot框架来开发,这些技术在当下都是非常流行的。其次项目也包含了许多与电商平台相关的业务场景,可供我们整合Redis技术针对性的解决并优化每个场景下可能会出现的问题,从而使我们对真实的开发环境有进一步的了解。因此,此项目绝对是用来巩固并提升自己所学前后端知识的不二之选。
参考视频:新哥点评