关于对账的一些理解


#软件架构与思考#


1、对账的目的

在会计中,对账的目的是为了保证账簿记录的真实、准确,主要是为了保证资金的正确可靠。

在财务、支付系统中都会引入对账系统。

举个简单的例子:

小明爸爸发现银行卡中有一笔1000元的消费,小明爸爸苦思冥想觉得自己并没有花1000元买东西。经过排查,发现是二字小明将银行卡偷偷绑定到了小明自己的微信上,花钱买游戏装备了,于是小明被打了一顿~ 从此以后,再也没有不知道怎么花出去的钱了。

小明爸爸是如何发现资金问题的?通过对账,即小明爸爸记忆中的花费与查询到的银行卡的账单记录之间进行比对。

2、对账的原因和作用

这里用一个更具体的开发案例来解释下对账的必要性。

2.1、一个扣款系统的示例

这里先举个例子,供下文使用。

【系统A】调用【系统B】对用户进行扣款操作。

【系统A】用 MySQL 表 table_a 记录扣款流水,字段如下:

字段 说明
user_id 用户标识
amount 扣款金额
deduct_sn 扣款单号,用一个全局唯一ID生成系统生成,要求全局唯一,会传到系统B。
status 状态。1初始化,2扣款成功,3扣款失败
create_time 创建时间
update_time 更新时间

【系统B】用MySQL 表 table_b 存储扣款流水。字段设计恰巧和 table_a 一致。

对于【系统A】的构建者而言,能保证系统 95% 无逻辑问题。也就是 5%的可能性有隐式逻辑问题

显式逻辑问题,比如【系统A】一运行就报错;

隐式逻辑问题, 比如【系统A】运行时不报错。但是预期是对用户1扣款,结果调用【系统B】时,变成对用户2扣款,扣款正常,但是扣错人了。

【系统A】如何提升业务正确性?

2.2、引入对账后,将发生什么?

引入【对账系统】后,【对账系统】对【系统A】和【系统B】的数据进行核对。对于【对账系统】的构建者而言,能保证该系统 95% 无隐式逻辑问题,就是存在 5% 的可能性有逻辑问题。

我们计算下各种概率:

系统A是否有隐式逻辑问题 对账系统是否有隐式逻辑问题 概率
情况1 5%✖️5% = 0.25% 。
情况2 95%✖️95% = 90.25% 。
情况3 5%✖️95% = 4.75%
情况4 95%✖️5% = 4.75%

情况1、2 中,我们看到的是系统稳定运行,但不知道有没有问题。

情况3、4 中,对账系统会报错,我们要检查是【系统A】出了问题,还是【对账系统】出了问题。通过问题定位和修正,我们将每个系统出现隐式逻辑问题的概率从 5% 降到更低,例如降低到了 3% 。

2.3、对账能发现什么问题?

对于扣款系统能发现下面的异常。

2.3.1、系统间异常

  • 【系统A】有扣款成功流水,【系统B】没有对应的扣款成功流水。
  • 【系统B】有扣款成功流水,【系统A】没有对应的扣款成功流水。

2.3.1、系统内异常

  • deduct_sn 出现重复。

3、对账的使用场景

对账的本质是通过引入一个【旁观者】,提升当前流程、业务的正确性。

既然是为了【提升当前流程、业务的正确性】,那么财务、支付外的系统,也应该尽可能的引入对账系统。但此时处理的数据与钱无关,也就不是【账】。此时,可以称之为核对数据核对,或者说广义的对账

在财务系统中,引入对账系统,会尽可能保证钱没有问题。

在券系统中,引入对账系统,会尽可能保证券没有多发,或者没有少发,或者没有一券扣多次,或者没有券未扣钱。

甚至,在个人发布文章时,通过引入一个旁观者帮忙review,可以尽可能的保证文章的一些数据、引用等内容的正确性。

4、对账的实时性

系统可以通过事前、事中、事后3个阶段来增强系统正确性,其中对账属于【事后】。

以【扣款系统】为例,可以分3个阶段保障系统正确性,每个阶段的保障方式举例如下:

  • 事前:
    • 代码 review
    • 单元测试
    • 测试环境测试
    • 线上灰度验证
  • 事中:
    • 数据库唯一索引约束
    • 【系统B】收到扣款请求后,将金额和用户ID与反查【系统A】该扣款单对应的金额和用户ID比对。不一致,则拒绝扣款。(注意,这个会导致系统循环依赖,个人一般不建议使用)
  • 事后:
    • 对账。

对账在大部分场景中是事后行为。

根据实时性可分为:

  • 准实时对账。比如业务发生后,5分钟内完成对账。
  • 离线对账。比如每天进行一次全量对账。

5、对账的实现

还是以【系统A】调用【系统B】对用户进行扣款操作为例,两个系统都有【扣款流水】。扣款单号是全局唯一的,两个系统都会记录。

5.1、离线对账

假设是每日核对,

将两个系统的 DB(最好是从库)数据导入 HIVE 中,每天导入一次,写一个 FULL JOIN 类型的 HQL (类似SQL)核对今天零点之前的数据。比对两边相同单号的成功状态的记录,关键信息是否一致。比如用户ID、金额。

另外,要考虑跨天问题。比如【系统A】中的某条扣款记录创建时间是 23:59:59,【系统B】是第二天的 00:00:01 创建。这种情况数据可能对不上,但可以先忽略。

假如今天日期是 2020-01-30

5.1.1、全量对账代码示例:

SELECT concat('部分数据对账有问题')
FROM (
    select * from table_a
    where create_time < '2020-01-30 00:00:00'
          and status = 2
) as A
full join
(
    select * from table_b
    where create_time < '2020-01-30 00:00:00'
          and status = 2
) as B
on A.deduct_sn = B.deduct_sn
where
-- A有,B没有,且A创建时间不在跨天的零点左右
(
    A.deduct_sn is not null
    and
    B.deduct_sn is null
    and 
    A.create_time < '2020-01-29 23:55:00'
)
or
-- B有,A没有,且B创建时间不在跨天的零点左右
(
    B.deduct_sn is not null
    and
    A.deduct_sn is null
    and 
    B.create_time < '2020-01-29 23:55:00'
)
or
-- A、B都有,但是关键信息对不上
(
    B.deduct_sn is not null
    and
    A.deduct_sn is not null
    and 
    A.user_id != B.user_id
    and
    A.amount != B.amount
)

5.1.2、增量对账代码示例:

-- 每日对账一次,为了防止遗漏,一次对两天的数据

SELECT concat('部分数据对账有问题')
FROM (
    select * from table_a
    where create_time < '2020-01-30 00:00:00'
          and create_time > '2020-01-28 00:00:00'
          and status = 2
) as A
full join
(
    select * from table_b
    where create_time < '2020-01-30 00:00:00'
          and create_time > '2020-01-28 00:00:00'
          and status = 2
) as B
on A.deduct_sn = B.deduct_sn
where
-- A有,B没有,且A创建时间不在跨天的零点左右
(
    A.deduct_sn is not null
    and
    B.deduct_sn is null
    and 
    A.create_time < '2020-01-29 23:55:00'
    and 
    A.create_time > '2020-01-28 00:05:00'
)
or
-- B有,A没有,且B创建时间不在跨天的零点左右
(
    B.deduct_sn is not null
    and
    A.deduct_sn is null
    and 
    B.create_time < '2020-01-29 23:55:00'
    and 
    B.create_time > '2020-01-28 00:05:00'
)
or
-- A、B都有,但是关键信息对不上
(
    B.deduct_sn is not null
    and
    A.deduct_sn is not null
    and 
    A.user_id != B.user_id
    and
    A.amount != B.amount
)

5.2、准实时对账

【分钟级】的对账可以称作【准实时对账】。

对账的本质思路是一样的。不一样的是,每次对的是最近几分钟的发生业务。

实现上,可以写一个定时任务,没3分钟跑一次最近6分钟的业务数据对账。

数据源最好是DB从库。

6、关于系统内数据核对

如果【系统A】中,扣款记录基于用户ID分库分表。因为要求扣款单号 deduct_sn 全局唯一,所以单表中最好对 deduct_sn 加上唯一索引。但是这只保证了单表中全局唯一,无法保证所有的表放在一起后全局唯一。

此时,关于唯一性问题,我们两个选择:

  • 选择1:完全相信扣款单号生成系统。
  • 选择2:不完全相信扣款单号生成系统。

对于选择2,我们可以加一个数据的离线核对,核对SQL如下:

select concat('deduct_sn 出现重复: ', deduct_sn)
from table_a 
group by deduct_sn having count(deduct_sn) > 1

7、如何处理对账异常数据

总的原则是,先将异常数据记录下来,找到原因并处理好后,将结论和处理流程记录下来。

留一个问题,在分库分表的场景下,deduct_sn 如何在【事中】保证全局唯一?


( 本文完 )