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

news2024/11/18 9:22:46

前言.

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

需求.

金币方面之前的设计:
       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值肯定是匹配不到更新的 
文末在演示一下这个问题.
 

  • 历史数据处理

增加完字段之后首先需要处理历史数据:
        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了 不会有并发问题 

 

 

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

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

相关文章

github上有什么好的node.js的项目?

前言 可以参考一下下面的nodejs相关的项目&#xff0c;希望对你的学习有所帮助&#xff0c;废话少说&#xff0c;让我们直接进入正题 1、 NodeBB Star: 13.3k 一个基于Node.js的现代化社区论坛软件&#xff0c;具有快速、可扩展、易于使用和灵活的特点。它支持多种数据库&am…

驱动开发:通过应用堆实现多次通信

在前面的文章《驱动开发&#xff1a;运用MDL映射实现多次通信》LyShark教大家使用MDL的方式灵活的实现了内核态多次输出结构体的效果&#xff0c;但是此种方法并不推荐大家使用原因很简单首先内核空间比较宝贵&#xff0c;其次内核里面不能分配太大且每次传出的结构体最大不能超…

jvm之GC

写在前面 本文一起看下GC相关的内容。 1&#xff1a;GC基础内容 1.1&#xff1a;为什么要有GC 内存资源的稀缺性&#xff0c;以及内存管理的复杂性&#xff0c;决定了需要有垃圾回收器这样的角色来帮助我们管理内存资源&#xff0c;避免手动管理带来的内存不能得到正常释放…

普源DS1102Z-E示波器,100MHz带宽

DS1000Z-E系列数字示波器是RIGOL基于主流需求而设计的&#xff0c;电商专供款高性能经济型数字示波器,具备100MHz带宽和1GSa/s采样率&#xff0c;搭载RIGOL独创的UltraVision技术平台&#xff0c;更深的储存&#xff08;标配达24 Mpts&#xff09;&#xff0c;高达30,000 wfms/…

解决基于kvm的win10虚拟机只识别2个cpu的问题

在使用kvmqemu创建win10虚拟机的时候&#xff0c;发现尽管我在virt manager里面指定了72个vcpu&#xff0c;但是win10里面只识别2个cpu核心的现象。 如图所示&#xff1a; 虚拟系统管理器里面当时是这样设置的&#xff1a; 这个时候&#xff0c;对应的xml文件内&#xff0c;关…

【深度学习】离线安装Python及相关第三方库

如果对方电脑无法联网。 那么需要在可以联网的电脑上进行如下操作&#xff0c;随后将整个文件包传输到对方电脑&#xff0c;实现环境配置。 Python 先给离线电脑安装Python初始环境 https://www.python.org/downloads/ 这里我选择下载了Python3.7.8 https://www.python.org…

如何用python在微信公众号上添加自己的ChatGPT

由于之前chatgpt的火热&#xff0c;现在很多微信公众号都接入了chatgpt的接口&#xff0c;来给自家公众号增添一丝乐趣&#xff0c;下面我以自己的经验&#xff0c;用python在微信公众上添加自己的chatGPT&#xff0c;先看下方成果图 三步曲 申请自己的公众号部署服务接入chat…

如何用国产DBDesginer软件进行数据库建模设计?

我们在开发软件系统之前都需要进行数据结构的建模设计&#xff0c;传统的都是通过PowerDesiger等国外的软件或直接Excel来进行数据库表结构设计&#xff0c;今天来了解一下如何使用国产软件来进行数据库建模设计 1、首先是注册DBDesigner用户&#xff08; http://dbdesigner.n…

用蹩脚英语在StackOverflow上飞奔:如何在一个RestApi接口中调用另一个RestApi文件接口发送上传文件请求

上班摸鱼、下班干活&#xff0c;日常埋坑、加班填坑——这是我的搬砖&#xff0c;亦是在座的各位&#xff01; 昨天在StackOverflow上面闲逛&#xff0c;突然看到一个国际友人提出了一个问题&#xff0c;他发出了好久都没有人来回答&#xff0c;本着在下为人处世乐善好施、广结…

视频flv怎么转换成mp4格式?

视频flv怎么转换成mp4格式&#xff1f;关于视频格式转换这个问题&#xff0c;小编发现&#xff0c;就算不是从事视频编辑的朋友也有可能经常会遇到这种转换操作&#xff0c;为什么会这样呢&#xff1f;原因主要是因为视频格式的种类真的非常多&#xff0c;多到我们数不清&#…

软件测试之自动化测试

目录 一、初试自动化测试 1.自动化测试的分类&#xff08;简单了解&#xff09; 2.如何实施自动化单元测试 二、selenium 介绍&#xff08;重点内容&#xff09; 1.工作原理 2.Selenium 环境搭建 2.1 Chrome java 2.2 常见问题及解决方案 1.元素的定位 1.1 CSS 定位…

sonarqube分析仓库

sonarqube可以有多种方式分析仓库 使用GitlabCI 设置项目编码 添加环境变量 创建或修改配置文件 sonarqube-check:image: name: sonarsource/sonar-scanner-cli:latestentrypoint: [""]variables:SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar" # Defi…

GE H201Ci-1 单通道变压器控制单元

HYDRAN 201Ci-1一个标准模拟输出通道控制器非隔离&#xff0c;跳线可配置为0-l mA 一个遥控电子控制器或4-20 mA&#xff08;最大输出10 V&#xff09;、0-1 V或0-10 V&#xff1b;0-2000 ppm范围 HYDRANR 201Ti智能变送器&#xff1b;它提供网络通信能力 第二个可选模拟输出加…

基于Web的智慧产业园区3D可视化运营管理平台

改革开放以来&#xff0c;园区逐渐成为地区招商引资、储备人才的重要途径。我国社会、经济处于快速发展阶段&#xff0c;园区正向着智慧化、创、科技化转变。 建设背景 在人类的历史发展过程中&#xff0c;随着5G、人工智能、云计算、物联网、GIS等新一轮信息技术的迅速发展&…

Jenkins流水线整合k8s实现代码自动集成和部署

一、前置条件 1、安装好k8s集群 这里先要搭建好一个K8s集群&#xff0c;笔者这边就采用使用了一个一主一丛的k8s集群&#xff0c;k8s集群的版本使用1.19.5版本&#xff0c;服务器的配置&#xff1a;2核4G&#xff0c;操作系统: CentOS Linux release 7.9.2009 (Core) 主机名…

Rabbitmq 下载与安装

文章目录 说明1、下载Erlan2、下载对应的rabbitmq 说明 Rabbitmq依赖于Erlan 对应版本查看&#xff1a;https://www.rabbitmq.com/which-erlang.html 这里版本使用&#xff1a; Rabbitmq 3.9.11 Erlan 24.1.7 1、下载Erlan https://www.erlang.org/patches/otp-24.1.7 安装完…

matlab代码复现:室内定位、无线传感器网路定位、估计优化滤波等探讨及技术支持

室内外定位导航、无线传感器网路定位、估计优化滤波等探讨及技术支持 博主主要擅长以下几个方面&#xff0c;探讨、技术支持、代码复现等问题欢迎联系&#xff0c;也可以站内私信 weixin: ZB823618313&#xff08;备注来意&#xff09; 1. 估计\滤波\融合 1 各种参数估计算法…

ChatGPT智能聊天系统1.0.3版本发布啦~

ChatGPT智能聊天系统1.0.3版本啦&#xff01;主要更新内容如下&#xff1a; 新增功能 获取微信昵称 支付宝支付 PC端允许退出登录 PC端创作和技能支持关键词搜索 分享记录添加今日分享统计 邀请记录添加今日邀请和今日发放统计 细节优化 模型对话框去掉快递发送功能 后…

bioinformatics2022 | Sc2Mol+:使用VAE和Transformer的两阶段基于骨架的分子生成

原文标题&#xff1a;Sc2Mol&#xff1a;a scaffold-based two-step molecule generator with variational autoencoder and transformer 论文地址&#xff1a;Sc2Mol: a scaffold-based two-step molecule generator with variational autoencoder and transformer | Bioinfo…

3 分钟利用 FastGPT 和 Laf 将 ChatGPT 接入企业微信

原文链接&#xff1a;https://forum.laf.run/d/556 FastGPT 是一个超级&#x1f42e;&#x1f37a;的 ChatGPT 平台项目&#xff0c;功能非常强大&#xff1a; ✅ 集成了 ChatGPT、GPT4 和 Claude ✅ 可以使用任意文本来训练自己的知识库、文档库&#xff0c;而且知识库专有模…