MYSQL 高并发下,记录变动后余额怎么统计才能更准确?

2023-12-05 10:51:12 +08:00
 Aluhao
-- 积分总表
CREATE TABLE `api_credits` (
`uid` bigint unsigned NOT NULL COMMENT '用户 ID',
`names` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户姓名',
`credits1` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '积分 1 余额',
`credits2` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '积分 2 余额',
`credits3` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '积分 3 余额',
`time` int NOT NULL DEFAULT '0' COMMENT '更新时间',
PRIMARY KEY (`uid`)
) ENGINE=InnoDB COMMENT='积分总表';

-- 积分记录表
CREATE TABLE `api_credits_log` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '记录 ID',
`uid` bigint unsigned NOT NULL DEFAULT '0' COMMENT '用户 ID',
`credits` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '积分变动',
`balance` decimal(15,2) NOT NULL DEFAULT '0.00' COMMENT '变动后余额',
`cid` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '操作类型',
`time` int unsigned NOT NULL DEFAULT '0' COMMENT '记录时间',
PRIMARY KEY (`id`),
KEY `uid_time` (`uid`,`time`)
) ENGINE=InnoDB COMMENT='积分记录表';

消费 100 积分,向 积分总表 api_credits 减去用户总积分,并向积分记录表 api_credits_log 写入一条记录,
balance 用户余额计算是 api_credits 表中 credits1 - 100 (即 80000 - 100 = 79900 )

消费事务会产生 SQL 数据:
SELECT * FROM `api_credits` WHERE `uid`='22' LIMIT 1
UPDATE `api_credits` SET `credits1`=`credits1`-'100' WHERE `uid`='22' AND `credits1`>='100'
INSERT INTO `api_credits_log` SET `uid`='22', `cid`='3', `credits`='100', `balance`='79900', `time`='1701001020'

但是在高并发场景下 SELECT 读出来的值不是最新,如,在同一时间段这个用户同时几条消费记录,这个变动后余额统计的就不是这么准确了,有没有什么好点的解决方案?

系统用了主从架构,读写分离,但是在这条事务中 SELECT 查的是主库

欢迎大家一起探讨一下。
6623 次点击
所在节点    MySQL
63 条回复
asmile1993
2023-12-05 17:46:40 +08:00
原来的操作逻辑有问题,你第一步是查询用户积分总额,然后更新积分总额,最后再将积分余额插入到积分记录表中,第一步只读查询并没有加锁,因此是可以并发的,这可能导致并发的线程读取到的余额是不一致的,又因为你积分总表的更新逻辑是正确的,这会造成积分记录表中记录的积分余额错乱,而积分总表中的余额又是正确的。

-- 步骤一:先变更,这样会锁住 `uid`='22' 这条记录,别的会话只能查询,不能变更
UPDATE `api_credits` SET `credits1`=`credits1`-'100' WHERE `uid`='22' AND `credits1`>='100'
-- 步骤二:拿到变更后的最新的积分余额
SELECT * FROM `api_credits` WHERE `uid`='22' LIMIT 1
-- 将步骤二中获取到的积分余额插入到积分记录表中
INSERT INTO `api_credits_log` SET `uid`='22', `cid`='3', `credits`='100', `balance`='79900', `time`='1701001020'

将这三步放入到同一个事务中
xiaoHuaJia
2023-12-05 18:01:22 +08:00
redis 配合 lua 脚本搞
iseki
2023-12-05 18:14:19 +08:00
当然,解决这个问题的最佳方法是 ——写个存储过程
sunny1688
2023-12-05 18:49:18 +08:00
第一步用 select for update ,因为 uid 是主键,可以使用到行锁,这样就串行了,不会造成余额错乱
或者先 update ,其它会话无法 update ,这样也是串行
l4ever
2023-12-05 19:30:49 +08:00
积分记录表不要用 mysql 搞, 换成时序数据库
cavities
2023-12-05 19:37:06 +08:00
其中 不建议用 mysql 有钱换 redis 都可以
pengjay
2023-12-05 19:38:11 +08:00
表里加一个版本号字段,先 select 出版本号 v ,update money=money-10 ,ver=ver+1 where ver =v and xxx
leimu012
2023-12-05 20:13:47 +08:00
学习一下
tonyli
2023-12-05 21:05:46 +08:00
引入 redis , 把主表 credits1 加载到 redis ,读取 redis 的数据进行操作,log 正常记录,做个定时任务,同步 redis 里面的数据到 mysql ,改动最小,能保证高并发
2kCS5c0b0ITXE5k2
2023-12-05 22:54:10 +08:00
尽量还是别引入其他组件. 除非实在是无法解决. 不然处理起来很麻烦
hefish
2023-12-05 23:00:54 +08:00
要不转岗试试。。。太伤脑筋了。。
yufeng0681
2023-12-05 23:06:19 +08:00
引入 redis ,优先读取 redis 数据,没命中,则读取数据库到 redis 内,进行操作(增删);命中了,直接进行操作; 定时将 N 分钟不更新的数据更新到数据库内,并从 redis 清空; 保证 redis 不会数据量过大
当 redis 异常,直接操作数据库,并在返回结果中告知用户数据不及时,需要精确数据,请稍后查询 [用户体验方面可优化]
当 redis 从异常恢复到正常,优先将数据更新到数据库内 [保证数据一致性]
crazyweeds
2023-12-05 23:38:07 +08:00
单个用户 UPDATE 、DEL 、ADD 操作增加全局锁( http 接口层面)?一个用户瞬时下多单本来就不合理。
bianhui
2023-12-06 08:39:58 +08:00
不明白你 select 的作用是什么,你消费时候不是应该只写吗。select 本来就是快照读,你就不要想着读到他正确的值,除非锁行。再说了你 update 也加了 credites1>100 了。你是事物执行完,直接 select 查询余额返回就行了,展示功能别和写库逻辑放在一起。单独接口都行。
sprite82
2023-12-06 09:15:37 +08:00
@Aluhao #4 这个难道不应该叫商户吗,用户这次比较容易误解😂
gitdoit
2023-12-06 09:31:53 +08:00
反正,别再引入新的组件了, 不然一个问题就会变成 N 个问题
echoZero
2023-12-06 09:34:15 +08:00
我更习惯用 UPDATE `api_credits` SET `credits1`=`credits1`-'100' WHERE `uid`='22' AND `credits1`='1000'
zhuzhibin
2023-12-06 09:48:11 +08:00
uid 主键索性等值查询,先 update 会产生行锁 block 吧,后面事务同 uid 更新会被锁,在第一个事务没有提交前。要么就悲观锁,要么就分布式锁解决
allenzhangSB
2023-12-06 10:02:44 +08:00
你需要的是更新并查询, 在 MySQL 中可以使用变量来记录更新后的值, 然后在更新语句后查询该变量值,
UPDATE `api_credits` SET `credits1`= @xxx := `credits1`-'100' WHERE `uid`='22' AND `credits1`>='100'
INSERT INTO `api_credits_log` SET `uid`='22', `cid`='3', `credits`='100', `balance`='79900', `time`='1701001020';
select @xxx;
TUNGH
2023-12-06 10:16:24 +08:00
@kanepan19 #21 你的第三条,如果不启用事务,那么其他地方报错,这个 update 就无法回滚

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/997702

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX