(3)账户模块---操作账户余额的最佳实践

2023-10-03 22:00:49 浏览数 (1)

1.账户系统说明

账户系统负责记录和管理用户账户的余额,这个余额就是每个用户临时存在电商的钱,来源

可能是用户充值或者退货退款等多种途径。

账户余额对不上账的本质:冗余数据的不一致问题

(账户表与流水表两表数据的不一致)

2.为什么需要流水记录表?

答: 方便追溯,相当于给账户余额的变化过程记录到了一张表,余额出现不一致,以流水表中金额的加减之后的结果为准

账户系统用于记录每个用户的余额,为了保证数据的可追溯性,还需要记录账户流水。流水

记录只能新增,任何情况下都不允许修改和删除,每次交易的时候需要把流水和余额放在同

一个事务中一起更新。

代码语言:sql复制
// DDL
1 CREATE TABLE `account_log` (
2 `log_id` int NOT NULL AUTO_INCREMENT COMMENT '流水号',
3 `amount` int NOT NULL COMMENT '交易金额',
4 `timestamp` datetime NOT NULL COMMENT '时间戳',
5 `from_system` int NOT NULL COMMENT '转出系统编码',
6 `from_system_transaction_number` int DEFAULT NULL COMMENT '转出系统的交易号',
7 `from_account` int DEFAULT NULL COMMENT '转出账户',
8 `to_system` int NOT NULL COMMENT '转入系统编码',
9 `to_system_transaction_number` int DEFAULT NULL COMMENT '转入系统的交易号',
10 `to_account` int DEFAULT NULL COMMENT '转入账户',
11 `transaction_type` int NOT NULL COMMENT '交易类型编码',
12 PRIMARY KEY (`log_id`)
13 );
14
15
16 CREATE TABLE `account_balance` (
17 `user_id` int NOT NULL COMMENT '用户ID',
18 `balance` int NOT NULL COMMENT '余额',
19 `timestamp` datetime NOT NULL COMMENT '时间戳',
20 `log_id` int NOT NULL COMMENT '最后一笔交易的流水号',
21 PRIMARY KEY (`user_id`)
22 );

3.怎么解决冗余数据一致性问题(怎么账户余额怎么对上账)

1.将流水表与账户余额表进行整合到一张数据表中,记录余额与交易金额

缺点:对于交易系统,频繁的根据历史交易流水进行计算,显然不太靠谱,所以采用“空间换时间”的策略将最终账户余额维护到一张表中去---account_balance

2.数据库事务

怎么将对两个表的操作(两个步骤)维护到一块去,要不两步都成功,要不都失败?

答案:数据库事务(transaction)

事务具有ACID特性:原子性(Atomic),一致性(Consistency),隔离性(Isolation),持久性(Durability)

例子:在事务中执行一个充

值 100 元的交易,先记录一条交易流水,流水号是 888,然后把账户余额从 100 元更新到

200 元。

事务还可以保证,数据库中的数据总是从一个一致性状态(888 流水不存在,余额是 100

元)转换到另外一个一致性状态(888 流水存在,余额是 200 元)。对于其他事务来说,

不存在任何中间状态(888 流水存在,但余额是 100 元)。

其他事务,在任何一个时刻,如果它读到的流水中没有 888 这条流水记录,它读出来的余

额一定是 100 元,这是交易前的状态。如果它能读到 888 这条流水记录,它读出来的余额

一定是 200 元,这是交易之后的状态。也就是说,事务保证我们读到的数据(交易和流

水)总是一致的,这是事务的一致性 (Consistency)

实际上,这个事务的执行过程无论多快,它都是需要时间的,那修改流水表和余额表对应的

数据,也会有先后。那一定存在一个时刻,流水更新了,但是余额还没更新,也就是说每个

事务的中间状态是事实存在的。

数据库为了实现一致性,必须保证每个事务的执行过程中,中间状态对其他事务是不可见

的。比如说我们在事务 A 中,写入了 888 这条流水,但是还没有提交事务,那在其他事务

中,都不应该读到 888 这条流水记录。这是事务的隔离性 (Isolation)

小结:一致性是保证操作表前的数据状态是一个状态A,所有对表的多个操作后是另一个状态B,不存在中间状态,理论虽如此,但存在中间状态是客观现实,只有串行执行SQL,也就是隔离级别SERIALIZABLE的才能达到这个标准,但是数据库的性能将大打折扣,一般也不采用SERIALIZABLE隔离级别

隔离性是为了提高并发和性能,做出的牺牲,所以就有如下的四种隔离级别,前提:保证原子性和持久性必须保证

RU、RC、RR 和 SERIALIZABLE,这四种级别的隔离性越来越严格,性能也越来越差,在 MySQL 中默认的隔离级别是 RR,可重复读。

RU READ-UNCOMMITTED 能读到未提交的数据就是完全不隔离,每个进行中事务的中间状

态,对其他事务都是可见的,所以有可能会出现“脏读”

常用的隔离级别其实就是 RC 和 RR 两种,其中 MySQL 默认的隔离级别是 RR。这两种隔

离级别都可以避免脏读,能够保证在其他事务中是不会读到未提交事务的数据,或者通俗地

说,只要你的事务没有提交,那这个事务对数据做出的更新,对其他会话是不可见的,它们

读到的还是你这个事务更新之前的数据。

RC 和 RR 唯一的区别在于“是否可重复读”在一个事务执行过程中,它能不能读到其他已提交事务对数据的更新,如果能读到数据变化,就是“不可重复读”,否则就是“可重复读”

在 RR 隔离级别下,在一个事务进行过程中,对于同一条数据,每次读到的结果总是相同的,无论其他会话是否已经更新了这条数据,这就是“可重复读”。

4.兼顾性能,并发与数据一致性的最佳实践是:

在账户表中增加一列:流水id(该账户下的最新流水id)

1.开启事务,查询并记录最后更新账户余额的流水id为last_id

2.然后写入一条流水记录,insert into values(null,......);

3.更新账户表该账户的余额,where条件:只有last_id = 流水id,才进行更新

4.检查更新后的影响条件,如果影响条数>0,成功提交事务,如果影响条数为0,回滚事务,如果更新捕获到异常,也回滚事务,提示给客户端

代码语言:sql复制
// RR级别下更新账户余额的sql
1 mysql> begin;
2 Query OK, 0 rows affected (0.00 sec)
3
4 mysql> -- 查询当前账户的余额和最后一笔交易的流水号。
5 mysql> select balance, log_id from account_balance where user_id = 0;
6  --------- -------- 
7 | balance | log_id |
8  --------- -------- 
9 | 100 | 3 |
10  --------- -------- 
11 1 row in set (0.00 sec)
12
13 mysql> -- 插入流水记录。
14 mysql> insert into account_log values
15 -> (NULL, 100, NOW(), 1, 1001, NULL, 0, NULL, 0, 0);
16 Query OK, 1 row affected (0.01 sec)
17
18 mysql> -- 更新余额,注意where条件中,限定了只有流水号等于之前查询出的流水号3时才更新。
19 mysql> update account_balance
20 -> set balance = balance   100, log_id = LAST_INSERT_ID(), timestamp = NOW
21 -> where user_id = 0 and log_id = 3;
22 Query OK, 1 row affected (0.00 sec)
23 Rows matched: 1 Changed: 1 Warnings: 0
24
25 mysql> -- 这里需要检查更新结果,只有更新余额成功(Changed: 1)才提交事务,否则回滚事务。
26 mysql> commit;
27 Query OK, 0 rows affected (0.01 sec)

扩展:

1.既然是RR级别,上面sql的21行更新为啥还需要带上log_id ?

①这是数据库的特性,涉及到写操作(insert update remove)都是快照读,都是读取最新的数据

②另一个原因,带上log_id,相当于版本号,相当于在更新的时候,加了个前置条件,更新时候如果log_id变化了,相当于中途有其他事务对账户余额表进行了更新,则回滚事务,否则,认为中途没有其他事务对账户余额表进行更新,则直接更新即可并判断受影响的条数(相当于乐观锁思想的一种实现)

2.如果并发两个请求同时,开启事务,更新账户余额为负数,怎么办?

①加分布式锁或者mysql数据库行锁

②在where条件上加上余额条件判断,比如:

代码语言:sql复制
// 并发情况下,防止账户余额更新为负数
update account_balance  set balance = balance - 100, log_id=LAST_INSERT_ID(), timestamp = NOW where user_id = 0 and log_id = 3 and balance>=100

总结:

整个更新账户余额表拆分为两步(两个过程):

1.先插入流水表成功后,才能进行下一步的更新账户余额表操作(保证第一个业务操作成功,相当于第一个条件成立,隐形创建了一个前置条件)

2.再对更新账户余额表操作的条件进行判断(版本号判断,乐观锁思想的一种实现)

①如果where条件满足,进行更新操作

基于之前开启事务后,查询查询出来的log_id进行判断,相当于版本号判断,为了防止更新余额为负数,需要加上balance>=交易金额的条件判断

②并判断受影响的条数是否>0,

代码语言:txt复制
i 正常条件下:如果受影响条数>0,则提交事务,否则回滚事务
代码语言:txt复制
ii 异常条件下:如果更新出现系统异常,则直接进行事务回滚,返回失败给客户端,因为对于交易系统来说,一般更新账户余额不进行重试操作

3.ACID的原子性的深入理解和认识:

多个操作要不都成功,否则其中任何一个失败,整个过程则认为失败,回滚事务,其次,只有前面的条件成立后,后面的操作步骤才能往下进行,利用这个特点,业务中需要必须拆分的过程,注意顺序性,利用顺序性,可以人为的创造条件判断屏障,相当于设置前置条件

我正在参与2023腾讯技术创作特训营第二期有奖征文,瓜分万元奖池和键盘手表

0 人点赞