每日学习Java之一万个为什么?

文章目录

  • 基于RouYi改造的电商项目业务简单描述
    • Minio文件上传服务
    • 权限控制
    • 管理端功能
    • H5页面Ajax响应数据接口开发
      • 缓存穿透
      • 缓存击穿
    • 购物车功能
    • 订单
      • 结算
      • 下单
      • 立即购买
      • 支付页
      • 我的订单
      • 订单详情
      • 用户取消订单
    • 库存
    • 支付
  • AOP实现自定义缓存热拔插注解中遇到的问题
      • **1. 是否需要将增删改查分为四个类型的插件?**
        • **原因分析**
        • **解决方案**
      • **2. 缓存数据一致性问题**
        • **常见问题**
        • **解决方案**
      • **3. 幂等性的需要**
        • **场景**
        • **解决方案**
      • **4. EL表达式的使用是否冗余?**
        • **场景**
        • **解决方案**
      • **5. 参数是对象时怎么拼接 Key?**
        • **场景**
        • **解决方案**
      • **总结**

基于RouYi改造的电商项目业务简单描述

Minio文件上传服务

  • 文件上传-Config : MinioClient初始化配置类,导入了yml中的minio prefix属性值使用构建者模式初始化
  • WebMvcConfigurer :添加跨域配置,本地文件上传路径
  • LocalSysFileServiceImpl : FileUploadUtils.upload
  • MinioSysFileSerivceImpl : client.putObject(args)
  • FileUploadUtils : 提供了文件上传的相关方法

权限控制

  • 基于RBAC模型设计,表结构包括:系统菜单,系统角色,系统用户,角色菜单关联表,用户角色关联表
  • 接口权限设计通过AOP实现
    每日学习Java之一万个为什么?_第1张图片

管理端功能

  • 商品分类 : 增删改查,管理端展示一个分类列表树,后端就是一个根据顶级分类id,查找分类表,如果某分类的父分类id为传入id,则该分类为二级分类,再补充二级分类的子结点,三级分类。
  • 商品单位:增删改查,分页查询,这里通过RouYi的startPage分页,传入商品单位名称,然后构建查询包装类即可。
  • 分类品牌 : 这是一个关联菜单,后端提供增删改查即可
  • 商品规格 : 后端提供商品规格的增删改查即可
  • 商品管理 : 管理端新增商品需要先加载商品单位数据、品牌、规格,点击新增后,后端接收包含product请求体的请求,对product进行insert操作。管理端更新商品的上下架状态也需要提供一个update sql。删除商品的业务中,支持批量删除,传入ids,先删除商品表中的数据,然后删除sku,不过我们先需要获取skuIds,就需要根据productIds查询sku表中的skuIds

H5页面Ajax响应数据接口开发

  • 一级分类展示 :查询一级分类即可,一级分类的parentId = 0
  • 畅销商品数据 :查询sku和stock表,根据销售数量排序取前20条数据。
  • 分类树展示 : 由于三级分类存放在一个表中,我们查询所有分类Vo,创建map,将id,vo放在map中,初始化map。然后重新遍历分类volist,当前vo如果非顶级分类且父类存在的话,就将当前Vo设置为父节点的子结点。最后我们收集parentId == 0 的分类返回。
  • 某品牌下商品列表展示 : 前端传入skuQuery查询包装类,我们根据该查询的信息返回商品sku对象,因为该对象包含商品详情,这个查询sql稍微复杂一点。
  • 商品详情展示 : 点击某个商品的时候,我们需要在详情页面展示出商品的详情数据包括商品的基本信息、当前商品sku基本信息、商品sku最新价格、轮播图、规格、库存等你需要的字段。不过其中封装数据的部分,由于是互不干扰的结果子集,我们可以用CompletableFuture+自定义线程池 异步编排查询任务。最后allof().join()返回结果即可。
  • 缓存优化 : 同时详情页面是被用户高频访问的,我们可以将主体内容sku对象放入缓存中(item整体太大了,而且是多表结果,一致性难以保证)。具体实现 : 先构建该业务数据Key:前缀+业务唯一标识,然后我们查缓存,如果有数据直接返回。

缓存穿透

位图初始化sku信息到缓存中,如果商品有上下架,同步更新位图信息。使用商品id作为偏移量设置位图的true / false。

缓存击穿

使用setnx + lua 脚本解决

@Autowired
private RedisTemplate redisTemplate;

/*
     * 根据SkuID查询SKU商品信息
     *
     * @param skuId
     * @return
     */
@Override
public ProductSku getProductSku(Long skuId) {
    try {
        //1.优先从缓存中获取数据
        //1.1 构建业务数据Key 形式:前缀+业务唯一标识
        String dataKey = "product:sku:" + skuId;
        //1.2 查询Redis获取业务数据
        ProductSku productSku = (ProductSku) redisTemplate.opsForValue().get(dataKey);
        //1.3 命中缓存则直接返回
        if (productSku != null) {
            log.info("命中缓存,直接返回,线程ID:{},线程名称:{}", Thread.currentThread().getId(), Thread.currentThread().getName());
            return productSku;
        }
        
        //2.尝试获取分布式锁(set k v nx ex 可能获取锁失败)
        //2.1 构建锁key
        String lockKey = "product:sku:lock:" + skuId;
        //2.2 采用UUID作为线程标识
        String lockVal = UUID.randomUUID().toString().replaceAll("-", "");
        //2.3 利用Redis提供set nx ex 获取分布式锁
        Boolean flag = redisTemplate.opsForValue().setIfAbsent(lockKey, lockVal, 5, TimeUnit.SECONDS);
        if (flag) {
            //3.获取锁成功执行业务,将查询业务数据放入缓存Redis
            log.info("获取锁成功:{},线程名称:{}", Thread.currentThread().getId(), Thread.currentThread().getName());

            try {
                //再次检查缓存,是否有数据
                productSku = (ProductSku)redisTemplate.opsForValue().get(dataKey);
                if(productSku != null){
                    return productSku;
                }

                productSku = baseMapper.selectById(skuId);
                //防止缓存穿透,将null值转换成新对象存入数据库,并且设置较短的过期时间
                long ttl = productSku == null ? 1 * 60 : 10 * 60;
                if(productSku == null){
                    productSku = new ProductSku();
                }
                //将查询数据库结果放入缓存
                redisTemplate.opsForValue().set(dataKey, productSku, ttl, TimeUnit.SECONDS);
                return productSku;
            } finally {
                //4.业务执行完毕释放锁
                String scriptText = """
                    if redis.call('get',KEYS[1]) == ARGV[1]
                    then
                        return redis.call('del',KEYS[1])
                    else
                        return 0
                    end
                    """;
                DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
                redisScript.setScriptText(scriptText);
                redisScript.setResultType(Long.class);
                redisTemplate.execute(redisScript, Arrays.asList(lockKey), lockVal);
            }
        } else {
            try {
                //5.获取锁失败则自旋(业务要求必须执行)
                Thread.sleep(200);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.error("获取锁失败,自旋:{},线程名称:{}", Thread.currentThread().getId(), Thread.currentThread().getName());
            return this.getProductSku(skuId);
        }
    } catch (Exception e) {
        //兜底处理方案:Redis服务有问题,将业务数据获取自动从数据库获取
        log.error("[商品服务]查询商品信息异常:{}", e);
        return baseMapper.selectById(skuId);
    }
}

购物车功能

购物车功能只将商品添加到redis缓存中。有以下几个功能,就是对redis的操作

  • 查询购物车列表
  • 添加到购物车 : 根据商品id和数量,添加到
  • 删除购物车商品
  • 更新商品选中状态
  • 全选
  • 清空

订单

结算

  • 前端需要 用户地址信息列表,结算页选中默认地址。以及购物车商品列表,总金额。
  • 后端根据当前用户ID远程调用购物车模块,查找缓存中的购物车信息,并使用BigDecimal计算订单总金额。同时生成订单流水号(这里需要异步编排吗?),防止产生重复订单和页面等待超时。

下单

  • 用户在点击提交订单后,我们需要保存订单信息、订单项信息、并记录订单日志,下单成功后重定向到订单支付页面。
  • 后端先通过LUA脚本验证订单流水号,然后远程调用商品模块查询商品价格列表是否变更,有变更:远程调用,更新购物车选中商品价格,购物车获取某个商品价格,更新缓存。提示价格有变更,引导用户重新。保存订单、明细、日志,如果下单失败,解锁库存,下单成功则锁定库存,并删除购物车的商品。并响应订单,跳转支付。

立即购买

  • 立即购买流程与购物车计算流程一致,立即购买直接进入结算页,不经过购物车,结算页返回数据与正常下单结算数据一致,提交订单接口不变

支付页

  • 提交订单成功后,跳转到支付页面,根据订单id获取订单详细信息,展示订单支付信息。本质上就是根据接口文档或者前后端协调封装数据。

我的订单

  • 我的订单有四种不同状态:待支付、待收货、待评价、已取消。默认查全部,是分页查找。
  • 也是根据不同订单状态封装订单数据返回,用RouYi提供的分页插件

订单详情

  • 点击我的订单中的订单详情,返回相应的数据。

用户取消订单

  • 业务逻辑和延迟关单一致

库存

  • 新建common-rabbit模块,导入amqp依赖,编写路由交换机配置类
  • 消息可靠性配置 生产者确认setConfirmCallback,生产者回退setReturnsCallback。消费者在yml中配置
  • 编写检查、锁定、解锁库存的sql语句。其中检查库存使用悲观锁
  • 检查与锁定库存-Handler : 传入订单号以及skuvo列表。调用spi返回errorMsg字符串。
  • 检查与锁定库存-Impl : 遍历传入的skuVo列表,对每一个sku进行check sql,返回值是skustock对象,如果没有足够的库存,这个对象会为null,我们将库存不足的skuVo中是否有足够库存设置为false。 只要skuVo列表中有一个商品库存不足,将商品遍历出来拼接errorMsg并返回。如果所有商品库存充足,锁定库存,也就是遍历Vo列表对每一个进行lock sql,并将该订单库存锁定信息skuVo列表写入缓存
  • 检查与锁定库存-feign :这个接口会被order服务调用,所以product需要提供feign接口,定义服务降级工厂,返回一个默认值。
  • 解锁库存-Handler : 这个handler是一个rabbit消费者,处于product服务中,监听绑定unlock路由的队列。当下单失败 / 支付成功的时候,我们需要解锁库存。
  • 解锁库存-Impl :传入一个订单号,先setnx|ex设置该订单库存解锁幂等性,然后获取锁定库存的缓存信息。如果缓存没有,返回。然后对skuVo列表遍历解锁每一个skuVo的库存即可

支付

  • 构建支付模块,引入alipay-skd-java依赖,配置yml所需参数(区分正式环境/沙箱环境)

  • 构建配置类,引入支付url,私钥,返回链接,通知链接,公钥,appid等参数,返回alipayClient组件

  • 支付-Handler:根据订单号支付,传入订单号,调用服务层提交表单,我们返回给前端一个 String payForm

  • 支付-Impl:先保存本地交易记录到数据库,然后创建支付API对应的请求对象,同步回调引导用户至成功页面,用户付款成功后,支付宝异步通知商户系统 ,设置setBizContent,调用客户端的pageExecute方法并返回String 类型 响应体

  • 保存本地交易记录-Impl:传入订单号,根据订单号查询本地交易记录,如果存在则直接返回。然后构建本地交易记录的对象PaymentInfo ,其中需要远程调用根据订单号获取订单信息,如果订单已支付/已关闭直接返回,否则继续构建交易记录对象。构建完毕后,save并返回交易记录对象

  • 支付回调-Handler:传入一个map,调用支付宝回调api,并为其配置白名单

  • 支付回调-Impl:rsaCheckV1验证签名,setnx设置订单回调的信息缓存进行幂等性处理,再次验证回调信息的正确性,并更新本地交易记录的状态为已支付 updatePaymentStatus

  • 支付回调-Config : 支付成功后,由于本地接口位于一个私有IP范围内,无法被公共网络访问这个时候我们需要内网穿透技术

  • 更新本地交易记录Impl :传入的是map类型的支付宝回调信息,先验证用户支付金额与商户侧应付金额是否一致,如果不一致抛出异常。然后更新本地交易记录对象并重新保存,保存后发送修改订单状态以及库存扣减通知Msg到相应服务的交换机以及路由上。

  • 修改订单状态-rabbit : 监听到支付成功的消息之后,调用spi将订单状态更新即可

  • 扣减商品库存-rabbit :监听到成功消息后,调用扣减库存spi,setnx保证幂等性

  • 扣减商品库存-Impl : 根据订单号获取 缓存中的锁定库存信息,skulist,并对每个sku库存

[Order Service] 
   │
   └─→ (Feign) [Stock Service: lockStock]
   │       │
   │       └─→ DB悲观锁 → Redis缓存
   │
   └─→ [Alipay SDK] → 支付页面跳转
           │
           └─→ 支付成功 → 回调接口
                   │
                   └─→ 验签 → Redis幂等校验 → 更新支付状态
                           │
                           └─→ RabbitMQ[Order Service: updateStatus]
                                   │
                                   └─→ [Stock Service: deductStock]

AOP实现自定义缓存热拔插注解中遇到的问题

1. 是否需要将增删改查分为四个类型的插件?

需要分类处理,但可通过注解和切面统一管理。**

原因分析
  • 缓存操作类型不同

    • 查询操作(@Cacheable):命中缓存则直接返回结果,未命中则执行方法并缓存。
    • 更新操作(@CachePut):无论是否命中缓存,都执行方法并将结果更新到缓存。
    • 删除操作(@CacheEvict):清空缓存中的特定键。
    • 批量操作(allEntries):清空所有缓存条目。
  • 业务逻辑差异
    查询和更新的缓存逻辑不同,分开处理能避免冲突(例如更新后需立即清空旧缓存)。

解决方案
  • 设计注解
    定义 @MyCacheable@MyCachePut@MyCacheEvict 等注解,分别对应不同操作类型。

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface MyCacheable {
        String key();
        long expireTime();
        TimeUnit unit();
    }
    
  • 切面逻辑分离
    在切面中通过注解类型判断操作类型,并调用对应的缓存方法(如 getputdelete)。

    @Around("@annotation(myCacheable)")
    public Object handleCacheable(ProceedingJoinPoint pjp, MyCacheable myCacheable) {
        // 查询缓存逻辑
    }
    
    @Around("@annotation(myCachePut)")
    public Object handleCachePut(ProceedingJoinPoint pjp, MyCachePut myCachePut) {
        // 更新缓存逻辑
    }
    

2. 缓存数据一致性问题

需结合事务和缓存更新策略。**

常见问题
  • 缓存与数据库不一致
    数据库更新后未及时更新缓存,导致读取旧数据。
  • 缓存雪崩/击穿
    大量缓存同时过期或热点数据过期时,请求直接打到数据库。
解决方案
  • 双写策略
    数据库更新后,先更新数据库,再删除缓存

    @Transactional
    public void updateData(User user) {
        // 更新数据库
        userRepository.save(user);
        // 删除缓存
        redisTemplate.delete("user:" + user.getId());
    }
    
  • 缓存过期时间分散
    为缓存设置随机过期时间(如 TTL + random(0,300s)),避免同时过期。

    redisTemplate.expire("user:" + id, expireTime + new Random().nextInt(300), TimeUnit.SECONDS);
    
  • 热点数据预热
    对高频访问的数据(如商品详情页)在启动时预加载到缓存中。


3. 幂等性的需要

需在缓存操作中加入幂等性控制。

场景
  • 同一请求重复提交时,需确保只处理一次(如订单创建、支付接口)。
解决方案
  • Redis原子操作
    使用 SETNX(SET if Not Exists)或 Lua 脚本实现原子性校验。

    Boolean isLocked = redisTemplate.opsForValue().setIfAbsent("lock:" + key, "1", 5, TimeUnit.SECONDS);
    if (isLocked) {
        // 执行业务逻辑
    } else {
        throw new RuntimeException("重复提交");
    }
    
  • 分布式锁优化
    使用 Redlock 算法或 Redisson 的分布式锁应对高并发场景。


4. EL表达式的使用是否冗余?

合理使用 EL 表达式可提高灵活性,但需避免过度复杂化。

场景
  • 动态生成缓存键(如 user:#idorder:#userId:#orderId)。
解决方案
  • SpEL 表达式解析
    在切面中通过 SpelExpressionParser 解析注解中的表达式,并绑定方法参数。

    SpelExpressionParser parser = new SpelExpressionParser();
    StandardEvaluationContext context = new StandardEvaluationContext();
    for (int i = 0; i < args.length; i++) {
        context.setVariable("arg" + i, args[i]);
    }
    String key = parser.parseExpression("#{arg0.id}").getValue(context, String.class);
    
  • 避免冗余
    若表达式过于复杂(如嵌套调用),可能导致性能问题或维护困难。建议保持简洁,必要时拆分逻辑。


5. 参数是对象时怎么拼接 Key?

通过 SpEL 表达式提取对象属性,或手动序列化对象。

场景
  • 方法参数是复杂对象(如 User user),需提取 user.id 作为缓存键的一部分。
解决方案
  • SpEL 提取属性
    使用 #root.args[0].id#user.id 动态获取对象属性。

    @MyCacheable(key = "#user.id")
    public User getUserById(User user) {
        return userRepository.findById(user.getId());
    }
    
  • 手动序列化
    将对象转为 JSON 字符串(如 JSON.toJSONString(user)),但需注意对象的 equalshashCode 实现。

    String key = "user:" + JSON.toJSONString(user);
    
  • 工具类辅助
    使用 ObjectUtils 工具类提取关键字段(如 getId())。

    String key = "user:" + user.getId();
    

总结

问题 解决方案
1. 分类插件 使用不同注解(@MyCacheable@MyCachePut)区分操作类型
2. 缓存一致性 双写策略 + 随机 TTL + 预热
3. 幂等性 Redis 原子操作 + 分布式锁
4. EL 表达式 SpEL 解析参数 + 避免复杂表达式
5. 对象参数 Key SpEL 提取属性 + 手动序列化

你可能感兴趣的:(学习,java,开发语言)