V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
drymonfidelia
V2EX  ›  .NET

EF Core 不引入锁,高并发场景 ExecuteSqlRawAsync("UPDATE Users SET Balance = Balance + {0} WHERE UserId = {1}");后如何获取 Updated 后的值?

  •  
  •   drymonfidelia · 226 天前 · 2181 次点击
    这是一个创建于 226 天前的主题,其中的信息可能已经有所发展或是发生改变。
    用于余额变动记录。再查一遍肯定不行,极端情况下一个用户会同时发 10000 个下单请求(客户端随硬件交付,没有升级功能,无法更新),这样不加锁余额变动记录就不准了。加锁的话性能太差了。
    23 条回复    2024-12-26 14:28:02 +08:00
    lujiaxing
        1
    lujiaxing  
       226 天前
    这个取决于你用什么数据库吧? 跟 EF 好像是没什么关系. 你如果觉得不靠谱你就在外面加个 tranaction. 然后设置数据库隔离等级 RC. 这样你只需要在后面再接一个 SELECT 就 OK 了. 而且你也完全可以先查出 Balance, 然后更新. 更新成功后然后用程序计算出新的 Balance 直接返回.

    SELECT Balance FROM Users WHERE UserId = {userId};
    拿到之后先不管.

    UPDATE Users SET Balance = Balance + {amount} WHERE UserId = {userId};

    然后 return balance + amount;
    drymonfidelia
        2
    drymonfidelia  
    OP
       226 天前
    @lujiaxing 我之前就是这么实现的,然后忘记出现了高并发频繁事务失败还是余额加错的问题,代码大致是这样的
    var strategy = dbContext.Database.CreateExecutionStrategy();
    await strategy.ExecuteAsync(async () =>
    {
    using (var transaction = await dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted))
    {
    var user = await dbContext.Users.NotCacheable().FirstOrDefaultAsync(x => x.UserId == userId);
    if (user == null) return;
    await dbContext.Entry(user).ReloadAsync();
    if (balanceChange < 0 && user.Balance < balanceChange * -1) throw new Exception("UserBalanceNotEnough");
    await dbContext.Database.ExecuteSqlRawAsync(
    "UPDATE Users SET Balance = Balance + {0} WHERE UserId = {1}",
    new object[] { balanceChange, userId });
    var balanceRecord = new BalanceRecord
    {
    UserId = userId,
    Description = description,
    OperatorIp = operatorIp,
    BalanceChange = balanceChange,
    RemainingBalance = user.Balance + balanceChange
    };
    await dbContext.BalanceRecords.AddAsync(balanceRecord);
    try
    {
    await dbContext.SaveChangesAsync();
    await transaction.CommitAsync();
    }
    catch (Exception ex)
    {
    await transaction.RollbackAsync();
    }
    }
    }
    改了好几个版本,最后只能又套了一个 Redis 锁,但是会导致高并发性能变差很多
    drymonfidelia
        3
    drymonfidelia  
    OP
       226 天前
    格式被 V 站弄坏了,在 pastebin 上再发一份 https://pastebin.com/bFHrRT9L
    @lujiaxing
    MoYi123
        4
    MoYi123  
       226 天前
    postgresql 可以 update returning, mysql 好像只能开事务或者写存储过程.
    drymonfidelia
        5
    drymonfidelia  
    OP
       226 天前
    @MoYi123 pg 这些方面确实厉害,但是这个项目运行好多年了,我刚接手没多久,不敢改太多
    bqn
        6
    bqn  
       226 天前
    EF 不是有实体追踪嘛,实体设置需要更新的字段,直接保存就好了
    至于并发的问题,你需要在表中设计一个字段,数据存时间戳。查询出来数据,给实体某一些字段赋值,然后进行更新,如果当前数据的时间戳和数据库中的数据时间戳不一致,表示这条数据被操作过了,会触发一个异常的,直接抛出来就好了
    thtznet
        7
    thtznet  
       226 天前
    高并发一定要队列,不要想着用数据库的事务去代替领域解决业务问题。
    i8086
        9
    i8086  
       226 天前
    这个下单量有些高,建议用 7 楼方法~
    drymonfidelia
        10
    drymonfidelia  
    OP
       226 天前 via iPhone
    @bqn 实体追踪没办法在高并发的情况下给一个字段增加值
    drymonfidelia
        11
    drymonfidelia  
    OP
       226 天前 via iPhone
    @bqn 我这边的情况是一个客户端会 10000 并发下单,不可能给 9900 个订单全抛异常
    cloudzhou
        12
    cloudzhou  
       226 天前
    1. 存储过程
    update 和返回最新值

    ---
    2. 引入 version ,乐观锁自旋
    2.1 select version;
    2.2 update version=version+1 where version = {old_version}

    如果 update 成功,说明 select -》 update 之间没有修改,update 成功,新旧值
    如果 失败,重复 2.1-2.2 并引入随机等待

    ---
    3. select * for update 提前加锁
    然后 UPDATE Users SET Balance = Balance + {0} WHERE UserId = {1}
    再次 select 得到最新值
    在同一个事务
    drymonfidelia
        13
    drymonfidelia  
    OP
       226 天前 via iPhone
    @cloudzhou 这个 version 是一个单独字段么?每个查询都要多 update 一个字段,会不会导致性能问题
    cloudzhou
        14
    cloudzhou  
       226 天前
    @drymonfidelia 只有写,才会 update 阿,查询多一个字段没问题的,问题在于写
    quan01994
        15
    quan01994  
       226 天前
    如果是 sqlserver , 有 output inserted.Balance
    drymonfidelia
        16
    drymonfidelia  
    OP
       225 天前
    @quan01994 是 MySQL 。sqlserver bug 好多
    lovelylain
        17
    lovelylain  
       225 天前 via Android
    @drymonfidelia #13
    SELECT Balance, version FROM Users WHERE UserId = {userId};
    UPDATE Users SET Balance = Balance + {amount}, version=version+1 WHERE UserId = {userId} AND version={version};
    成功 return Balance + amount;
    drymonfidelia
        18
    drymonfidelia  
    OP
       225 天前
    @lovelylain 这样要写入硬盘,性能会不会比我现在用的 redis 锁还差
    bqn
        19
    bqn  
       225 天前
    @drymonfidelia 这 10000 都是对同一条数据做操作?上面的处理并发的方式是对一条的数据的操作,另外可做产生这个异常做重试,同 12 楼的做法思想是一致的,https://learn.microsoft.com/en-us/ef/core/saving/concurrency?tabs=data-annotations 这个文章里面也说了对于并发的处理。
    cloudzhou
        20
    cloudzhou  
       225 天前
    @lovelylain 是的,就是这个意思

    @drymonfidelia 你要事务性,那么不管怎么做,不会比 redis 更好的了
    如果你不要求事务,用 redis lua ,然后结束后日志入库,这种事务性,如果遇到 redis 不可用,就很难了
    drymonfidelia
        21
    drymonfidelia  
    OP
       225 天前 via iPhone
    @bqn 对,10000 并发 同一条
    forgottencoast
        22
    forgottencoast  
       214 天前
    我们以前在 ODBC/OLE DB 是这样做的:
    "UPDATE ....;
    SELECT "
    整个作为一个批处理事务脚本发过去。
    niubiman
        23
    niubiman  
       1 天前
    EF 有 fromsql 方法, 如果是在 MySQL 或者 pg 下,自己写 sql, 带上 for update 即可, 如果是 mssql, 设置事务隔离级别为 RR 即可
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2747 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 11:36 · PVG 19:36 · LAX 03:36 · JFK 06:36
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.