第一天:
短信验证登录
发送验证码的api可以使用腾讯或者阿里的,自行付费即可,下面是主要的service层的代码。由于controller层就单纯调用方法,这里进行省去。
验证码和登录token都保存在redis中,并且设置合适的TTL,避免redis存储过多的信息。
public Result sendCode(String phone, HttpSession session) {
//对手机号进行校验
if (RegexUtils.isPhoneInvalid(phone)) {
//不符合
return Result.fail("手机号格式有误!");
}
//符合 生成验证码
String code = RandomUtil.randomNumbers(6);
//保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码
System.out.println("验证码为:" + code);
return Result.ok();
}
用户登录
public Result login(LoginFormDTO loginForm, HttpSession session) {
//根据提交的手机号和验证码进行验证
String phone = loginForm.getPhone();
String code = loginForm.getCode();
//从redis中获取验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式有误!");
}
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码有误!");
}
//根据手机号码查询用户
User user = query().eq("phone", phone).one();
//判断用户是否存在
if (user == null) {
//没查到用户 将数据写入数据库
user = creatUserWithPhone(phone);
}
//将用户保存到redis
String token = UUID.randomUUID(true).toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fildValue) -> fildValue.toString()));
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS);
return Result.ok(token);
}
//创建用户
private User creatUserWithPhone(String phone){
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX +RandomUtil.randomString(10));
save(user);
return user;
}
由于前面的代码限制,在每次登录成功之后,便开始计时,等到达token的过期时间后,无论用户当时是否正在操作,系统都将强行把用户踢出。所有后面添加一个拦截器,用于每次用户操作后,都将TTL进行刷新。
public class RefreshTokenIntercepor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenIntercepor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
//2.基于token获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
//3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
//5.将查询到的hash数据转为userdto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6.存在 保存用户信息
UserHolder.saveUser(userDTO);
//刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.SECONDS);
//7.放行
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//移除用户
UserHolder.removeUser();
}
商品类型的展示
由于这类数据的不易变性和常访问性,所以将这些数据保存在redis中,方便用户每次访问,提供性能的同时,增加了安全性。具体的逻辑思路在代码中已经明确写出。
public Result getTypeList() {
//查询redis中是否有类型数据
String key = "type";
String typeJson = stringRedisTemplate.opsForValue().get(key);
//有 直接返回
if (StrUtil.isNotBlank(typeJson)){
List<ShopType> shopTypes = JSONUtil.toList(typeJson, ShopType.class);
return Result.ok(shopTypes);
}
//没有 查询数据库
List<ShopType> typeList =typeService.query().orderByAsc("sort").list();
//数据库没有数据 返回无数据
if(typeList==null){
return Result.fail("无类型数据!");
}
//数据库有数据 将数据写入redis
stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(typeList));
//返回
return Result.ok(typeList);
}
商品展示
当用户量达到很大的数量时,如果由于redis中没有该数据而导致大量的线程同时去查询数据库,造成数据库性能消耗过大,甚至宕机的危险。所有在这考虑加入一个锁去进行一个控制。能获取到锁的线程才能区查询数据库,而没有获取到锁的线程则返回之前的旧数据。在redis中有个关键字“setnx”,使在该key没有释放时没有任何线程能对该key进行操作,所以下面用该方法进行。
//获取锁
private boolean tryLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
//删除锁
private void unLock(String key){
stringRedisTemplate.delete(key);
}
public Result queryById(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
Shop shop = JSONUtil.toBean(shopJson,Shop.class);
return Result.ok(shop);
}
//4.不存在 查询数据库
Shop shop = getById(id);
if (shop == null) {
return Result.fail("无此数据!");
}
//5.存在 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
return Result.ok(shop);
}
第二天:
实现商铺缓存和数据库数据一致性
缓存更新策略:这里采用主动更新策略,并且操作是先更新数据库,再删除缓存,具体缘由可以查看redis专题这篇文章。
@Transactional
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("店铺id不能为空!");
}
//1.更新数据库
updateById(shop);
//2.删除缓存
String key = CACHE_SHOP_KEY + id;
stringRedisTemplate.delete(key);
return Result.ok();
}
商铺时可能导致的缓存击穿问题
利用互斥锁解决缓存击穿问题
在某个热点key突然失效的情况下,大量的请求由于redis中已经没有,这时会导致大量请求到数据库,所有这里使用互斥锁来解决
public Shop queryWithPassThrough(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断是否存在
if (StrUtil.isNotBlank(shopJson)) {
//3.存在 直接返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
if (shopJson != null) {
return null;
}
//4.实现缓存重建
//4.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + 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);
//5.不存在 查询数据库
if (shop == null) {
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);
}
return shop;
}
利用设置逻辑过期时间实现
public Shop queryWithLogicalExpire(Long id) {
//1.根据id从redis中查询数据
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
//2.判断缓存是否存在
if (StrUtil.isBlank(shopJson)) {
//3.不存在 直接返回空
return null;
}
//4.命中,需要先把json反序列化为对象
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
//5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回店铺信息
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(key);
//6.2.判断是否获取锁成功
if (isLock) {
//6.3.成功,开启独立线程,实现缓存重建
CACHE_REBUILD_EXECUTOR.submit(()->{
try {
//重建缓存
this.saveShop2Redis(id,20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
// 6.4.返回过期的商铺信息
return shop;
}
第三天:
全局唯一id生成器
当用户抢购时,就会生成订单并保存到tb voucher order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
id的规律性太明显
受单表数据量的限制
public long nextId(String keyPerfix) {
//1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
//2.生成序列号
//2.1.获取当前日期 精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPerfix + ":" + date);
//3.拼接并返回
return timestamp<<COUNT_BITS | count ;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long second = time.toEpochSecond(ZoneOffset.UTC);
System.out.println("second = " + second);
}
实现优惠券秒杀下单
下单时需要判断两点
1.秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
2.库存是否充足,不足则无法下单
@Transactional
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
//2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
//3.未开始 返回异常
return Result.fail("活动还未开始!");
}
//4.开始 判断库存是否充足
Integer stock = voucher.getStock();
if (stock < 1) {
//5.不充足 返回错误信息
return Result.fail("手慢了 已经被抢完了!");
}
//6.充足 扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("手慢了 已经被抢完了!");
}
//7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//7.返回
return Result.ok(orderId);
}
下面使用jmeter对其进行多线程测试
发现我们库存原本为100的优惠券现在剩下-9张,这就是我们所谓的“超卖”问题。
这里我们考虑使用乐观锁的方式,所谓乐观锁就是认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据。如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法
- CAS法
所以其实真正需要修改的也就只有第六点
//6.充足 扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).eq("stock",voucher.getStock())
.update();
if (!success) {
return Result.fail("手慢了 已经被抢完了!");
}
这里是超卖问题确实解决了,但300个线程却只卖了40个出去,又出现了问题。乐观锁的成功率太低,后面对他进行一个改良。也就是说不用限制库存必须是上次查到的值,只要库存>0就能继续。
//6.充足 扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).gt("stock",0)
.update();
if (!success) {
return Result.fail("手慢了 已经被抢完了!");
}
现在问题是没有了,但是一看数据库,发现一个人居然可以无限量对秒杀券进行抢购,这在实际生活中肯定是不行的,下面将实现一人一单。
实现起来也很简单,只需要对用户id和优惠券id进行and查询,如果能查出数据,说明该id已经有了对应的优惠券,查不出才允许它创建订单。
//7.一人一单
Long userId = UserHolder.getUser().getId();
//7.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//7.2.判断是否存在
//7.3.存在 返回错误信息
if(count>0){
return Result.fail("一个人只能买一单哦!");
}
然后老规矩使用jmeter设置400线程同时测试,400个请求是同一个用户,下面是测试结果
OK,一看到这个结果就悟了,多线程,多个请求同时查到时,结果都为0,都认为可以进行接下来的操作,然后都去对库存进行删减。
将需要加锁的内容单独拎出来,然后给它加上事务
@Transactional
public Result createVoucherOrder(Long voucherId) {
//6.一人一单
Long userId = UserHolder.getUser().getId();
//7.1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//6.2.判断是否存在
//6.3.存在 返回错误信息
if (count > 0) {
return Result.fail("一个人只能买一单哦!");
}
//7.充足 扣减库存
boolean success = iSeckillVoucherService.update()
.setSql("stock = stock -1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!success) {
return Result.fail("手慢了 已经被抢完了!");
}
//8.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//9.返回
return Result.ok(orderId);
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
//获取代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
在单线程的情况下,的确发现上的锁起到了效果。下面使用Idea的自带功能,使该程序变成多线程情况下。
发现两条线程都获取到了数据,并且对库存进行了删减。为什么会导致这样的情况发生呢?
因为在使用集群部署时候,会产生一个全新的JVM对象,它会有自己的堆栈,也有自己的锁监视器。也就是说,锁只在同一个JVM的情况下才能生效。
下面就会涉及到分布式锁的知识了,感兴趣的朋友可以看看我的另外一篇文章Redis专题 里面有专门讲解到Redis实现分布式锁的知识。
public class SimpleRedisLock implements ILock {
private static final String KEY_PREFIX = "lock:";
private static final String ID_FREFIX = UUID.randomUUID().toString(true);
private StringRedisTemplate stringRedisTemplate;
private String name;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
//获取线程标识
String threadId = ID_FREFIX + Thread.currentThread().getId();
//获取锁
String key = KEY_PREFIX + name;
Boolean seccess = stringRedisTemplate.opsForValue()
.setIfAbsent(key, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(seccess);
}
@Override
public void unlock() {
String key = KEY_PREFIX + name;
//获取线程标识
String threadId = ID_FREFIX + Thread.currentThread().getId();
//获取锁中的标识
String id = stringRedisTemplate.opsForValue().get(key);
//判断标识是否一致
if (threadId.equals(id)){
//释放锁
stringRedisTemplate.delete(key);
}
}
}
Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//获取锁
boolean isLock = lock.tryLock(5);
//判断是否获取锁成功
if (!isLock){
return Result.fail("不允许重复下单");
}
//获取代理对象
try {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}
上面使用了UUID对锁进行了限制,防止因为线程阻塞,一个线程将另外一个线程的锁开了的情况。
Lua脚本
确保释放锁的原子性操作,考虑加入Lua脚本,来使数据保持一致。
释放锁的业务流程是这样的:
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
然后用Lua脚本来实现上面的业务逻辑
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁 del key
return redis.call('del', KEYS[1])
end
return 0
@Override
public void unlock() {
//调用Lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX+name),
ID_FREFIX+Thread.currentThread().getId());
}
基于setnx实现的分布式锁存在的问题
- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
- 主从一致性:如果Redis提供了主从集群主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
后面决定使用Redisson中的方法来解决问题啦!
第四天
Redis优化秒杀:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
- 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
@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
String key=SECKILL_STOCK_KEY+voucher.getId();
stringRedisTemplate.opsForValue().set(key,voucher.getStock().toString());
}
然后经过验证,确实加入成功
下面是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.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
--redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT=new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
@Override
public Result seckillVoucher(Long voucherId) {
//获取用户
Long userId = UserHolder.getUser().getId();
//执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString()
);
//2.判断结果是否为0
int r = result.intValue();
if (result!=0){
//2.1.否 返回异常信息
return Result.fail(r==1?"库存不足":"一个人只能抢一单");
}
//2.2.将优惠券id、用户id和订单id存入阻塞队列
long orderId = redisIdWorker.nextId("order");
// TODO 保存阻塞队列
//3.返回订单id
return Result.ok(orderId);
}
发布探店笔记
老规矩,在Contorller层主要引用方法,具体业务放在Service层来写
@GetMapping("/{id}")
public Result queryBlogById(@PathVariable("id") Long id){
return blogService.queryBlogById(id);
}
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
@Resource
private IUserService userService;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryBlogById(Long id) {
//1.查询blog
Blog blog = getById(id);
if (blog == null) {
return Result.fail("笔记不存在");
}
//2.查询blog相关的用户
queryBlogUser(blog);
//3.查询blog是否被点过赞
isBlogLiked(blog);
return Result.ok(blog);
}
private void isBlogLiked(Blog blog) {
//1.获取登录用户
UserDTO user = UserHolder.getUser();
if (user==null){
//用户未登录 无需查询是否点赞
return;
}
Long userId = UserHolder.getUser().getId();
//2.判断当前用户是否已经点过赞
String key = BLOG_LIKED_KEY + blog.getId();
Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
blog.setIsLike(score != null);
}
@Override
public Result queryHotBlog(Integer current) {
// 根据用户查询
Page<Blog> page = query()
.orderByDesc("liked")
.page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
// 查询用户
records.forEach(blog -> {
this.queryBlogUser(blog);
this.isBlogLiked(blog);
});
return Result.ok(records);
}
@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());
//3.如果未点赞 可以点赞
if (score == null) {
//3.1.数据库数量+1
boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
//3.2.保存用户到Redis中的Set
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();
if (isSuccess) {
//4.2.把用户从redis中移除
stringRedisTemplate.opsForZSet().remove(key, userId.toString());
}
}
return Result.ok();
}
@Override
public Result queryBlogLikes(Long id) {
//1.查询top5的点赞用户
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() );
}
//2.解析出其中的用户id
List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
String idStr = StrUtil.join(",", ids);
//3.根据用户id查询用户
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);
}
private void queryBlogUser(Blog blog) {
Long userId = blog.getUserId();
User user = userService.getById(userId);
blog.setName(user.getNickName());
blog.setIcon(user.getIcon());
}
}
关注和取关
这个功能最终也就是两个需求
- 关注和取关接口
- 判断是否关注的接口
@Override
public Result follow(Long followUserId, Boolean isFollow) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
//1.判断到底是关注还是取关
if (isFollow) {
//2.关注 新增数据
Follow follow = new Follow();
follow.setFollowUserId(followUserId);
follow.setUserId(userId);
save(follow);
} else {
//3.取关 删除数据
remove(new QueryWrapper<Follow>()
.eq("user_id",userId).eq("follow_user_id",followUserId));
}
return Result.ok();
}
@Override
public Result isFollow(Long followUserId) {
//1.获取登录用户
Long userId = UserHolder.getUser().getId();
//1.查询是否关注
Integer count = query()
.eq("user_id",userId).eq("follow_user_id",followUserId).count();
//3.判断
return Result.ok(count>0);
}
共同关注
@GetMapping("/of/user")
public Result queryBlogByUserId(
@RequestParam(value = "current", defaultValue = "1") Integer current,
@RequestParam("id") Long id) {
// 根据用户查询
Page<Blog> page = blogService.query()
.eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
// 获取当前页数据
List<Blog> records = page.getRecords();
return Result.ok(records);
}
@GetMapping("/{id}")
public Result queryUserById(@PathVariable("id") Long userId){
// 查询详情
User user = userService.getById(userId);
if (user == null) {
return Result.ok();
}
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 返回
return Result.ok(userDTO);
}
关注推送
关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。
Feed流产品有两种常见模式:
- Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
优点:信息全面,不会有缺失。并且实现也相对简单.
缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
- 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
缺点:如果算法不精准,可能起到反作用
feed流的实现方案
- 推模式
- 拉模式
- 推拉结合模式
针对不同的情况采用不同的实现方案。
所以这里的需求就是:
- 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱收件箱
- 满足可以根据时间戳排序,必须用Redis的数据结构实现
- 查询收件箱数据时,可以实现分页查询
@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.查询笔记作者的所有粉丝
List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
//4.推送笔记id给粉丝
for (Follow follow : follows) {
Long userId = follow.getUserId();
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.查询收件箱
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.解析数据
List<Long> ids = new ArrayList<>(typedTuples.size());
long minTime = 0;
int os=1;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
//4.1.获取id
ids.add(Long.valueOf(typedTuple.getValue()));
//4.2.获取分数(时间戳)
long time = typedTuple.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数据结构
- GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2.新功能
附近商户搜索
按照商户类型做分组,类型相同的商户作为同一组,以typeld为key存入同一个GEO集合中即可
先通过测试方法,将数据库中的商铺信息存入redis中
@Test
void loadShopData() {
//1.查询店铺信息
List<Shop> list = shopService.list();
//2.把店铺分组 按照typeId分组 id一致的放到一个集合
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
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);
}
}
下面是具体的业务逻辑实现
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().search(key,
GeoReference.fromCoordinate(x, y),
new Distance(5000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().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);
}
用户签到
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。
public Result sige() {
//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 SELECT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok();
}
然后就是他的附加功能,也就是统计连续签到数
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.获取本月截止到今天的所有的签到记录
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) {
//6.1.让这个数与 1 做与运算 得到数字的最后一个bit位
if ((num & 1) == 0) {
break;
} else {
count++;
}
num >>>= 1;
}
return Result.ok(count);
}