前言.
公司的商城模块嵌在微信公众号里面, 商城里面除了少量的现金业务, 大头在金币业务里面, 商城本来就是用来增加客户粘度的, 金币是客户通过某些行为免费获得如注册, 绑定,推荐等
需求.
金币方面之前的设计:
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更新被另外一个事务锁着呢导致失败