黑马点评记录
本文最后更新于56 天前,其中的信息可能已经过时,如有错误请发送邮件到1225615596@qq.com

一、短信校验登录

思路:

校验登录状态,需要限制一些未登录时的一些操作,例如:用户下载,评论。

这个地方还有一个优化,判断用户是否存在后,多个页面都需要进行用户校验,所有定义一个拦截器去拦截,后续不需要再在多个controller中添加这个代码。

拦截器的实现:(是一个工具utils)

思路:添加LoginInterceptor工具类->配置它(在config包中创建MvcConfig)->使用拦截器

1 添加LoginInterceptor工具类

public class LoginInterceptor implements HandlerInterceptor {
//    preHandle → Controller → postHandle → 视图渲染 → afterCompletion
    @Override   //在controller之前执行 用处:登录/权限校验
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1 获取session
        HttpSession session = request.getSession();
        //2 获取session中的用户
        Object user = session.getAttribute("user");
        //3 判断用户是否存在
        if (user == null){
            //4 不存在,未登录,需要跳转到登录页面
            return false;
        }
        //将用户保存到ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        return true;
    }

//    @Override   //在controller之后执行 用途:资源回收、ThreadLocal 清理(特别重要)。
//    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//        HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
//    }

    @Override   //视图渲染之后,返回给用户之前执行
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }

2 创建MvcConfig

public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).
                excludePathPatterns(//这些不需要进行用户校验
                         "/user/login",
                         "/user/code",
                         "/user/logout",
                         "shop/**",
                         "shop-type/**",
                         "blog/**",
                         "voucher/**"
                );
    }
}

3 使用拦截器

    @GetMapping("/me")
    public Result me(){
        // TODO 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

其中优化点:隐藏cookie中的一些user敏感信息

方法:添加一个UserDTO类,只保留id、、nickName、icon信息,然后放入session中

 UserDTO user = query().eq("phone",phone).one();

使用session实现的弊端:

为什么要使用redis来实现?

当请求变多时,会增加tomcat服务器,使用负载均衡(将一台服务器的压力分散到多台服务器中)缓解压力。最初解决方案:复制Session给多个服务器,但是会带来一些问题:每个服务器中存在相同内容,出现内存问题;服务器之间相互复制session时有延迟,也会出现数据不一致的问题。

所以使用redis。

如何使用redis来实现?

修改原来的业务流程,

1、在发送验证码过程中,改用redis保存验证码。考虑数据结构:redis是k-v形式,

v:验证码的值使用String类型即可

k:使用手机号(原因:session实现时,每个用户都有唯一的sessionID,现在用户数据放入redis中,数据是共享的,需要有唯一的ID即手机号;校验手机号方便)

使用redis,添加:
@Resource
    private StringRedisTemplate stringRedisTemplate;
将4修改成:使用redis保存验证码
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone,code,LOGIN_CODE_TTL , TimeUnit.MINUTES);

注:在实际业务中,k值一般加前缀加以区分,和将这些常量单独写入一个类使用,显得“优雅”!

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;

2、在验证码登录注册过程中,使用redis保存用户,再将K返回给前端。考虑数据结构:redis保存对象方式:

1)String 用JSON字符串来保存

2)Hash 将对象中的字段独立存储,可以单独修改对象字段,占用内存更小

v:使用Hash类型保存

k:生成随机字符串token(不使用手机号原因:k会跟之前的sessionID一样,保存到浏览器中,数据不安全)

//2 从redis中获取验证码验证
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
//5 保存用户到redis中
        //5.1 生成随机token
        String token = UUID.randomUUID().toString(true);
        //5.2 将用户对象转换成Hash
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);//与之前一样需要将浏览器中私密信息隐藏(优化)
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);//putAll中需要使用Map,一次性放入对象的多个字段和值

        //5.3 保存
        stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY,userMap);

        //5.4 设置有效期,避免用户频繁登录,生成新的token,导致内存不必要的占用(优化)
        stringRedisTemplate.expire(LOGIN_USER_KEY + token,LOGIN_USER_TTL, TimeUnit.MINUTES);

        //6 返回token给前端
        return Result.ok(token);

3)在登录校验时,使用K去获取用户,以及刷新token

使用俩个拦截器,流程如下:第一个只负责保存登录用户和刷新token,第二个负责检查用户是否存在

//注册器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录拦截器
        registry.addInterceptor(new LoginInterceptor()).
                excludePathPatterns(//这些不需要进行用户校验
                         "/user/login",
                         "/user/code",
                         "/user/logout",
                         "/shop/**",
                         "/shop-type/**",
                         "/blog/hot",
                         "/voucher/**",
                         "/upload/**"
                ).order(1);
        //刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    }
}

//RefreshTokenInterceptor
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录拦截器
        registry.addInterceptor(new LoginInterceptor()).
                excludePathPatterns(//这些不需要进行用户校验
                         "/user/login",
                         "/user/code",
                         "/user/logout",
                         "/shop/**",
                         "/shop-type/**",
                         "/blog/hot",
                         "/voucher/**",
                         "/upload/**"
                ).order(1);
        //刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    }
}

//LoginInterceptor
 @Override   //在controller之前执行 用处:登录/权限校验
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (UserHolder.getUser()==null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }

启发:当我写一个工具类不想交给Spring,我又想间接注入spring中的bean时,就可以创建一个他的配置类,然后将配置类交给spring管理,最后通过构造器进行简介注入,即应用IOC思想。

二、将原有的商品CURD使用redis缓存

给商户添加缓存:

@Override
public Result queryShopById(Long id) {
    //1 从redis查询商铺信息
    String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
    //2 如果存在,直接返回
        /**区别:if (shopJson!=null) if (!shopJson.isEmpty())
         * shopJson != null
         * 只判断是否为 null。
         * "": true(非 null)
         * " ": true(非 null)
         * 可能误把空串/空白串当作命中。
         * !shopJson.isEmpty()
         * 判断长度是否>0,但前提是变量不是 null。若为 null 会抛 NullPointerException。
         * null: 抛 NPE
         * "": false
         * " ": true(长度>0),会把全空白当命中。
         * StrUtil.isNotBlank(shopJson)(Hutool)
         * 同时判断非 null、长度>0、去掉首尾空白后长度>0。
         * null: false
         * "": false
         * " ": false
         * "abc": true
         * 最安全、最符合“命中有效值”的语义。
         * */
    if (StrUtil.isNotBlank(shopJson)) {
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //3 如果不存在,从数据库查询
    Shop shop = getById(id);
    //4 如果数据库不存在,返回错误信息
    if (shop == null) {
        return Result.fail("商铺不存在");
    }
    //5 如果数据库存在,写入redis
    stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop));
    //6 返回商铺信息
    return Result.ok(shop);
}

缓存前:366ms —> 缓存后:43ms

三、缓存策略

主动更新策略:

  1. 自定义更新策略(推荐,满足大部分要求,缺点:维护成本大)
  2. 调用服务(没什么现成的服务,维护困难)
  3. 线程异步写回(优点:只需要写回最后一次修改内容,缺点:没写回前,数据不一致,如果redis宕机,缓存数据全部丢失)

问题三原因:

总结:

实战:使用超时剔除和主动更新策略(先改数据库,再删缓存)实现双向写一致

 //5 如果数据库存在,写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
 
 @PutMapping
    public Result updateShop(@RequestBody Shop shop) {

        return shopService.update(shop);
    }

 @Override
    @Transactional//事务保证1 2 原子性
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("商铺id不能为空");
        }
        //1 修改数据库
        updateById(shop);
        //2 删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId());
        return Result.ok();
    }

四、缓存穿透

什么是缓存穿透?

指发送无效的请求,到redis,且缓存未命中,直接到数据库,返回空。

如何解决缓存穿透?

主流有以下几种:

1)缓存空对象(被动)

优点:实现简单暴力,维护方便

缺点:额外内存开销(缓存大量null(1,null)、(2,null)…通过设置TTL缓解);

短时间内数据不一致(缓存后(1,null),插入(1,“张三”),通过插入后更新缓存解决)

举例:请求(1)-> redis 未命中 -> 数据库未命中 -> 缓存null(1,“”)

再次请求 -> 缓存命中

2)布隆过滤器(被动)

优点:内存占用少

缺点:实现复杂、存在偏差(会拦截所有没有的数据,但是对于存在的数据判断不准确

【例子:请求->布隆过滤器判断数据存在->redis未命中->数据库未命中】)

3)增加id复杂性,做好id格式校验即可(主动)

4)做好热点参数等限流(主动)

实战:在查询商铺中防止缓存穿透

public Result queryShopById(Long id) {
        //1 从redis查询商铺信息
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2 如果存在,直接返回
        //非空非null
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }
        //非null,即空,返回错误信息
        /**
            isEmpty() 只检查字符串长度是否为 0,但前提是对象不能为 null
            当 shopJson 为 null 时,调用 isEmpty() 方法会抛出 NullPointerException
         */
         if (shopJson != null){//或shopJson != null && !shopJson.isEmpty()
            return Result.fail("店铺不存在");
        }
        //3 如果不存在,从数据库查询
        Shop shop = getById(id);
        //4 如果数据库不存在,返回错误信息
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return Result.fail("商铺不存在");
        }
        //5 如果数据库存在,写入redis
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);
        //6 返回商铺信息
        return Result.ok(shop);
    }

五、缓存雪崩(集体休眠)

什么是缓存雪崩?

同一时间段内,大量key失效或Redis宕机,导致大量请求直接到达数据库,给数据库带来巨大压力。

如何解决缓存雪崩?

1)给key设置不同的TTL值(做预热时,批量导入数据到缓存,key会同时失效,给key的TTL添加不同的随机值即可)

2)利用redis集群提高服务的可用性(Spring Cloud)

3)给缓存业务添加降级限流策略(快速失败,redis宕机后,如果是无效请求直接返回错误 Spring Cloud)

4)给业务添加多级缓存(Spring Cloud)

六、缓存击穿(热点key问题)(单点爆破)

什么是缓存击穿?

被高并发请求和缓存重建复杂的key突然失效了,无数请求直接到达数据库,造成巨大压力

如何解决缓存击穿?(本质:如何处理重建缓存前,这段时间内的并发问题)

1)互斥锁【性能换可用性】(通过锁,让一个线程重建缓存,其他线程等待)

实战:

//在redis中有setnx实现互斥锁
help setnx

SETNX key value
summary: Set the value of a key, only if the key does not exist
since: 1.0.0
group: string

127.0.0.1:6379> setnx lock 1
(integer) 1
127.0.0.1:6379> get lock

127.0.0.1:6379> setnx lock 2
(integer) 0
127.0.0.1:6379> setnx lock 3
(integer) 0
127.0.0.1:6379> get lock
"1"

代码:

public Result queryShopById(Long id) {
        //缓存穿透
//        Shop shop = queryWithPassThrough(id);
        //缓存击穿(互斥锁)
        Shop shop = queryMutex(id);
        if (shop == null) {
            return Result.fail("商铺不存在");
        }
        //6 返回商铺信息
        return Result.ok(shop);
    }

    //缓存击穿(互斥锁)
    public Shop queryMutex(Long id){
        //1 从redis查询商铺信息
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2 如果存在,直接返回
        //非空非null
        if (StrUtil.isNotBlank(shopJson)) {
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
        //非null,即空,返回错误信息
        if (shopJson != null){
            return null;
        }
        //3 如果不存在,重建缓存
        // 3.1 尝试获取互斥锁
        Shop shop = null;
        try {
            boolean isLock = tryLock(LOCK_SHOP_KEY + id);
            // 3.2 判断是否获取成功
            if (!isLock) {
                // 3.3 如果获取失败,休眠一段时间
                    Thread.sleep(100);
                    return queryMutex(id);//递归
            }
            // 3.4 如果获取成功,从数据库查询
            shop = getById(id);
            //模拟延迟
//            Thread.sleep(200);
            //4 如果数据库不存在,返回错误信息
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }
            // 3.5 如果获取成功,写入redis
            stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop),CACHE_SHOP_TTL, TimeUnit.MINUTES);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            // 3.6 释放锁
            unLock(LOCK_SHOP_KEY + id);
        }
        //6 返回商铺信息
        return shop;
    }
  //尝试获取锁
    public boolean tryLock(String lockKey){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        //只有当 flag == Boolean.TRUE 时返回 true;flag == null、flag == Boolean.FALSE 都返回 false。
        return BooleanUtil.isTrue(flag);
    }

    //释放锁
    public void unLock(String lockKey){
        stringRedisTemplate.delete(lockKey);
    }

注:获取/释放锁都要放在一个try-catch-finally中

2)逻辑过期【高并发换数据一致性】(对活动商品进行预热,提前导入缓存,永久存在,未命中的说明不是活动商品;过程:第一个线程加锁,然后让另一个线程异步重建缓存,重建好后释放锁,在重建期间,都返回旧数据)

//预热数据
    public void saveShop2RedisWithLogicalExpire(Long id, Long expireSeconds) {
        //1 查询数据库
        Shop shop = getById(id);
        //封装逻辑过期时间
        RedisData redisData = new RedisData();
        redisData.setData(shop);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
        stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
    }
//导入预热数据
@Resource
    private ShopServiceImpl shopServiceimpl;

    @Test
    void testSaveShop(){
        shopServiceimpl.saveShop2RedisWithLogicalExpire(1L, 30L);
    }

//创建线程池
    private static final ExecutorService CACHE_BUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    //缓存击穿(逻辑过期)
    public Shop queryWithLogicalExpire(Long id){
        //1 从redis查询商铺信息
        String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
        //2 如果不存在,直接返回
        if (StrUtil.isBlank(shopJson)) {
            return null;
        }
        //3 命中,将JSON字符串反序列化成对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        //将拿到的data对象强转成JSON字符串,再toBean
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4 判断缓存是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //未过期
            return shop;
        }
        //5.1 过期,尝试获取互斥锁
        boolean isLock = tryLock(LOCK_SHOP_KEY + id);
        //5.2 判断锁是否拿到
        if (isLock) {
            //5.2.2 拿到锁,开启独立线程,重建缓存
            CACHE_BUILD_EXECUTOR.submit(() -> {
                try {
                    saveShop2RedisWithLogicalExpire(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //5.2.3 释放锁
                    unLock(LOCK_SHOP_KEY + id);
                }
            });
        }
        //5.2.1 未拿到锁,返回过期数据
        return shop;
    }

对比:

启发:封装:当我需要在已经有的entity中添加字段,可以创建一个新类包含entity类+新字段,再将值set到新类中即可

七、封装redis工具类

实战:

@Component
public class CacheClient {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    //存入缓存
    public void set(String key, Object value, Long time, TimeUnit unit){
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
    }
    public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
        //写入逻辑时间
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        //写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
    }

    //缓存穿透
    public <R,ID> R queryWithPassThrough(String keyPreFix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPreFix + id;
        //1 从redis查询商铺信息
        String json = stringRedisTemplate.opsForValue().get(key);
        //2 如果存在,直接返回
        if (StrUtil.isNotBlank(json)) {
            R r = JSONUtil.toBean(json, type);
            return r;
        }
        //非null,即空,返回错误信息
        if (json != null){
            return null;
        }
        //3 如果不存在,从数据库查询
        R r = dbFallback.apply(id);
        //4 如果数据库不存在,返回错误信息
        if (r == null) {
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL, TimeUnit.MINUTES);
            return null;
        }
        //5 如果数据库存在,写入redis
        this.set(key, r, time, unit);
        //6 返回商铺信息
        return r;
    }

    //创建线程池
    private static final ExecutorService CACHE_BUILD_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)) {
            return null;
        }
        //3 命中,将JSON字符串反序列化成对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        //将拿到的data对象强转成JSON字符串,再toBean
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        //4 判断缓存是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            //未过期
            return r;
        }
        //5.1 过期,尝试获取互斥锁
        boolean isLock = tryLock(LOCK_SHOP_KEY + id);
        //5.2 判断锁是否拿到
        if (isLock) {
            //5.2.2 拿到锁,开启独立线程,重建缓存
            CACHE_BUILD_EXECUTOR.submit(() -> {
                try {
                    //查询数据库
                    R r1 = dbFallback.apply(id);
                    //写入redis
                    this.setWithLogicalExpire(key, r1, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //5.2.3 释放锁
                    unLock(LOCK_SHOP_KEY + id);
                }
            });
        }
        //5.2.1 未拿到锁,返回过期数据
        return r;
    }

    //尝试获取锁
    public boolean tryLock(String lockKey){
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_SHOP_TTL, TimeUnit.SECONDS);
        //只有当 flag == Boolean.TRUE 时返回 true;flag == null、flag == Boolean.FALSE 都返回 false。
        return BooleanUtil.isTrue(flag);
    }

    //释放锁
    public void unLock(String lockKey){
        stringRedisTemplate.delete(lockKey);
    }
}

优惠券秒杀

一、生成全局唯一ID

原因:1.数据库的自增ID容易让用户猜到一些信息(订单编号10,再下一单编号100,中间卖出90个)

2.受单表限制,后期数据过多,使用多表时,ID不唯一

DROP TABLE IF EXISTS `tb_voucher_order`;
CREATE TABLE `tb_voucher_order`  (
  `id` bigint(20) NOT NULL COMMENT '主键',  不是自增
  `user_id` bigint(20) UNSIGNED NOT NULL COMMENT '下单的用户id',
  `voucher_id` bigint(20) UNSIGNED NOT NULL COMMENT '购买的代金券id',
  `pay_type` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '支付方式 1:余额支付;2:支付宝;3:微信',
  `status` tinyint(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
  `pay_time` timestamp NULL DEFAULT NULL COMMENT '支付时间',
  `use_time` timestamp NULL DEFAULT NULL COMMENT '核销时间',
  `refund_time` timestamp NULL DEFAULT NULL COMMENT '退款时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Compact;

策略:(时间戳+计数器)使用redis,redis数据库也是自增的(increment),所有需要给他拼接一些信息即可

返回的结果时数字Long类型,插入数据库时速度快

在生成序列号时,将每天的日期拼接到一起,后续可以根据年月日分别进行统计订单数量

@Component
public class RedisIdWorker {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private static final Long BEGIN_TIMESTAMP = 1577836800L;
    //设计ID的位数
    private static final Long COUNT_BITS = 32L;

    public Long nextId(String strPrefix) {
        //生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;
        
        //生成序列号
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM-dd"));
        //如果是increment("inc:" + strPrefix),redis自增最大是64位,但是这里设计的序列号只要32位,加上每天的时间,每天的Key都不一样
        //redis通过increment()key去自增ID
        Long count = stringRedisTemplate.opsForValue().increment("inc:" + strPrefix + ":" + date);

        //拼接返回
        return timeStamp << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        LocalDateTime localDateTime = LocalDateTime.of(2020, 1, 1, 0, 0);
        long seconds = localDateTime.toEpochSecond(ZoneOffset.UTC);
        System.out.println(seconds);
    }
}
二、添加秒杀券
表设计:
普通券—>秒杀券 ,秒杀券是子类,额外添加一些字段,在后端实现中使用封装思想实现
优惠券
CREATE TABLE `tb_voucher` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `shop_id` bigint unsigned DEFAULT NULL COMMENT '商铺id',
  `title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '代金券标题',
  `sub_title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '副标题',
  `rules` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '使用规则',
  `pay_value` bigint unsigned NOT NULL COMMENT '支付金额,单位是分。例如200代表2元',
  `actual_value` bigint NOT NULL COMMENT '抵扣金额,单位是分。例如200代表2元',
  `type` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '0,普通券;1,秒杀券',
  `status` tinyint unsigned NOT NULL DEFAULT '1' COMMENT '1,上架; 2,下架; 3,过期',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

秒杀券
CREATE TABLE `tb_seckill_voucher` (
  `voucher_id` bigint unsigned NOT NULL COMMENT '关联的优惠券的id',
  `stock` int NOT NULL COMMENT '库存',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `begin_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
  `end_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '失效时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`voucher_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT COMMENT='秒杀优惠券表,与优惠券是一对一关系';

代码:

    @PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }
    @Override
    @Transactional   //普通券和秒杀信息要一致完成
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
    }

三、实现秒杀下单

1.实现下单秒杀券(超卖问题)

2.解决超卖问题

使用乐观锁(判断之前查询到的数据是否被修改)俩种方式:

版本号法:判断版本号是否发生变化

CAS法:判断库存是否发生变化

CAS法代码:

@Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;


    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.根据ID查询秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())){
            return Result.fail("秒杀未开始");
        }
        //3.判断是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())){
            return Result.fail("秒杀已结束");
        }
        //4.判断库存
        if (voucher.getStock() <= 0){
            return Result.fail("库存不足");
        }
        //5.扣减库存
//        seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
        seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
        //6.创建订单
        //6.1获取订单ID
        Long orderId = redisIdWorker.nextId("order");
        //6.2获取用户ID
        Long userId = UserHolder.getUser().getId();
        //6.3获取秒杀券ID
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7.返回订单
        return Result.ok(orderId);
    }

解释:添加 gt(“stock”, 0) 就不会超卖(举例说明)

结果:最多扣减一次,不会出现变成 -1 的超卖。

场景一:初始库存 stock = 1,同时来了两个并发请求 T1 和 T2。

两个线程可能几乎同时到达 UPDATE,但实际在数据库端会串行获得行锁(数据库执行 UPDATE 时使用的是“当前读”(current read),而不是你应用层之前读到的快照值)。

T1 执行:检查条件 stock > 0 为真(1 > 0),于是将 stock = stock - 1,结果变为 0,影响行数为 1,成功。

T2 执行:此时数据库里的 stock 已是 0,再检查 stock > 0 为假,UPDATE 不生效,影响行数为 0,失败。

四、一人限定一单

难点:释放锁时保证事务已提交;如何只锁住同一用户

代码:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;


    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.根据ID查询秒杀券
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        //2.判断是否开始
        if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀未开始");
        }
        //3.判断是否结束
        if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已结束");
        }
        //4.判断库存
        if (voucher.getStock() <= 0) {
            return Result.fail("库存不足");
        }
        Long userId = UserHolder.getUser().getId();
        //intern()去常量池中找与该字符串相同的值的地址返回
        synchronized (userId.toString().intern()) {
            //获取代理对象(事务)
            /*
            * “this = 目标对象(业务实现)VoucherOrderServiceImpl”,用于类内部自调用,不走 AOP,事务不生效。
              “proxy = Spring 代理对象”,通过它调用才会触发事务等切面。
            * */
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        //5一人一单
        //5.1获取用户ID
        Long userId = UserHolder.getUser().getId();
        //5.2查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //5.3判断订单是否存在
        if (count > 0) {
            return Result.fail("用户已购买过");
        }
        //6.扣减库存
//        seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
        if (!success) {
            return Result.fail("库存不足");
        }
        //7.创建订单
        //7.1获取订单ID
        Long orderId = redisIdWorker.nextId("order");

        //7.3获取秒杀券ID
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        return Result.ok(orderId);
    }
}

//在pom.xml中添加
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>

//在HmDianPingApplication中添加
@EnableAspectJAutoProxy(exposeProxy = true)  //暴露Proxy

解释:乐观锁不是多余的!!乐观锁是锁库存;悲观锁只锁了用户,不同用户直接可以并发

举例说明:

场景一:单实例,同一用户并发两次,库存=1

  • 请求:U1 的 T1、T2 同时下单,落在同一个应用实例。
  • 应用层表现:因为 synchronized(userId.intern()),同一用户在本实例内会串行执行。T1 先进入 createVoucherOrder,T2 阻塞等待。
  • 数据库表现:
    • T1 扣减库存:UPDATE … SET stock=stock-1 WHERE voucher_id=? AND stock>0 → 成功,stock 从 1→0
    • T1 提交后释放锁,T2 获准进入方法,执行 UPDATE … WHERE stock>0 → 失败(受影响行数=0)
  • 结论:
    • 应用层锁解决的是“同一用户在本实例的并发重入”。
    • CAS 仍在起作用(即便 T2被串行化,最终是否成功仍由数据库当前库存决定),两者互不干扰。

场景二:单实例,不同用户并发,库存=1

  • 请求:U1 的 T1 与 U2 的 T2 同时下单,落在同一个应用实例。
  • 应用层表现:不同 userId 的 intern 字符串不同,不会互斥,两条线程都会并发进入 createVoucherOrder。
  • 数据库表现:
    • 两条 UPDATE 几乎同时到达,数据库串行获取行锁:
      • 先拿到锁的请求成功扣减 stock 1→0
      • 后一个请求判断 stock>0 不成立而失败(受影响行数=0)
  • 结论:
    • 对“不同用户”应用层没有加锁(也不该加),并发正确性完全靠 CAS 保证。
    • 这说明即使没有应用层锁,CAS 也能独立保障不超卖;两者职责边界清晰,互不影响。

场景三:多实例( N 台服务器部署同一个应用),同一用户并发两次,库存=1

若叠加订单表唯一约束 (user_id, voucher_id),还可同时兜底“一人一单”。

部署:两个应用实例 A、B,均接入同一数据库。

请求:U1 的 T1 到 A 实例,T2 到 B 实例。

应用层表现:synchronized(userId.intern()) 只在进程内有效,跨实例无效,T1/T2 会并发进入各自实例的 createVoucherOrder。

数据库表现:

两条 UPDATE 同样并发竞争行锁:

先执行者扣减成功,stock 1→0

另一条 UPDATE 因 stock>0 不成立失败。

结论:

多实例时应用层锁“不生效”,但 CAS 依然独立起作用,防止超卖。

//intern()去常量池中找与该字符串相同的值的地址返回
        synchronized (userId.toString().intern()) {
            //获取代理对象(事务)
            /*
            * “this = 目标对象(业务实现)VoucherOrderServiceImpl”,用于类内部自调用,不走 AOP,事务不生效。
              “proxy = Spring 代理对象”,通过它调用才会触发事务等切面。
            * */
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

实现分布式锁

什么是分布式锁?

在分布式系统或集群模式下满足多线程可见且互斥的锁。

如何实现分布式锁?

主要有三种:

Redis实现思路:

  • 获取锁:
    – 互斥:确保只能有一个线程获取锁
    – 非阻塞:尝试一次,成功返回true,失败返回false
# 添加锁,NX是互斥、EX是设置超时时间
SET lock thread1 NX EX 10
  • 释放锁:
    – 手动释放
    – 超时释放:获取锁时添加一个超时时间
# 释放锁, 删除即可
DEL key

亮点:

1.在使用短信登录时使用redis做缓存,保障负载均衡时,多台服务器之间数据一致,

以及降低延迟缓存前:366ms —> 缓存后:43ms

2.在登录过程中,获取的用户对象user(全部信息)改成使用userDTO(自定义显示user信息),防止信息泄露,加速序列化(将对象转换成Map存入redis中)

3.设置获取对象的token的有效期,避免用户频繁登录,生成新的token,导致内存不必要的占用,为了避免“活跃用户频繁掉线”,常用“滑动过期”策略:每次用户发起已登录请求时,刷新这个 token 的 TTL,让会话持续保持活跃。

4.使用IOC思想对工具类,实现更低的耦合

5.使用超时剔除和主动更新策略(先改数据库,再删缓存)实现双向写一致

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇