对账设计实现

一、分析设计

1.分析

首先我们得知道对账是在做什么?无非就是看同一笔交易数据,双方是否都存在,双方都存在的情况下再比较交易状态、交易金额是否一致。是的,对账就是那么简单,当然还有各种汇总需求,此处我们不做讨论,因为汇总是紧跟系统业务的,咱们只讨论数据比对核心部分。如何设计一个好的对账系统并不容易,对于交易类型多,交易量大的支付系统来说更不容易了。不仅要保证准确性,也要保证效率,一般只有在渠道对账完成后,才去给商户生成对账文件,商户对账依赖渠道对账,即我们的目标是快速、准确!

不同的支付公司有不同的对账方式,下面我们大概了解下常见的对账方案。

基于sql的,这种在我看来应该算是最低级的了吧,也就是将通道侧的数据入到一个临时表中,和自己平台的交易表进行连表操作,这种在交易量小的情况下还是可以用下的,当日交易和表交易存量很大的时候,你也不知道你的sql需要执行多久才能执行出结果了,这个自己很难把控了。

基于Redis的,利用Redis的Set类型的差集功能得出差异数据,使用Redis提供的并集/交集/差集等指令完成两个集合的数据比对,这个笔者暂时没有研究过,网上博客介绍此方法的还挺多,有兴趣的可以研究下,暂且不说效率,至少得费一套Redis集群。

 

基于应用进程内存的,利用Map数据结构进行数据比对,我们接下来主要讨论这个,这个是笔者在所做的几个支付系统里实际验证过的方式,完全可以扛起百万级别数据比对,中小型支付系统完全够用了,日交易量能达到千万级别的公司,应该确实不多吧。

基于大数据技术的,这个确实没有了解过,不过也是进行数据分片处理吧,这篇咱们是探究百万级别的,百万级别也用不到大数据相关技术,毕竟我们是普通Java开发攻城狮。

2.设计

c6ee7d96c9274283879feedda2730aa6.png

2.1 整体执行流程

从上图中可以看到整体执行流程,是由两个定时任务触发的。

第一个定时任务用于生成对账任务,生成对账任务的元数据信息每个公司支付系统设计各异,具体问题具体分析吧,这里不在展开。

第二个定时任务用于对账任务的分发工作,查询表中需要对账的对账任务,将任务投递到MQ队列中,消费节点消费到任务后完成对账流程。

2.2 相关表

7ea11b76869b482cb3ec5c72ef7fa419.png

涉及到的三张表:recon_task、recon_trans、recon_diff

recon_task:对账任务表,每日凌晨,第一个对账任务触发,根据对接的渠道信息,生成对应的对账任务。
recon_trans:渠道交易数据临时表,用于存放渠道侧的交易数据。
recon_diff:差错表,用于存储比对出的差异数据。

2.3 相关处理器

涉及到的三个处理器:DataFetchReconTaskProcessor、DataCompareReconTaskProcessor、DiffDataDealReconTaskProcessor

DataFetchReconTaskProcessor:数据获取处理器,处理对账任务状态为INIT、DATA_FETCH_FAILED的任务;将对账文件数据解析入临时表。
DataCompareReconTaskProcessor:数据比对处理器,处理对账任务状态为DATA_FETCHED、FAILED_RECON的任务;将交易表和临时表数据查询出来构建数据比对模型进行数据比对,并将差错结果存入差错表。
DiffDataDealReconTaskProcessor:差错处理处理器,处理对账任务状态为DONE_RECON、DIFF_DEAL_FAILED的任务;根据公司实际业务处理。

消费节点获取到任务后判断需要哪个处理器进行处理。

    @RabbitListener(queues = MqConstant.Q_RECON_TASK_DISPATCH, containerFactory = "rabbitListenerContainerFactory")public void execute(ReconTask reconTask, Channel channel, Message messageSource) {logger.info("[机构({})对账码({})状态({})对账任务号({})] 开始对账任务处理", reconTask.getInstCode(), reconTask.getTransCode(), reconTask.getProcessStatus(), reconTask.getReconTaskNo());try {for (IReconTaskProcessor processor : processors) {if (processor.isSupport(reconTask.getProcessStatus())) {try {processor.process(reconTask);} catch (Exception e) {logger.error("对账任务处理异常", e);break;}}}} finally {try {channel.basicAck(messageSource.getMessageProperties().getDeliveryTag(), false);} catch (IOException e) {LoggerUtil.error(logger, "MQ消息监听-消息ACK-异常", e);}}logger.info("[对账任务号({})] 对账任务处理结束", reconTask.getReconTaskNo());}

下面我们来分析设计这个三个处理器具体都是做什么的。

2.3.1 DataFetchReconTaskProcessor

此处理器负责数据的获取工作,对接的支付机构以不同形式将前一天/小时的交易数据提供给接入系统,一般通过文件形式方式。但是每个支付机构提供的文件格式各异,所以要想做一个兼容各种形式、格式的通用的对账平台,还是挺不容易的吧,如何兼容不同形式、格式的处理方式有很多,使用脚本语言个人觉得是个比较好的处理方式,思路可借鉴支付网关设计-1,使用Groovy脚本方式,只需要编写个脚本类,每个支付渠道有自己特定的解析脚本,将标准流程下的不同处使用脚本处理,动态加载执行脚本,保证我们的标准化流程,此处就不在展开了。
此处就拿笔者近期为一个支付系统做的对账系统展开将吧,交易量还是可以的,此交易系统对接了4个支付渠道,并且渠道对账文件渠道根据公司要求生成了统一格式的对账文件,所以省却了很大工作量,拿到文件后只管解析就好了,不需要关注格式问题了。为了加快解析速度使用SpringBatch批处理框架,在入表时候使用Mybatis ExecutorType.BATCH模式,实际运行效果(110W笔交易,文件下载+解析入临时表)耗时60s左右。

2.3.2 DataCompareReconTaskProcessor

此处理器主要负责数据的比对,也是我们最重要的处理器了。
核心流程:
–>查询交易表数据/临时表数据
–>构建比较模型放入内存
–>数据比对
–>差错入表

1.查询交易表数据/临时表数据

第一部分,我们需要将交易表、临时表数据查询出来,因为对账文件给的一笔交易字段非常多,所以我们在第一个处理器中将文件解析入表,此处理器又将从表里查询出来我们所需的字段,没办法,文件数据全部放入内存的话将导致内存吃紧,所以多了一步看似脱裤子放屁的步骤,实际还是很有必要的,我们将整个流程划分为三个处理器处理,一方面使流程清晰,更重要的是一个任务在某个执行器流程出问题后,再次执行时候直接从失败的执行器执行就行了,不用从开始执行。
在查询交易表和临时表使用了不同的分片策略。交易表主键非自增,所以使用了交易完成时间作为分片策略(要在此字段创建索引),一个sql查询固定时间片的数据。

此片查询startTime:20221221224000,endTime:20221221225000,读出数据量:4137,耗时:858ms
此片查询startTime:20221221233000,endTime:20221221234000,读出数据量:2677,耗时:649ms
此片查询startTime:20221221235000,endTime:20221221235959,读出数据量:1603,耗时:300ms
此片查询startTime:20221221223000,endTime:20221221224000,读出数据量:3727,耗时:913ms
此片查询startTime:20221221234000,endTime:20221221235000,读出数据量:2325,耗时:476ms
此片查询startTime:20221221005000,endTime:20221221010000,读出数据量:2910,耗时:376ms
此片查询startTime:20221221000000,endTime:20221221001000,读出数据量:3573,耗时:710ms

此方法没有控制每个时间片交易量,可以优化为将一天的时间二分法根据控制的时间片交易量进行分片,没必要根据固定时间分片,固定时间分片无法控制每个时间片的交易量。

临时表自增主键,根据主键ID进行分片,可以准确的控制每个分片的交易量,所以首先要对前一天的交易进行分片,分片核心SQL如下:

--1.统计出待删除数据总量
SELECT count(1) FROM TEMP_RECON_TABLE where RECON_TASK_NO =? 
--2.计算出此片最小ID
select Min(ID) from (select ID from TEMP_RECON_TABLE where RECON_TASK_NO =? and ID > ? order by ID) where rownum<= ? 
--3.计算出此片最大ID
select Max(ID) from (select ID from TEMP_RECON_TABLE where RECON_TASK_NO =? and ID > ? order by ID) where rownum<= ? 

分片核心代码

/*** @author kkk* @Description: 默认数据分片根据id进行分片*/
@Component
public class IdPartitioner implements Partitioner {private Logger logger = LoggerFactory.getLogger(IdPartitioner.class);@Overridepublic List<BatchTask> partition(BatchJob job) {List<BatchTask> tasks = new ArrayList<>();int sum;int slice = job.getSliceSize();sum = job.getRepository().selectBatch(job);logger.info("待分片数据量sum:{}",sum);long indexId = 0;for (int i = 0; i * slice < sum; i++) {int curSlice = (i + 1) * slice <= sum ? slice : sum % slice;BatchTask task = new BatchTask();//task任务分片计算task.setCurSlice(curSlice);task.setReconTaskNo(job.getReconTaskNo());long beginId = job.getRepository().selectByClause2Id(task, "beginId", indexId);long endId = job.getRepository().selectByClause2Id(task, "endId", indexId);indexId = endId;task.setRepository(job.getRepository());task.setStartId(beginId);task.setEndId(endId);//task任务集合tasks.add(task);logger.info("第{}片完成,beginId({}),endId({}),此片数据量:{}", i + 1, beginId, endId, curSlice);}return tasks;}
}@Overridepublic long selectByClause2Id(BatchTask task, String id, long indexId) {//定义临时辨别值String endId = "endId";String beginId = "beginId";Long idNo=0L;if (id.equals(endId)) {idNo=reconMapper.queryMaxIdLimitInfo(task.getReconTaskNo(),indexId,task.getCurSlice());} else if (id.equals(beginId)) {idNo=reconMapper.queryMinIdLimitInfo(task.getReconTaskNo(),indexId,task.getCurSlice());}logger.info("执行({})查询id结果idNo:{}", id,idNo);return idNo;}

分片执行结果:

查询数据开始分片…
待分片数据量sum:1168963
.
执行(beginId)查询id结果idNo:101218001
执行(endId)查询id结果idNo:101228000
第1片完成,beginId(101218001),endId(101228000),此片数量:10000
.
执行(beginId)查询id结果idNo:101228001
执行(endId)查询id结果idNo:101238000
第2片完成,beginId(101228001),endId(101238000),此片数量:10000
. . .
执行(beginId)查询id结果idNo:102378001
执行(endId)查询id结果idNo:102386963
第117片完成,beginId(102378001),endId(102386963),此片据量:8963

2. 构建比较模型

基于应用进程内存的方式,首先我们确定使用Map来进行数据比对,那么Map的Key、Value如何定义?同时值要尽量的小,我就直接说怎么定义吧,Key使用请求通道订单号,这个可以随意,但是要保证通道侧给的对账文件里和自己支付表中都要有的字段,当然也可以是若干个字段组合,目的是能唯一确定一笔交易!那么Value该怎么定义?我们定义CompareModel对象,CompareModel对象中属性定义如下:

/*** @author kkk* @Description: 比对实体*/
public class CompareModel extends Serializable {/** 唯一索引*/private String uniqueIndex;/** 值*/private String value;/** 主键*/private Long id;public CompareModel() {}public CompareModel(String uniqueIndex, String value) {this.uniqueIndex = uniqueIndex;this.value = value;}@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;CompareModel that = (CompareModel) o;if (uniqueIndex != null ? !uniqueIndex.equals(that.uniqueIndex) : that.uniqueIndex != null) return false;return value != null ? value.equals(that.value) : that.value == null;}@Overridepublic int hashCode() {int result = uniqueIndex != null ? uniqueIndex.hashCode() : 0;result = 31 * result + (value != null ? value.hashCode() : 0);return result;}
}

下面我们来简单介绍下,上面实体类中三个字段的用意:

uniqueIndex:交易唯一索引,同Map中的Key。
value:就是我们同一笔订单要比对的值,上面我们说过了,对账无非就是看同一笔订单在双方平台是否都存在,存在的话,那么再看交易状态和交易金额是否一致,所以我们将value值定义为:交易状态/交易金额的组合,如:SUCCESS_10。
id:交易表/临时表主键,用于差异数据原交易数据的查找,这句话可能有点绕,也就是对查差异来了,入差异表时候,我们要知道这笔差异的详情信息。

Map的数据格式为:Map<String,CompareModel>
map.put(compareModel.getUniqueIndex(), compareModel);
查询出的数据格式:

uniqueIndex, value, id
2022206580909023224135688, 1+9950, 1
2022010658090924388515840, 1+1000, 2
2022010658090915596992512, 1+2000, 3
2022000658090902112960512, 1+2100, 4
2022000658090909037232128, 1+9950, 5
2022010658090916209491968, 1+2176, 6

3. 数据比对

定义完数据比较实体,那么再定义数据比较器接口:

/*** @author kkk* @Description: 对账-比较器*/
public interface IComparator {IComparator putOrigins(List<CompareModel> origins);IComparator putTargets(List<CompareModel> targets);CompareResult compare();
}

基于本地内存方式的接口实现:

/*** @author kkk* @Description: 基于内存的比对实现*/
public class LocalCacheComparator implements IComparator{private static Logger logger = LoggerFactory.getLogger(LocalCacheComparator.class);//用于存储平台交易数据private Map<String, CompareModel> originMap = null;//用于存储渠道交易数据private Map<String, CompareModel> targetMap = null;/*** 存储原始交易数据*/@Overridepublic LocalCacheComparator putOrigins(List<CompareModel> origins) {if (originMap == null) {originMap = convert2Map(origins);}else {originMap.putAll(convert2Map(origins));}return this;}/*** 存储目标交易数据*/@Overridepublic LocalCacheComparator putTargets(List<CompareModel> targets) {if (targetMap == null) {targetMap = convert2Map(targets);}else {targetMap.putAll(convert2Map(targets));}return this;}/*** List-->Map*/private static Map<String, CompareModel> convert2Map(List<CompareModel> compareModels) {Map<String, CompareModel> map = new HashMap<>(Math.max((int) (compareModels.size() / 0.75f) + 1, 16));for (CompareModel compareModel : compareModels) {map.put(compareModel.getUniqueIndex(), compareModel);}return map;}/*** 数据比对*/public CompareResult compare() {if (originMap == null || targetMap == null) {throw new RuntimeException("compare交易数据或对账数据为空,无法比对");}CompareResult result = new CompareResult();Iterator<String> iterator = originMap.keySet().iterator();for (; iterator.hasNext();) {String originKey = iterator.next();CompareModel origin = originMap.get(originKey);if (targetMap.containsKey(originKey)) {CompareModel target = targetMap.get(originKey);if (!StringUtils.equals(origin.getValue(), target.getValue())) {result.addDiff(origin, target);}targetMap.remove(originKey);}else {result.addOriginAndNotTarget(origin);}}result.addTargetAndNotOrigins(targetMap.values());return result;}
}/*** @author kkk* @Description: 对账比对结果存储器*/
public class CompareResult {//用于存储平台有、支付渠道无的交易List<CompareModel> originsAndNotTargets = new ArrayList<>();//用于存储平台无、支付渠道有的交易List<CompareModel> targetAndNotOrigins = new ArrayList<>();//用于存储平台、支付渠道差异的交易数据Map<CompareModel, CompareModel> diffs = new HashMap<>();....
}

从基于本地内存的数据比对接口实现我们可以看到核心比对方法 compare(),即比对两个map,同时将比对过程产生的差异存入CompareResult对象集合中,

从上代码可以看出将两边数据各存入Map中,进行遍历比对,比对出三种差异:
originsAndNotTargets(平台多) 、targetAndNotOrigins(渠道多) 、diffs(状态/金额不一致) ,经过这轮比对后还需要再处理一轮,因为渠道一般只是给交易状态成功的交易,所以上面比对的时候我们也只是取了我们平台交易成功状态的交易数据,经过上面比对我们不确定 targetAndNotOrigins 渠道多数据我们平台是真的没有,还是交易状态为非成功状态,所以这时候需要将渠道侧多的数据集合查询我么交易表,此步数据已经很少了,所以也基本没几条数据到这步,所耗费性能微乎其微了,毕竟一个好的支付系统对账基本也对不出什么差异的。

        // 处理渠道存在我们不存在的数据:防止“渠道-(成功) <--> 平台-(失败|处理中)”的场景覆盖不了List<CompareModel> targetAndNotOrigins = result.getTargetAndNotOrigins();if (targetAndNotOrigins.size() > 0) {List<String> uniqueIndexList = Lists.transform(targetAndNotOrigins, new Function<CompareModel, String>() {@Overridepublic String apply(CompareModel input) {return input.getUniqueIndex();}});// 根据“渠道存在我们不存在”的关键字UniqueIndex搜索数据库,检查是否存在我们为失败|处理中的交易记录,如存在,则此差异需要转移到DIFF“渠道与平台不一致”中params.put("transStatus", getFailAndProcess());List<CompareModel> originFails = compareModelRepository.queryCompareModelWithCustomFieldsAndUniqueIndex(uniqueIndexFields, valueFields, uniqueIndexList, params, ReconCodeMapping.getByCode(reconTask.getTransCode()).getModelClazz());if (CollectionUtils.isNotEmpty(originFails)) {comparator = new LocalCacheComparator();comparator.putOrigins(originFails);comparator.putTargets(targetAndNotOrigins);CompareResult result1 = comparator.compare();result.getTargetAndNotOrigins().removeAll(result1.getDiffs().values());result.getDiffs().putAll(result1.getDiffs());}}

注意更严谨的话需要对如上List targetAndNotOrigins 集合进行分片,如果系统交易出问题,真的有个几万比渠道成功,平台处理中|失败,这里反查表不进行分片的话会出问题的。

4. 差错入表

    protected void buildAndSaveReconDiffs(ReconTask reconTask, CompareResult compareResult, ReconDataStorage<ReconDiff> storage) {BaseRepository originRepository = ReconCodeMapping.getByCode(reconTask.getTransCode()).getRepository();List<CompareModel> originsAndNotTargets = compareResult.getOriginsAndNotTargets();List<CompareModel> targetAndNotOrigins = compareResult.getTargetAndNotOrigins();Map<CompareModel, CompareModel> diffMap = compareResult.getDiffs();LoggerUtil.info(logger, "[对账任务({})]  对账数据完整补全开始", reconTask.getReconTaskNo());for (CompareModel compareModel : originsAndNotTargets) {Object originData = originRepository.findOne(compareModel.getTransId());ReconDiff diff = new ReconDiff();//...storage.saveAsyn(diff);}for (CompareModel compareModel : targetAndNotOrigins) {ReconTrans reconTrans = reconTransRepository.findOne(compareModel.getTransId());ReconDiff diff = new ReconDiff();//...storage.saveAsyn(diff);}Iterator<CompareModel> iterator = diffMap.keySet().iterator();for (; iterator.hasNext(); ) {CompareModel origin = iterator.next();CompareModel target = diffMap.get(origin);ReconDiff diff = new ReconDiff();Object originData = originRepository.findOne(origin.getTransId());//...ReconTrans reconTrans = reconTransRepository.findOne(target.getTransId());//...storage.saveAsyn(diff);}LoggerUtil.info(logger, "[对账任务({})]  对账数据完整补全及异步入库结束", reconTask.getReconTaskNo());}

核心代码如上了,核心逻辑即将我们比对出的差异根据我们比较模型中的Id查询交易表/临时表填充详细数据入差错表。
如上我们就完成了我们的数据比对了,整体实现思路还是挺简单的,但是写起来还是有点费劲的,时刻要想到我么的一条SQL会涉及到多少条数据,比如临时表数据的清理不能一条SQL delete掉,数据比对完成后交易表一般会有个对账状态字段,需要将前一天的数据对账状态置为已对账时候也不能一条SQL update,还有差异日、日切非整点的,一大堆头疼的问题。

2.3.3 DiffDataDealReconTaskProcessor

此步骤省略了,没啥说的,就是有的公司要做自动差错处理(将渠道状态/金额覆盖平台状态/金额),或者差错推送出去等,具体问题具体分析吧。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值