金币商城功能迭代. 使用版本号乐观锁解决数据的并发修改问题

news2024/10/5 18:29:59

前言.

        公司的商城模块嵌在微信公众号里面, 商城里面除了少量的现金业务, 大头在金币业务里面, 商城本来就是用来增加客户粘度的, 金币是客户通过某些行为免费获得如注册, 绑定,推荐等

需求.

金币方面之前的设计:
       1.金币只有一个流水表,消费为负,获取为正
       2.并且没有有效期的限制
       3.客户余额通过sum流水表的金额获得.
       4:商城支持退款功能
新需求: 从下月1号开始, 比如2023年6月1号吧, 这个时间之前获得的金币统一有效期设置为2024年6月1日, 之后获得金币,有效期统一为一年. 
贴一下旧的字段: 客户余额就是sum(money)字段得到的

设计.    

将金币记录表增加4个字段:
        avail_money        当前流水还可以消费的余额 (适用于获取金币记录切是非退款流水)(以下简称av)
        back_flag             退款标识 (标记退款流水记录,被标记的不被统计余额,不参与运算)
        expire_time          过期时间 (本来是可以通过sql利用create_time代替这个字段的, 但是谁能知道1个月之后需求会不会在改为金币有效期改为2年呢, 这个过期时间后面也是要支持可配置的, 那就在新建的时候根据当前配置填充过期时间吧)
        trade_order          关联付款订单号, 用于生成退款记录的时候将之前的支付记录也标记为退款
        其实正常这里还应该需要一个version字段,用于防止并发问题,后面说

总体上讲, 就是消费的时候, 根据临期时间查到av值>0的, 然后开始根据消费金额计算av的值在更新.          退款的时候,根据消费时间, 获取到money > av列表 ,然后开始计算av的值更新

先说需要解决的问题: 
        1: 退款之后金币有效期如何计算: 尽量保证退款之后金币可以退到对应的消费订单扣除的金币记录上面,最起码可以推到最近的消费记录上面, 比如有2000金币明天过期, 然后我今天消费了2000金币的订单a, 第二天我又消费了3000金币的订单b. 然后我又退了订单a. 这个时候是允许金币退到订单b的消费记录上的. 当然如果a和b都退了, 那么过期的2000金币就不能用了. 退款时间就可以已金币记录为准. 同时退款订单产生的金币记录不应该是可消费的.  
        2: 在第一条的基础上, 历史数据的退款订单关联的金币记录我没有办法标记,  就是在查询可用金币的时候, 退款的也会算上. 后面想到反正之前历史数据的有效期统一为某一天了, 那么历史退款订单相关金币记录就都不标记了, 反正遵循一个规则, 要标记的话就要把订单的兑换的金币记录和退款的金币记录都标记, 切不可只标记一个
        3: 系统更新之后, 如果客户退老订单怎么办, 因为老订单是没有关联订单消费记录的, 更新之后, 在金币记录表里面有一个字段关联上新订单的订单号.  可以通过判断是否可以查到退款订单号相关的金币消费记录, 如果查不到,就说明退的是2023年6月1日之前的订单. 那么就只插入这条退款记录, 且这条退款记录的金币可以参与运算. 这里有一个问题, 就是这条退款记录产生的金币有效期, 严格一点可以直接设置成2024-06-01,因为已经可以确认当前订单消费的金币记录的在2023年6月1日前.  宽松一点可以当前时间+1年
        4:并发问题: 同一条金币记录, 假如money是4000, 之前消费了一笔订单是2000, av_money还剩2000, 现在客户将之前的订单申请了退款, 退款时需要后台审核完成退款. 假如刚好后台审核退款的时候, 客户又在下单支付1800金币, 且根据我们写的退款消费规则, 同时对这条money为4000的金币记录进行了操作:
        退款 查到av是2000, 退款2000, 更新av(avail_money)为4000
        下单 查到av是2000, 消费1800, 更新av为200
        正常期望值 2000+2000-1800=2200
这个时候问题就来了, 如果退款事务先提交, 那么下单事务提交之后会覆盖退款事务提交的值,客户白亏1800.   相反的客户白撸1800. 这个问题也特地验证了下确实存在. 方法就是将消费或者退款逻辑中,修改数据之后打一个断点不让提交, 然后再开一个服务执行另外的逻辑,执行完之后在放行服务一提交事务,确认是覆盖的. 
        解决这个问题正常就要用到加版本号了, 更新的时候加一个判断, 版本号和查询出来的一样不一样, 不一样的话根据update返回的结果集为0 判断没有更新到, 业务里面抛出异常回滚事务就行了. 我这个地方没加version, 是因为这个表的av可以当做version使用. 其余字段都没有修改的接口和需求, 只有这个av字段被修改. 所以改造下sql脚本就可以了. 这种是乐观锁的解决方式

update jbtable set av = newvalue where id =:id and av = oldvalue  -- 伪脚本

不知道有没有同学还有这种疑问: 万一我两个或多个事务在update的时候, 刚好同时更新, 这是想象中av值全都是旧数据的话, 那么是不是还会有覆盖的问题呢.  这个问题在学习mysql的时候一般讲师都会实操复现下: 在一个事务中update一条索引条件的数据时, 在事务提交之前, 当条数据是被锁的, 意味着你的另一个事务的更新语句是在等待锁的, 必须等第一个事务提交之后, 第二个事务才可能在去更新这个id的数据, 既然第一个事务已经提交了, 想必av的值已经有所改变. 第二个事务在拿旧的av值肯定是匹配不到更新的 
文末在演示一下这个问题.  

另外也可以使用悲观锁的解决方式, 就是在查询需要修改数据的时候使用for update, 然后在该事务提交之前, 后续的事务对这批数据修改, 或者select  for update都会处于阻塞等待锁的状态. 需要注意的是, 使用时要保证你查询语句命中了索引这个时候是行级锁, 没有命中 索引的话就是表级锁了, 所有的对该表的修改都会阻塞.
        另外, 要结合业务场景考虑会发生死锁的情况. 比如你本来已经锁了一批数据修改完成了, 这时候事务还没有提交, 你又需要用for update去查询别的数据修改.   刚好另外一个事务持有第二批数据的锁, 又要去修改你修改的还没提交的第一批数据的. 现在就形成了一个死锁:你要的锁他持有还没释放,  他想要的你持有还没释放. 

        避免死锁:  代码层面: 一次性锁住自己需要修改的所有数据.   设计上, 并发表需要以相同的顺序访问 
        服务层面:
        1. mysql参数Innodb_lock_wait_timeout参数(默认50秒),死锁发生时, 超过该时间, 报错回滚(先请求的先报错回滚, 后请求的就可以拿到自己的锁了)
        2.mysql开启死锁检测, 将参数 innodb_deadlock_detect 设置为 on。死锁发生时,  mysql会回滚一个他认为相对不关键的业务.

  • 历史数据处理

增加完字段之后首先需要处理历史数据:
        1.填充expire_time字段, 刷脚本得方式  当然前提是需要备份好

create table jbtablebak as select * from jbtable ;
update cw_hzjb set expire_time= case when (create_time<'2023-06-01 00:00:00') then '2024-06-01 00:00:00'  when (create_time>='2023-06-01 00:00:00')  then DATE_ADD(create_time, INTERVAL 1 YEAR) end where  money > 0 
-- 这里时间判断有点多余, 因为当前时间还没到2023-06-01. 不过按规矩来

        2.avail_money的处理.
        首先需要把money大于0的记录的avail_money字段填充为money

update cw_hzjb set avail_money = money where money >0

        其次, 我需要查出来每个客户的消费记录,然后根据临期时间排序,去消费上面的avail_money,最后处理完之后, 客户余额就可以直接sum(avail_money)获得.  这段可以用存储过程实现, 因为不是太熟(学了就忘,一般也就财务,报表系统会用到), 所以写了一段程序去刷数据:

//Dao     使用的是springdatejpa

public interface JbDao  extends JpaRepository<jbtable , Long> {

    @Query(value = "select mid,sum(money)as money from jbtable where money<0 group by mid ", nativeQuery = true) //查询客户的消费记录
    List<Map<Long,Integer>> query1();
    
    @Query(value = "select * from jbtable where mid = ?1 and avail_money>0 order by create_time  ", nativeQuery = true)  //查询客户金币记录中可以用来消费的记录 即av>0           
    List<jbtable > query2(Long id);

}

// Service
public void getdate(){
        List<Map<Long,Integer>>  jbs= jbDao.query1();
        for(Map<Long,Integer> h:jbs){
            List<Hzjb> jbs1= jbDao.query2(Long.parseLong(h.get("mid")+""));
            Integer money = Integer.parseInt(h.get("money")+"");
            for(jdtable h2 :jbs1) {
                money += h2.getAvailMoney();
                if (money >= 0) {
                    h2.setAvailMoney(money);
                    jbDao.save(h2);
                    break;
                } else {
                    h2.setAvailMoney(0);
                }
                jbDao.save(h2);
            }
        }
    }
//然后单元测试跑一下getdate()方法将av修改. 数据量大的话用程序跑很慢 

        修改完av数据之后, 还要检验一下数据是否正确, 就是对比下sum(money)和sum(av)的值是否相等, 相等才是正确的. 

select mid from jbtable group by mid HAVING mid not in (select mid from jbtable group by mid having sum(money)=sum(avail_money)) ; -- 查不出来数据表示完全匹配
  • 业务代码变更
    主要是需要下单和退款的逻辑修改, 其他发放金币的逻辑改动的很小.   贴一点吧 .
@Transactional(rollbackFor = Exception.class)
    public CwHzjbDto create(CwHzjb resources) {
        Integer money = resources.getMoney();
        Long mid = resources.getMid();
        Integer sum = cwHzjbMapper.findSumMoneyByMid(mid);
        int newSum = money + sum;
        if (money < 0 && newSum < 0) {
            throw new BadExceptionNoMsg(String.format("剩余金币[%s]不足以支付本次消费!", sum));
        }
        CwHzjb save = null;
        if(money < 0){ //消费等业务,  需要查询可用的,然后减掉
            List<CwHzjb> userAvail = cwHzjbRepository.findUserAvail(mid); //查询出有余额,然后
            for (CwHzjb cwjb: userAvail){
                Integer oldAv = cwjb.getAvailMoney();
                money += cwjb.getAvailMoney();
                if (money >= 0) {
                    //cwjb.setAvailMoney(money); //可用余额
                    int size = cwHzjbRepository.updateByIdAndMoney(money, cwjb.getId(), oldAv);
                    if(size != 1){
                        throw new BadExceptionNoMsg("出错了,请刷新重试!"); //金币修改并发情况, 给客户提示
                    }
                    break;
                } else { //如果是商城对换, 需要在下单时传入订单号 方便退款标记
                    int size = cwHzjbRepository.updateByIdAndMoney(0, cwjb.getId(), oldAv); 本条记录不够扣
                    if(size != 1){
                        throw new BadExceptionNoMsg("出错了,请刷新重试!");
                    }
                }
            }
            if(money<0){
                throw new BadExceptionNoMsg("剩余金币不足以支付本次消费!");
            }
            resources.setCreateTime(new Date());
            resources.setExpireTime(null);
            resources.setAvailMoney(null);
            resources.setCreateUser(UserManager.getUsername(false));//员工操作记录员工帐号
            save = cwHzjbRepository.save(resources);
        }else if(money > 0){  //发放和退款等 退款特殊处理
            resources.setCreateTime(new Date());
            resources.setCreateUser(UserManager.getUsername(false));//员工操作记录员工帐号
            resources.setAvailMoney(money);
            if(resources.getBackFlag()!=null && resources.getBackFlag()){ //退款特殊处理  todo 标识需要在订单退款的时候标注好
                String tradeOrder = resources.getTradeOrder();
                CwHzjb byTradeOrderAndCause = cwHzjbRepository.findByTradeOrderAndCause(tradeOrder, HjbCauseEnum.C4.getName());
                //todo
                if(byTradeOrderAndCause != null){ // 如果没有匹配到兑换时的金币记录, 说明退的是老订单. 那这个退的金币重置有效期
                    List<CwHzjb> userConsumList = cwHzjbRepository.findUserConsumList(mid);//查询出用户消费记录, 退款用
                    for(CwHzjb uConsum :userConsumList){
                        Integer oldAv = uConsum.getAvailMoney(); //类似于版本号避免并发修改
                        money -=(uConsum.getMoney()-uConsum.getAvailMoney());  //单条记录可以退金额
                        if(money <= 0){ //够退了
                            //uConsum.setAvailMoney(uConsum.getMoney()+money);    //关注 可能有问题
                            //cwHzjbRepository.save(uConsum);  //这里并发情况下会产生覆盖操作,不能用原生的save方法;
                            int size = cwHzjbRepository.updateByIdAndMoney(uConsum.getMoney()+money, uConsum.getId(), oldAv);
                            if(size != 1){
                                throw new BadExceptionNoMsg("金币记录出现并发情况,请重试!");
                            }
                            break;
                        }else{
                            //uConsum.setAvailMoney(uConsum.getMoney());  //本条记录退满了
                            int size= cwHzjbRepository.updateByIdAndMoney(uConsum.getMoney(), uConsum.getId(), oldAv);
                            if(size != 1){
                                throw new BadExceptionNoMsg("金币记录出现并发情况,请重试!");
                            }
                        }
                    }
                    resources.setAvailMoney(null);
                    resources.setExpireTime(null);
                    //退款成功,需要把退款记录对应的消费记录标识上.
                    //String tradeOrder = resources.getTradeOrder();
                    //CwHzjb byTradeOrderAndCause = cwHzjbRepository.findByTradeOrderAndCause(tradeOrder, HjbCauseEnum.C4.getName());
                    byTradeOrderAndCause.setBackFlag(true);
                    cwHzjbRepository.save(byTradeOrderAndCause);
                }else{
                    resources.setBackFlag(false); //退款时, 没有匹配到下单记录 那么这个退款记录也不能标记, 退款的金币是可使用的, 过期时间重置一年吧
                }
            }
            save = cwHzjbRepository.save(resources);
        }
        //发送微信公众号通知相关
        UserWebDto userWeb = UserManager.midToUserWebDto(mid);
        if (null != userWeb && userWeb.subscribe()) {//已注册且关注公众号
            WxgzhUtil.sendCardMessage(userWeb.getOpenid(), WxManager.getTempId("TID14"), JB_URL,
                    new WxgzhMessage.Template("first", "尊敬的用户你好,您的账户信息已出,请知悉!"),
                    new WxgzhMessage.Template("keyword1", resources.getMoney() + ""),
                    new WxgzhMessage.Template("keyword2", DateUtil.now()),
                    new WxgzhMessage.Template("keyword3", resources.getCause()),
                    new WxgzhMessage.Template("keyword4", newSum + ""),
                    new WxgzhMessage.Template("remark", "详情可到商城中查看!"));
        }
        return cwHzjbBean.toDto(save);
    }

  过期时间和availMoney赋值放在了domain对象的set方法里面:

    public void setMoney(Integer money) {
        if(money>0 && (this.availMoney == null)){
            this.availMoney = money;
        }
        this.money = money;
    }

    public void setCreateTime(Date createTime) {
        if(this.expireTime == null&&createTime!=null){
            Calendar cal = Calendar.getInstance();
            cal.setTime(createTime);
            cal.add(Calendar.YEAR, 1);
            this.expireTime = cal.getTime()  ;
        }
        this.createTime = createTime;
    }

    public void setBackFlag(Boolean backFlag) {
        if(backFlag && this.availMoney != null){
            this.availMoney = null;
        }
        this.backFlag = backFlag;
    }


 

多事务更新同一条数据乐观锁演示:

1:数据准备.

2:会话开启一个事务, 更新数据,但不提交事务

3. 新开会话, 新开事务在更新这条数据, 可以看到更新一直在读秒,等待锁
 

 4.提交第一个事务之后, 第二个语句立即执行完成, 但是显示更新影响条数为0. 说明这个时候av已经别第一个事务改为66了 不会有并发问题 

for update 悲观锁效果演示

 staff表code字段有索引

 1. for update 查询

 2. 新开会话, 尝试 查询 , 带for update查询 ,更新 code = 4749 记录

         普通查询可以查询到.

        for update 查询无法加锁, 等待50秒左右超时

         update更新和for update效果一样

 3. 尝试更新一个code为1006的值

        WTF 咋回事, 这里应该是可以更新成功的, 试了下 应该是锁表了, 不应该呀

 

分析下 code 竟然没有走索引,  离谱了, 查看建表语句  `code` varchar(32) , 原来是我们语句中1007和1006没有使用引号, 导致mysql帮我们做了一次类型转换导致索引失效了. 

         

加上在试下ok 

       

         在试下是不是行锁       ok成功 1006可以更新, 1007更新被另外一个事务锁着呢导致失败

 

 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/544102.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

常用的表格检测识别方法 - 表格区域检测方法(下)

——书接上文 Training 半监督网络的训练分两步进行&#xff1a;a)对标记数据独立训练学生模块&#xff0c;由教师模块生成伪标签&#xff1b;b)结合两个模块的训练&#xff0c;得到最终的预测结果。 伪标签框架 实验 数据集&#xff1a; TableBank是文档分析领域中用于表识别…

Beats:如何在 Docker 容器中运行 Filebeat

今天在这篇博客中&#xff0c;我们将学习如何在容器环境中运行 Filebeat。 为了快速了解 Filebeat 是做什么用的&#xff1a; Filebeat用于转发和集中日志数据它重量轻&#xff0c;小型化&#xff0c;使用的资源更少它作为代理安装在你的服务器上它监视来自指定位置的日志文件…

腾讯云EdgeOne为什么能让客户降本增效?

随着数字化时代的来临&#xff0c;各类线上互动场景不断出现&#xff0c;并成为人们日常工作生活中的一部分。然而&#xff0c;基于互联网提供线上娱乐、线上办公、线上购物等服务的企业&#xff0c;在复杂的全球网络环境下会遇到网络延迟不稳定的情况&#xff0c;海外环境更多…

QGIS安装与简单使用流程

QGIS工具是我们地图业务领域比用的工具之一&#xff0c;理论就先不介绍了&#xff0c;今天主要介绍安装使用等基本操作&#xff1a; 一、下载安装 1、官网下载 Download QGIS 显示页面&#xff1a; 可以选择不同版本。我们以3.28为例&#xff0c;点击进入&#xff1a; 点击…

【hive】hive order、sort、distribute、cluster by区别与联系

​ 1、order by hive中的order by 会对查询结果集执行一个全局排序&#xff0c;这也就是说所有的数据都通过一个reduce进行处理的过程&#xff0c;对于大数据集&#xff0c;这个过程将消耗很大的时间来执行。 2、sort by hive的sort by 也就是执行一个局部排序过程。这可以…

怎么将heic格式转化jpg,5种方法立马学

将HEIC格式转换为JPG格式是非常必要的。首先&#xff0c;HEIC&#xff08;.heic&#xff09;格式是苹果最新推出的图像格式。与JPEG相比&#xff0c;HEIC可以存储更多的图像信息&#xff0c;同时还支持透明度、深度和动态范围等功能。但由于HEIC格式相对较新&#xff0c;许多设…

Rust之泛型、特性和生命期(二):通用数据类型

开发环境 Windows 10Rust 1.69.0 VS Code 1.78.2 项目工程 这里继续沿用上次工程rust-demo 通用数据类型 我们使用泛型来为函数签名或结构等项目创建定义&#xff0c;然后我们可以将其用于许多不同的具体数据类型。让我们首先看看如何使用泛型来定义函数、结构、枚举和方法。…

27K 入职字节的那天,我哭了····

先说一下自己的个人情况&#xff0c;计算机专业&#xff0c;18年本科毕业&#xff0c;一毕业就进入了“阿里”测试岗(进去才知道是接了个阿里外包项目&#xff0c;可是刚毕业谁知道什么外包不外包的)。 更悲催的是&#xff1a;刚入职因为家里出现一些变故&#xff0c;没有精力…

【软件测试】Python自动化软件测试算是程序员吗?

今天早上一觉醒来&#xff0c;突然萌生一个念头&#xff0c;【软件测试】软件测试算是程序员吗&#xff1f;左思右想&#xff0c;总感觉哪里不对。做了这么久的软件测试&#xff0c;还真没深究过这个问题。 基于&#xff0c;内事问百度的准则&#xff1a; 结果…… 我刚发出软…

刚测完Bug,就被开除了····

我曾在一家软件公司担任功能测试工程师&#xff0c;经历了三年的工作。在这段时间里&#xff0c;我积累了丰富的测试经验和技能&#xff0c;在团队中也有着不错的表现。然而&#xff0c;最终我却被公司辞退了。 在我入职时&#xff0c;公司还没有建立完善的测试流程和标准。我的…

JVM系列-第10章-垃圾回收概述和相关算法(JVisualVM)

垃圾回收概述 Java 和 C语言的区别&#xff0c;就在于垃圾收集技术和内存动态分配上&#xff0c;C语言没有垃圾收集技术&#xff0c;需要程序员手动的收集。 垃圾收集&#xff0c;不是Java语言的伴生产物。早在1960年&#xff0c;第一门开始使用内存动态分配和垃圾收集技术的L…

【分享】阿里版ChatGPT—通义千问(初体验)

哈喽&#xff0c;大家好&#xff0c;我是木易巷~ 在上个月4月7号&#xff0c;木易巷开始申请阿里云大模型开始邀请测试「通义千问」&#xff0c;到今天早上&#xff0c;木易巷收到了申请通过的短信。 官网地址&#xff1a;tongyi.aliyun.com 迫不及待去测试了一下&#xff0c;效…

「直播精选问答」释放数据潜力,助力零售数智升级!

​5月10日的《释放数据潜力&#xff0c;助力零售数智升级》虹科零售专场BI直播课程活动&#xff0c;在各位观众的积极互动与热情反馈中落下帷幕&#xff01; 为激发观众和读者朋友对于零售行业数智化趋势的思考与讨论&#xff0c;真正让BI知识在数字时代得以共享和碰撞&#xf…

MaaS来临,SaaS进入「奇点」时刻|产业深度

大模型热度持续发酵。MaaS的到来&#xff0c;不仅改变了云厂商的竞争格局&#xff0c;SaaS行业也将迎来「奇点」时刻。未来十年&#xff0c;基于MaaS底座&#xff0c;国内SaaS甚至可能会出现Salesforce一样的巨头。 作者|思杭 编辑|皮爷 出品|产业家 大模型热度正在持续发酵。…

阿里云镜像服务下载并安装Go环境

【阿里云镜像】下载并安装Go环境 一、参考链接 阿里巴巴开源镜像站-OPSX镜像站-阿里云开发者社区 (aliyun.com) golang镜像-golang下载地址-golang安装教程-阿里巴巴开源镜像站 (aliyun.com) GO语言安装以及国内镜像 - DbWong_0918 - 博客园 (cnblogs.com) 二、Go介绍 Gol…

vscode使用git对代码进行管理

2、暂存自己更改&#xff1b;3、拉取别人的代码&#xff0c;防止别人更改代码合并时发生冲突&#xff1b;4、上传自己的更改并合并代码

深度学习在自然语言处理方面的应用

前言 自然语言处理是一种将自然语言转换为计算机可处理的形式的技术。深度学习是一种非常强大的机器学习技术&#xff0c;它在自然语言处理方面也有广泛的应用。本文将详细介绍深度学习在自然语言处理方面的应用。 自然语言处理的基本步骤 自然语言处理的基本步骤包括分词、词…

智能驾驶汽车的系统开发与验证软件PreScan2022版

Simcenter Prescan是基于物理学的最佳仿真平台&#xff0c;在桌面、集群和云端&#xff0c;用于智能驾驶车辆开发&#xff0c;于2022年三月宣布发布2022.1版本。Simcenter Prescan 2022.1引入了与SUMO的协同仿真&#xff0c;用于自动生成交通&#xff0c;并配有程序化的脚本&am…

RestTemplate使用详解

文章目录 1.1 RestTemplate环境准备1&#xff09;背景说明2&#xff09;工程配置RestTemplate 1.2 RestTemplate API入门-11&#xff09;get请求携带参数访问外部url2&#xff09;get请求响应数据自动封装vo实体对象3&#xff09;请求头携带参数访问外部接口 1.3 RestTemplate …

Spring:AOP 的详细内容

文章目录 Spring&#xff1a;Day 03AOP一、概述二、搭建环境三、实现 AOP1. 方式一&#xff1a;使用原生 Spring 的 API 接口2. 方式二&#xff1a;自定义类&#xff08;切面&#xff09;3. 方式三&#xff1a;注解 四、总结 Spring&#xff1a;Day 03 AOP 一、概述 AOP&…