GarinZ / Blog

Blog in issue
29 stars 2 forks source link

如何切一个强一致性数据快照? #10

Open GarinZ opened 2 years ago

GarinZ commented 2 years ago

在日常工作中,可能经常会遇到切“数据快照”的需求。所谓“数据快照”是在某一个时间点将数据库中的某些表转储到另一个地方,就像对当时的数据拍了照片存起来。对于数据一致性要求不高的场景可以直接在凌晨的某个时间点执行一次类似select * from xx的转储任务就可以满足需求。但是如果遇到对数据一致性有严格要求场景应该怎么做呢?本文就“日结余额快照”这个具体问题介绍一下强一致性场景下数据快照任务的实现思路。

背景介绍

在每个广告投放系统中,都需要一个报表来为广告主按天级别展示花费、转入、转出和余额数据。其中余额这一项就依赖一个“数据快照“任务在每一天结束前对广告主余额做一个快照存储起来,作为当天的结束时候的余额,我们称它为“日结余额快照”。

我们开头提到日结余额快照有强一致性要求,那么什么是强一致性要求?那么就是在问“强在哪”和“跟谁一致”?

跟谁一致:我们既然说一致性,那就必须有另外一些数据需要和目标数据保持一致。在日结余额的计算中,需要和日结余额保持一致性的关联数据包括:花费、转入、转出。

强在哪:这几项数据需要在产出的数据快照中要满足以下一致性公式: 简单来说就是“前一天的日结余额 - 当天花的钱 + 当天转入的钱 - 当天转出的钱 = 当天日结余额”。只有满足了这个公式才能达到财务层面和平台报表的要求。

理解了强一致性“强在哪”和“跟谁一致”之后。我们需要从工程角度出发,考虑实现方式。

为了简化理解假设我们现在有三张表,均为MySQL存储,分别是:广告主余额表、广告主天级花费表、广告主转入/转出记录表。其中广告主天级花费表、广告主转入/转出记录表中均包含准确字段标识数据发生的日期因此不需要额外的数据快照,而广告主余额表存储的是广告主余额的实时状态。

如何计算日结余额

方案一: select *

我们先考虑最简单的方案,直接通过一个0点的定时任务执行类似select * from {广告主余额表}做查询并转储行不行?

在实际执行中,虽然定时任务指定0点,但真正执行到查询语句一定已经过了0点。此时的查询出的余额状态很可能已经受次日发生的花费、转入、转出影响,因此各项数据带入公式中也就无法满足一致性等式。因此这种方案不能满足强一致性需求。

方案二:冲减今日消耗和流水

既然方案一的问题是源于我们不能保证“次日0点查询的余额不被次日的发生额影响”,那么我们只要冲减掉次日的发生额就行了。于是可以总结出这样一个余额计算公式: 解释一下就是:前一天的日结余额 = 实时的余额 + 今天的消耗 - 今天的转入 + 今天的转出。抽象的公式已经确定了,查询的过程语句大概如下所示

public Map<Long, BalanceSnapshot> getBalanceSnapshot(Date targetDate) {
  Date date  = new DateTime(targetDate).withTimeAtStartOfDay().plusDays(1).toDate();
  // select * from {广告主余额}
  Map<Long, Balance> acctId2balance = balanceDao.queryForMap();
  // select * from {广告主花费} where date = {date}
  Map<Long, ChargedDaily> acctId2balanceList = chargedDailyDao.queryForMap(date);
  // select * from {广告主转入/转出} where date = {date}
  Map<Long, List<BalanceRecord>> acctId2balanceRecords = balanceRecordDao.queryForMap(date);
  // 根据上述公式做冲减
  Map<Long, BalanceSnapshot> acctId2balanceSnapshot = calculateBalanceSnapshot(acctId2balance, acctId2balanceList, acctId2balanceRecords)
  return acctId2balanceSnapshot;
}

这样有没有问题呢?虽然公式是对的,但在工程实现上这样写也是有问题的。由于余额、消耗、转入/转出不断在发生变化,而查询语句的执行有先后顺序,所以这样写也无法保证这4项数据的一致性。有没有一种方法能够在同一时间让涉及到的3张表时间暂停,让查询可以在3张表数据一致的情况下完成?有,那就需要借用MySQL内部的MVCC机制。

方案三:冲减今日消耗和流水 + RR事务

MVCC本质上为我们提供了在一个时间段内维护同一行的多个版本的功能,它的底层是基于undo log和Read View来实现的,undo log记录对行的修改操作和先后顺序,Read View决定了我们能看到哪个版本的行记录。我们既然期望在某个时间点开始让时间暂停,让对这3张表的变更不受影响,这其实和基于MVCC实现的”可重复读(Repeatable Read)“事务隔离级别的效果是一样的。在“可重复读”隔离级别下,事务开启时会创建一个Read View,创建后的写操作对于事务内的查询语句不可见,在事务结束时会销毁Read View。这样可以实现在事务开启后,对于查询语句三张表的数据版本会保持不变的,也就做到了强一致,就像时间静止了一样。

在明确了开“可重复读”可以解决问题后,我们对以上代码进行改写:

public Map<Long, BalanceSnapshot> getBalanceSnapshot(Date targetDate) {
  Date date  = new DateTime(targetDate).withTimeAtStartOfDay().plusDays(1).toDate();
  Map<Long, Balance> acctId2balance;
  Map<Long, ChargedDaily> acctId2balanceList;
  Map<Long, List<BalanceRecord>> acctId2balanceRecords;
  DataSourceTransactions.shardTrx(dataSource, shardId, jdbcTemplate -> {
    // select * from {广告主余额}
    acctId2balance = balanceDao.queryForMap();
    // select * from {广告主花费} where date = {date}
    acctId2balanceList = chargedDailyDao.queryForMap(date);
    // select * from {广告主转入/转出} where date = {date}
    acctId2balanceRecords = balanceRecordDao.queryForMap(date);
  });
  // 根据上述公式做冲减
  Map<Long, BalanceSnapshot> acctId2balanceSnapshot = calculateBalanceSnapshot(acctId2balance, acctId2balanceList, acctId2balanceRecords)
  return acctId2balanceSnapshot;
}

再优化一下,如果支持跨天重算呢?

和常规的服务一样,数据快照任务也需要考虑容错性。当AZ不可用、网络抖动、DB连接异常等问题碰巧出现时,数据快照任务也会失败。我们上述逻辑对于T+1内的重新执行是天然支持的,但如果异常超过一天或遇到上游刷数据需要我们重新计算指定日期的日结余额时怎么办呢?

为了能够让数据快照任务可以有更强的容错性,我们就引入考虑跨天重算的功能。

和之前一样,我们需要对余额计算公式重新整理,整理后的公式如下: 解释一下公式:目标日期的日结余额 = 实时的余额加上目标日期之后发生的花费和转出,再减掉目标日期发生之后的转入。

最终作用在代码上,我们也需要做一些修改:

public Map<Long, BalanceSnapshot> getBalanceSnapshot(Date targetDate) {
  Date date  = new DateTime(targetDate).withTimeAtStartOfDay().plusDays(1).toDate();
  Map<Long, Balance> acctId2balance;
  Map<Long, ChargedDaily> acctId2balanceList;
  Map<Long, List<BalanceRecord>> acctId2balanceRecords;
  DataSourceTransactions.shardTrx(dataSource, shardId, jdbcTemplate -> {
    // select * from {广告主余额}
    acctId2balance = balanceDao.queryForMap();
    // 修改为范围查询:select * from {广告主花费} where date >= {date} 
    acctId2balanceList = chargedDailyDao.queryForMap(date);
    // 修改为范围查询:select * from {广告主转入/转出} where date >= {date}
    acctId2balanceRecords = balanceRecordDao.queryForMap(date);
  });
  // 方法内根据新公式做冲减
  Map<Long, BalanceSnapshot> acctId2balanceSnapshot = calculateBalanceSnapshot(acctId2balance, acctId2balanceList, acctId2balanceRecords)
  return acctId2balanceSnapshot;
}

总结

到目前为止就做到了满足强一致性的日结余额计算。大家会发现实现计算逻辑的代码又简单又普通,不过我想着重分享的是从面对一个产品、到将产品问题转换为可计算问题,然后找到工程上合适的解决方案的过程。

最后我再对整个过程做一个总结:

  1. 明确需求:写出一个确定的“日结余额”的概念,保证理解上没有偏差
  2. 确定这是一个强一致性的数据场景,并确定在强一致性上的要求
    1. 强在哪:总结用于约束和验证一致性的公式
    2. 和谁一致:明确日结余额需要和消耗、转入、转出保持一致
  3. 确定计算快照的算法:写出抽象的快照计算公式
  4. 思考具体工程上的写法:选择”可重复读“隔离级别事务,必须在事务中执行需要保持一致的数据项的查询
  5. 再思考一下容错性:根据业务场景,增加可跨天重算的功能
  6. 写出代码

希望可以借这个具体问题能为大家提供解决这类问题的思路,其实想要实现强一致性也并不总是那么难。