1 前言赘述
1.1 重要性
资损最直接的就是给平台和公司带来经济损失,甚至会有舆论风波,造成用户流失等严重后果,更有甚者可能引发法律纠纷等。
1.2 必要性
正是因为有上述各种风险,进行资损防控建设很有必要。
1.3 一贯性
-
敬畏之心、警钟长鸣。资损防控建设应该是贯穿系统建设整个周期的事情
-
无万全应对之法,需从点滴做起。
2 资损防控理论
2.1 资损定义
广义定义:任何由外在或内在原因 引起的平台或客户 资金损失均可以称为资损。
狭义定义:由于支付平台故障引发的公司资金损失。
2.2 资损引发的原因
2.3 资损的类型
资损有很多种类型,如(1)钱少付多付、早付晚付;(2)优惠券的错发;
2.4 资损的"防"与"控"
资损防控的要义如其所见在于"防范"和"控制"。防范于未然---注重于事前的防范,资损发生时及时拦截"止血"----注重事中,事后及时复盘与分享。
2.4.1 防
资金平台需要对涉及资金风险的链路重点保障。在需求开发之前到发布之后需要全链路重视。

2.4.2 控
需求上线之后,需及时关注告警。

4 常见场景与应对
4.1 关于幂等
一个支持幂等的接口,应当满足:无论调用多少次,对系统造成的业务结果是一致的。
(1)明确幂等字段:服务提供方在接口约定中一定要明确哪个是幂等字段。
(2)幂等键需跨域对齐:内部应用的幂等字段变更,一定要跨域拉通对齐。
(3)与渠道的幂等字段及幂等条件一定要问清楚,不能想当然。

常见的幂等性解决方案列举:
4.2.1 select+insert+主键/唯一索引冲突
交易请求过来,先根据请求的唯一流水号 bizSeq字段,先select一下数据库的流水表如果数据已经存在,就拦截是重复请求,直接返回成功;如果数据不存在,就执行insert插入,如果insert成功,则直接返回成功,如果insert产生主键冲突异常,则捕获异常,接着直接返回成功。

4.2.2 状态机幂等
在处理带状态的流程时,可以根据状态的流转来控制。比如常见的处理状态:0-待处理,1-处理中、2-成功、3-失败。当我们更新数据时,可以添加前置状态的限定来处理。
4.2.3 防重表
通过建立单独的防重表,来处理重复请求。同一种操作类型 + 同一个业务单据号,只能被接收成功一次,后续操作都将会被幂等掉。

4.2.4 加锁
(1)悲观锁:select *** for update
sql
begin; # 开始事务
select * from order where order_id= 'ID_0001' for update # 查询订单,判断状态,锁住这条记录
if(status !=处理中){
//非处理中状态,直接返回;
return ;
}
## 处理业务逻辑
update order set status='finish' where order_id='ID_0001' # 更新完成
commit; #提交事务
注意:
-
这里面order_id需要是索引或主键,只需锁住这条记录就好,不然会锁表;
-
需在事务内。因为select之后就会释放锁,但是后续还得更新数据。
(2)乐观锁
就是给表的加多一列version版本号,每次更新记录version都升级一下(version=version+1)。即先查出当前的版本号version,然后去更新修改数据时,确认下是不是刚刚查出的版本号,如果是才执行更新
sql
select version from order where order_id='ID_0001'; update order set version = version + 1,status='P' where order_id='ID_0001' and version =1
(3)分布式锁
分布式锁实现幂等性的逻辑就是,请求过来时先去尝试获得分布式锁,如果获得成功,就执行业务逻辑,反之获取失败的话,就舍弃请求直接返回成功。如使用Redis分布式锁。
总结
本质上,只要严格执行"一锁二判三更新"的原则,基本不会有太大问题。
4.3 金额问题
4.3.1 金额的放大缩小
交易链路中,对金额的处理可能会遇到单位不一致(分与元)等问题,加上系统间或前后端交互,比较容易出现金额放大或缩小问题。
建议:
(1)DB中金额字段类型设定:设定为Long(分)或者Bigdecimal(元)类型。
(2)制定适用于公司业务的Money类来统一收口处理金额,而非到处自己实现。
4.3.2 防止金额超扣
假设原有支付单为100元,需要对其进行扣减,如退款场景。由于涉及审批等较长流程,再加上并发操作等,可以有以下几种方式:
(1)严格并发:当前只允许有一个扣减操作;
(2)金额预先冻结(乐观锁,更新时才加锁):
判断是否可以继续扣减,判断余额 = (ori_amount - refund_amount - freeze_amount )是否 > 0;
sql
表:order
列:ori_amount refund_amount freeze_amount version
100 20 70 1
更新时: update order
set refund_amount = refund_amount + diff,
freeze_amount = freeze_amount - diff,
version = version + 1
where id = 111
and ori_amount - refund_amount - freeze_amount >= 0
and version = 1;
4.4 依赖服务返回码映射不明确
依赖的服务,除了正常返回成功或失败之外,有可能会有其他返回:如timeout、如system exception、如other unknow exception等。
(1)区分"请求受理结果"和"业务处理结果";
(2)只有明确是"业务成功"才能推进成功,明确是"业务失败"才能推进到失败。禁止把"超时","系统异常","交易重复","订单不存在"等返回码映射为失败。禁止把"请求成功"映射为业务成功。
(3)需要依赖方提供可查询确认接口,防止重复请求引发其他问题。
4.5 消息乱序
问题描述:
在依赖消息队列处理业务流程时,由于消息中间件不保证消息发送的顺序和业务发生的先后顺序的一致性,比如第一次请求失败,会发送失败的消息出来。服务会自动重试又成功了,这个时候会发出成功的消息。
对于消费方可能会先收到的成功的消息,将自己的流程处理成成功状态;
接着又收到失败的消息,又将自己的流程处理成失败状态。 ==== 但是真实情况是已经成功了。

处理:
(1)消息体增加**发送时间字段,**消息生产方设定消息发送时的时间,接收方需要落盘此时间,对于过期消息不处理,新消息则更新。
(2)控制消息发送FIFO(如果可以的话),保证消息是按顺序发送。
4.6 越权
水平越权:即张三可以访问李四的数据,按说应该隔离;
垂直越权:即跨越角色越权。如本应该有客服发起的变更流程,结果普通用户也可以发起。
解决:
(1)前后端同时对用户输入信息进行校验,双重验证机制;
(2)执行关键操作前必须验证用户身份,验证用户是否具备操作数据的权限
(3)敏感操作可以让用户再次输入密码或其他的验证信息,二次确权。
(4)可以从用户的加密认证 cookie 中获取当前用户 id,防止攻击者对其修改。或在 session、cookie 中加入不可预测、不可猜解的 user 信息。
(5)直接对象引用的加密资源ID,防止攻击者枚举ID,敏感数据特殊化处理
4.7 状态机
(1)状态更新,需要有前置合法状态的校验;
(2)要有终态概念,一旦到达终态,就不能再变更。如果数据异常,那就人工订正即可。
4.8 滥用魔法值
在编码中多处直接写死某些属性或固定值。可能引发的问题:
(1)代码难以维护,难以理解,修改时容易漏改;
(2)可能出现错误,如写入缓存时同学A写死为"prefix_"+bizId;而使用者B查缓存时用"prefix"+bizId而获取不到缓存出现问题;


4.9 事务与异常处理
4.9.1 事务内异常捕获后处理不当
-
事务内将异常吃掉,导致落盘脏数据,流程也未回滚。
java
public class Service implements ServiceI{
@Transactional
public void update(Data data){
try{
//修改相关逻辑
}
catch (Exception e){
// 这里什么都不做,或者只打印异常日志
}
}
}
4.9.2 @Transactional 属性 rollbackFor 设置错误,导致异常不满足回滚条件
如下代码:
这里#addUser()标记了@Transactional,但没有设置rollbackFor属性,且#addUserRole()抛出的异常是Exception ,不是RuntimeException,这样事务也失效了,因为默认情况下,出现 RuntimeException(非受检异常)或 Error 的时候,Spring才会回滚事务。
java
@Service
public class test{
@Transactional
public void addUser(Param param) {
addUserRole(user.getId());
log.info("执行结束了");
}
private void addUserRole(Long userId) throws Exception {
if (CollectionUtils.isEmpty(roleIds)) {
return;
}
List<String> userRoles = Arrays.asList("122");
userRoleDAO.insertBatch(userRoles);
throw new Exception("发生异常");
}
}

4.9.3 @Transactional长事务引发生产问题
即在复杂耗时方法上直接标注@Transactional。比如第三方接口调用(未知其具体的实现过程)、业务逻辑复杂、大批量数据处理等就会导致数据库连接一直被占用不释放。一旦类似操作过多,就会导致数据库连接池耗尽。这就是典型的长事务问题。其危害有:
(1)数据库连接池被占满,应用无法获取连接资源;
(2)容易引发数据库死锁;
(3)数据库回滚时间长;
解决方案:
(1)按需启用事务,一些读操作没必要在事务内组组装;
(2)采用编程式事务,手动控制事务的开启和提交。(避免声明式)。
4.10 多线程与资源共享
背景
-
一些service(服务)使用了类成员变量,而且是可写的,导致不同线程写入不同数据,引起资损。
-
引入了Threadlocal变量,但在入口和出口没有做重置和清理,导致不同线程误用另一个线程的数据。
-
为提高性能引入缓存,但是混用了特定用户的缓存数据。比如设计是只是缓存了公共数据,但是在开发中把单个请求的特定数据也放入了缓存中,导致后续线程误用数据。
解决方案
-
严格禁止在service(服务)使用可写的类成员变量。
-
Threadlocal变量在入口和出口一定要做重置和清理。
-
所有缓存数据,需要明确是所有线程共享,还是特定用户的数据。全局缓存数据和个人缓存数据不要混在一起。如果要有个人数据的缓存,一定要有与个人强相关的明确的KEY
4.11 一些不好的习惯(待讨论)
4.11.1 POJO对象转换
禁用BeanCopy
除非是继承类之间,否则不建议使用BeanCopy。对象赋值可能会遗漏,问题排查也困难;
mapstruct自定义映射
对象Convert,使用mapstruct不检查属性不一致时的转换,如属性名和类型不一致等。
4.11.2 枚举空值转换
枚举类型定义为Integer,然后0也是其中的一个枚举项。
4.11.3 方法不能"见名知义"或者过于明细



5 监控与对账
5.1监控告警
(1)告警分层
按照核心与次核心进行分层,并分别通知(分不同的群以及不同的触达方式,如短信、电话、飞书等)。
5.2 对账系统的建设
6 机制完善
6.1 故障应急机制
故障应急机制:(1)专门的小组;(2)流程标准加日常演练;
6.2 发布流程
7 攻防演练
联合测试同学,定期对核心链路以及易资损点进行攻防演练,保持"自我检查"与"自我迭代"。
(1)检查监控是否有效;
(2)检验应急是否及时;