摘要

直播业务的用户余额表(星币星豆)为亿级的数据量,营收日均订单量为千万级别,随着直播业务的发展,余额表单表架构已经成为消费模块的核心瓶颈之一。在2018年初已经发生多次慢查导致的业务故障,我们迫切需要对用户余额表进行分表,减轻数据库的压力和提升系统性能。本文提出一种平滑分表的方案,不停服、支持灰度和数据回滚。该方案不影响线上业务并且能够极大地降低数据迁移的风险。

一、现状&问题

消费服务是直播营收业务的核心基础服务,封装了用户星币星豆的所有基础行为操作。用户余额表是一个亿级的大表,单表已经无法满足日益增长的业务需求。

graph LR
C(客户端)-->S1(业务后端)
S1-->S2(消费服务)
S2-->S3((DB))

慢查会直接影响到上游的业务后端和消费后的下游业务。具体表现为:

分表解决的问题/目标:

二、国内外研究现状

业界分表的主流解决方案主要有下面几种:

2.1 停服迁移

简单粗暴的数据迁移方式,主要流程如下:

这种方式的优点是简单,并且不需要担心并发带来的数据不一致的场景。然而缺点也很明显,停服的时间长,停服的时间取决于数据迁移的时间和功能验收时间。如果表的数据量比较大(亿级),停服的时间甚至需要好几个小时。营收业务的停服意味着公司的直接损失,因此在一些比较大的互联网公司这种方式并不可取。

2.2 双写迁移

双写迁移把写入新表和旧表的操作绑定在同一个事务,从而保证新表的旧表的数据一致性。在数据迁移完成后把读流量迁移到新表。

图-双写迁移

主要流程如下:

优点:

缺点:

2.3 存量数据+增量数据迁移

通过某一个时间点T1的快照数据(存量数据)+增量数据可以得到任意时间点T2(T2>T1)的数据集合。

这里的快照数据我们可以通过mysql dump的方式得到,增量数据可以通过binlog的监听工具canal实现。

主要流程如下:

优点:

缺点:

三、亮点

本文的亮点在于提出了一种无需停服、支持灰度、且对线上业务影响较小的分表方案。

四、方案设计

分表方案主要分为5部分

4.1 数据裁剪

我们先思考一个问题,余额表的用户数据都是必要存在的吗?

付费用户定义:

余额(星币/星豆)不为0   有过付费行为   曾经收到星豆/星币 的用户
select * from t_user where coin!=0 or money!=0 or moneySpend!=0 or coinSpend!=0 or coinTotal!=0 or czTotal!=0 or bean!=0 or beanTotal!=0;

余额表关注的用户数据是付费用户,已注册但没有付费行为的用户我们不需要关注。付费用户占总体用户占比约12%,因此我们数据迁移的数据量由亿级下降到千万级别,大大缩短我们数据迁移的时长。

4.2 热点数据迁移

用户的写流量触发用户的余额迁移,这里有几个关键的技术点

在开始迁移前先介绍一下几张核心的表。

用户余额表(分表前)

用户余额表(分表后)

用户余额快照表。用户余额从t_user迁移到t_user_money会写入快照表。快照表的作用主要有两个,数据异常回滚和标识当前用户是否已经迁移成功。

4.1.1 初始状态

新建t_user_money表,这时候t_user_money表没有任何数据。

graph LR
L((读写流量))
L-->|更新操作|t_user
subgraph d_fanxing
t_user
t_user_money-空集合
snapshot-空集合
end
4.1.2 写流量开关控制

用户的写流量会触发用户的余额迁移。迁移完成后对这个用户所有的余额更新的操作都会写到t_user_money。

graph LR
L((写流量))
C1{snapshot是否存在?}
C2{是否灰度用户?}
C3{迁移成功?}
L-->A(抢锁)
A-->C1
C1-->|否|C2
C2-->|是|M(用户余额迁移)
C1-->|是|t_user_money
C2-->|否|t_user
M-->C3
C3-->|迁移成功,更新t_user_money|t_user_money
C3-->|迁移失败,更新t_user|t_user
subgraph d_fanxing
t_user
t_user_money
end
t_user-->R(释放锁)
t_user_money-->R

整体流程如上图所示:

由于灰度后的用户只需要增加一个查询(snapshot),可以通过缓存的方式最大程度的减少对线上服务性能影响。

随着灰度比例的增加,最终所有的热点用户都会迁移到新表(t_user_money)。

4.1.3 余额迁移流程

余额迁移的结果直接影响到用户的写流量最终会写入哪张表,如果迁移的结果不准确会导致用户的余额错乱,因此我们必须保证迁移结果的准确性。迁移流程需要满足以下几个要求:

余额迁移流程:

graph TD
开始-->C1{snapshot是否存在?}
C1-->|否|L(加锁,db行锁)
L-->B(t_user数据覆盖到t_user_money)
B-->C(写入snapshot)
C-->C2{迁移成功?}
C2-->|失败|D(查询snapshot,二次确认)
D-->R
C2-->|成功|R(释放锁)
C1-->|是,返回迁移成功|结束
R-->结束
subgraph 子事务,事务传递等级NEST,回滚不会影响到外层事务
L
B
C
R
C2
D
end
4.1.4 锁选型

由于用户余额迁移需要保证迁移过程中t_user表的数据不被其它线程/进程修改,我们需要分布式锁来确保我们数据的一致性。

乐观锁

缓存中间件的实现方式(redis/memcache):redis无法严格地保证锁只会被一个线程获取。

数据库乐观锁

t_user缺少一个版本号的字段,而且由于数据量大,无法直接对线上表做加字段等操作。

悲观锁

select for update实现方式,会阻塞其他等待的用户线程,严格保证迁移过程中数据不会被更新。对于每个用户只会迁移一次,因此对接口性能的损耗可以忽略。

最终我们考虑使用db行锁的方式来解决。

4.3 冷数据迁移

热点数据迁移灰度100%之后,t_user表不会再进行更新,这时候t_user表就是一个冷数据。我们扫描完整个t_user表就能完成整个迁移过程。

graph LR
L((写流量))
L-.-|X|t_user
L-->|w|t_user_money
subgraph d_fanxing
t_user
t_user_money
end
t_user-->|拉|迁移进程
迁移进程-->|迁移|t_user_money

同样,在写t_user_money的过程中需要加锁。数据迁移的逻辑跟写流量迁移的逻辑一致。这里可以为了提高迁移效率可以多个定时任务分片处理。

graph TD
开始-->E(按用户ID升序扫描n个用户)
E-->R(数据迁移)
R-->C2{扫描结束?}
C2-->|是|结束
C2-->|否|E

冷数据迁移有几个关键点:

数据分片。根据用户ID分片迁移

业务闲时迁移。

分布式作业调度系统saturn可以很好地解决这两个问题。

4.4 数据一致性校验

本方案的一个亮点在于实现了余额表的准实时一致性校验。

对于非活跃用户(在迁移过程中没有产生付费行为),由于不存在并发,我们认为数据的准确性在于数据拷贝代码逻辑的正确性,这块我们可以通过单元测试来保证,不需要另外通过程序进行验证,因此数据校验的核心在于对活跃用户的数据校验

我们利用期初期末统计的方式对余额表的正确性做校验。

期初期末的思路:余额表期初+流水的变更=余额表的期末

通过把等式左右的内容变更一下,可以得到 流水的变更=余额表的期末-余额表期初,等价于 余额表变更=流水表的变更

graph LR
subgraph d_fanxing
t_user
t_user_money
流水表
end
t_user-->|binlog|canal
t_user_money-->|binlog|canal
流水表-->|binlog|canal
canal-->S(余额表变更==流水表变更?)
S-->O(输出异常用户+告警)

图-数据校验说明

4.5 数据异常回滚

正常情况下,数据一致性校验能够把数据迁移过程中的异常场景尽早地发现,我们希望能够把故障控制在测试验收和白名单验收的阶段。一旦数据迁移后出现数据错乱/异常的情况下,我们需要一个及时止损的方案。

我们通过流水和余额快照把用户的余额恢复。

graph LR
Lock(用户封禁)-->S
Lock-->L
S(snapshot)-->C(计算用户余额)
L(用户流水)-->C
subgraph d_fanxing
S
L
end
C-->R(修复t_user数据)
R-->用户解封

由于修复需要通过快照+回放用户的流水实现,灰度的时间越长,回滚所需要的时间越长。

五、代码设计

image-20191121181627114

图1 UML图

我们把所有涉及到余额的表的操作统一抽象到IUserManager这个接口,这个接口有3个实现类。

封装所有的涉及到余额表操作的接口

封装用户余额表的所有操作

封装分表后所有余额表的所有操作

代理类。根据灰度百分比控制流量的写入。

/**
 * 封装数据库的DAO操作
 * @author 李泳权
 * @Date: 2019/10/31 15:38
 * @Description:余额表变更接口
 */
public interface IUserManager {
    /**
     * 用户初始化
     * @param kugouId
     * @return
     * 用户初始化结果
     */
    boolean init(long kugouId);
    /**
     * 扣减用户星币
     * @param kugouId
     * kugouId
     * @param coin
     * 星币
     * @param money
     * 真实星币
     * @return
     * 更新行数
     */
    int deduct(long kugouId,BigDecimal coin,
                      BigDecimal money);
    /**
     * 充值增加星币
     * @param kugouId
     * kugouId
     * @param money
     * 真实星币
     * @return
     * 更新行数
     */
    int recharge(long kugouId,BigDecimal money);
    ...
}

六、总结

本文提出了一种不停服的平滑分表方案,支持灰度和故障止损,不影响线上业务并且极大减少数据迁移的风险。该方案适用于其他营收资产业务,

七、展望

数据一致性校验可以适用于其他营收资产类的业务,从而实现一个通用的准实时的期初期末模型。分表的方案也同样适用于其他营收资产业务(eg.仓库服务)。

参考文档

canal

saturn

cache aside patten