在ASP.NET WebAPI + SQL中正确实现并发控制

时间:2016-05-22 13:35:27

标签: c# asp.net-web-api concurrency

我正在编写一个非常简单的Web应用程序,用作从客户上传货币交易并将其保存在SQL Server数据库中的端点。它接受只有2个参数的请求:userid: 'xxx', balancechange: -19.99。如果应用程序数据库中存在用户标识,则更改余额;如果不是 - 为此ID创建一个新行。

所有这一切中的困难部分是请求的数量巨大,我必须以尽可能快的方式实现应用程序并解决并发问题(如果同一ID的2个请求同时到达)

该应用程序是ASP.NET MVC WebAPI。我选择使用普通的旧ADO.NET来提高速度,这就是我目前所拥有的:

private static readonly object syncLock = new object();
public void UpdateBalance(string userId, decimal balance)
{
    lock (syncLock)
    {
        using (var sqlConnection = new SqlConnection(this.connectionString))
        {
            var command = new SqlCommand($"SELECT COUNT(*) FROM Users WHERE Id = '{userId}'", sqlConnection);
            if ((int)command.ExecuteScalar() == 0)
            {
                command = new SqlCommand($"INSERT INTO Users (Id, Balance) VALUES ('{userId}', 0)", sqlConnection);
                command.ExecuteNonQuery();
            }
            command = new SqlCommand($"UPDATE Users SET Balance = Balance + {balance} WHERE Id = {userId}", sqlConnection);
            command.ExecuteNonQuery();
        }
    }
}

从这样的控制器调用:

[HttpPost]
public IHttpActionResult UpdateBalance(string id, decimal balanceChange)
{
    UpdateBalance(id, balanceChange);
    return Ok();
}

我关心的是使用lock (syncLock)的并发控制。这会在高负载下降低应用程序速度,并且不允许将应用程序的多个实例部署在不同的服务器上。有什么方法可以在这里正确实现并发控制?

注意:我想使用一种快速且独立于数据库的方式来实现并发控制,因为当前的存储机制(SQL Server)将来可能会发生变化。

1 个答案:

答案 0 :(得分:0)

首先,与数据库无关的代码:

为此,您需要查看DbProviderFactory。这允许传递提供者名称(MySql.Data.MySqlClientSystem.Data.SqlClient),然后使用与数据库交互的抽象类(DbConnectionDbCommand)。

其次,使用交易和参数化查询:

当您使用数据库时,您总是希望对您的查询进行参数化。如果您使用String.Format()或任何其他类型的字符串连接,则打开查询直到注入。

事务确保您的查询全部或全部,并且它们还可以锁定表,以便只有事务中的查询才能访问这些表。事务有两个命令,Commit将更改(如果有的话)保存到数据库,Rollback将丢弃对数据库的任何更改。

以下假设您已在类变量DbProviderFactory中拥有_factory的实例。

public void UpdateBalance(string userId, decimal balanceChange)
{
    //since we might need to execute two queries, we will create the paramaters once
    List<DbParamater> paramaters = new List<DbParamater>();
    DbParamater userParam = _factory.CreateParamater();
    userParam.ParamaterName = "@userId";
    userParam.DbType = System.Data.DbType.Int32;
    userParam.Value = userId;
    paramaters.Add(userParam);

    DbParamater balanceChangeParam = _factory.CreateParamater();
    balanceChangeParam.ParamaterName = "@balanceChange";
    balanceChangeParam.DbType = System.Data.DbType.Decimal;
    balanceChangeParam.Value = balanceChange;
    paramaters.Add(balanceChangeParam);

    //Improvement: if you implement a method to clone a DbParamater, you can
    //create the above list in class construction instead of function invocation 
    //then clone the objects for the function.

    using (DbConnection conn = _factory.CreateConnection()){
        conn.Open(); //Need to open the connection before you start the transaction
        DbTransaction trans = conn.BeginTransaction(System.Data.IsolationLevel.Serializable);
        //IsolationLevel.Serializable locks the entire table down until the
        //transaction is commited or rolled back.

        try {
            int changedRowCount = 0;

            //We can use the fact that ExecuteNonQuery will return the number
            //of affected rows, and if there are no affected rows, a 
            //record does not exist for the userId.
            using (DbCommand cmd = conn.CreateCommand()){
                cmd.Transaction = trans; //Need to set the transaction on the command
                cmd.CommandText = "UPDATE Users SET Balance = Balance + @balanceChange WHERE Id = @userId";
                cmd.Paramaters.AddRange(paramaters.ToArray());
                changedRowCount = cmd.ExecuteNonQuery();
            }

            if(changedRowCount == 0){
                //If no record was affected in the previous query, insert a record
                using (DbCommand cmd = conn.CreateCommand()){
                    cmd.Transaction = trans; //Need to set the transaction on the command
                    cmd.CommandText = "INSERT INTO Users (Id, Balance) VALUES (@userId, @balanceChange)";
                    cmd.Paramaters.AddRange(paramaters.ToArray());
                    cmd.ExecuteNonQuery();
                }
            }

            trans.Commit(); //This will persist the data to the DB.
        }
        catch (Exception e){
            trans.Rollback(); //This will cause the data NOT to be saved to the DB.
                              //This is the default action if Commit is not called.
            throw e;
        }
        finally {
            trans.Dispose(); //Need to call dispose
        }

        //Improvement: you can remove the try-catch-finally block by wrapping 
        //the conn.BeginTransaction() line in a using block. I used a try-catch here
        //so that you can more easily see what is happening with the transaction.
    }
}