我们知道,在应用程序中与数据库进行交互是一个比较耗时的过程,首先应用程序需要与应用程序建立连接,然后将请求发送到数据库,数据库执行操作,然后将结果集返回。所以在程序中,要尽量晚的与数据库建立连接,并且较早的释放连接。

然而在很多时候,我们需要频繁的查询和更新数据库中的记录,比如我们的一张表中有1000条记录,假设有一个场景,需要一条一条的判断这1000条记录,如果不存在,插入;如果存在,更新某一个字段。这种场景很常见,比如银行的用户转账或者汇款,在完成之后需要更新账户余额等操作。

最近项目中也遇到了类似的情况,通过实践也简单总结了一些如何提高应用程序执行效率的方法,当然这些都是通过减少和数据库进行交互以及当数据达到一定程度,通过批量实现的。下面就简要介绍一下。

一 场景


最近在项目中要实现一个类似大众点评团购这种筛选的功能,用户可以根据系统提供的城市,商区,美食类别列表来进行筛选,选好之后,下面就列出所有的满足条件的美食。这里有一点需要注意,系统要提供热门商区,和热门菜系,并且,当各种条件选择之后,在条件列表的后面需要显示符合该条件的美食个数,如下图中的数字。这些条件之间是联动的,当其他条件变化时,字数也会跟着变化,比如,当选择甜品饮料时,在徐汇区的衡山路商区中满足条件的甜品有12个,当切换到自助餐的时候,个数变为了2,并且如果该条件下徐汇区中某一商区没有自助餐,则该商区都不要显示。

团购

二 解决方法


其实,这些筛选条件,可以通过一个服务动态下发,当用户打开网页时,系统会请求所有的筛选项。 可以为该筛选项建立一张表,并在后台维护一个服务以一定的时间间隔更新这张表。 这里,我们以城市-商区-美食分类为例,来说明如何实现该功能。

我们可以建立一张表名位QueryFilter表, 该表字段为包含 城市ID,商区ID,美食分类ID,美食个数这几个字段,该表大体结构如下:

Query Filter Table

该表中仅存有ID,如果要现实对应的名称,可以关联对应的城市表,商区表,美食分类表,等等。

表的含义为:

比如对于第一条记录,城市为上海(CityID=2),所有商区(DistrictID=0),美食类别为川菜(CuisineID=4),的满足条件的美食个数为4(ProductCount=4)

比如对于第五条记录,城市为上海(CityID=2),商区为衡山路(DistrictID=71),美食类别为甜品(CuisineID=7),的满足条件的美食个数为3(ProductCount=3)

根据CityID,DistrictID,和CusineID来统计ProductCount 的语句和页面上用户选中这些条件后页面详情列表展示的接口其实是同一个接口,我们这里只需要统计美食个数。

后台需要有一个服务,以一定的时间间隔,遍历所有城市,所有商区,所有美食分类,然后更新这样一张表。

三 优化


很自然,最先想到的可能是如下方法,这里仅列出思路:

  1. 查找出所有的CityID,所有的DistirctID,所有的CuisineID,这些可以从相应的表中查出来。
  2. 然后,对于一个CityID,DistrictID,CuisineID的组合,调用接口,到数据库中所有美食表中去统计该组合条件下美食的个数。
  3. 然后检查QueryFilter中,是否存在CityID,DistrictID,CuisineID这样一条记录。
  • 如果不存在,插入CityID,DistrictID,CuisineID,以及对应的ProductCount
  • 不过存在,更新ProductCount

代码如下:

List<CityModel> allCitys;
List<BusinessDistrictModel> allDistricts;
List<CuisineModel> allCuisines;

allCitys = CityRepository.GetAllCitys();
allDistricts = BusinessDistrictRepository.GetAllBusinessDistricts();
allCuisines = CuisineRepository.GetAllCuisines();

foreach (CityModel city in allCitys)
{
    foreach (CuisineModel cuisine in allCuisines)
    {
        foreach (BusinessDistrictModel district in allDistricts)
        {
            //查找 城市-商区-美食类表- 的所有美食个数
            int productCount = QueryFilterRepository.GetProductCountProduct(city, cuisine, district);
            //查找该记录是否已经存在
            QueryFilterModel queryFilters = QueryFilterRepository.Get(city, cuisine, district);

            if (queryFilters == null)
            {
                //不存在,插入该条记录
                QueryFilterRepository.Insert(city, cuisine, district, productCount);
            }
            else
            {
                //存在在,更新该记录个数
                QueryFilterRepository.Update(city, cuisine, district, productCount);
            }
        }
    }
}

可以看到,在这段代码中有一个城市-商区-美食类别的三重循环,在最内层的循环体内,有三条需要与数据库进行交互的语句, 一条是统计美食个数,一个是获取是否存在该记录,一个是插入或者更新操作。假设如果有10个城市,总共有50个商区,有30个美食类别,那么与数据库进行交互个次数为10*50*30*3=45000次,很显然,如果数据量比较大,每一次查询比较耗时的时候,这种效率是没办法忍受的,而且数据库的负担也很大。在我自己的机器上,一次这样遍历下来,花了大概半个小时。即使这是一个运行在服务器上的后台的服务也是没有办法忍受。

优化一 : 减少与数据库的交互

可以看到,上面的问题在于,与数据库的交互次数过多,一般的在循环操作中去与数据库交互大多是会存在性能问题。所以,为了减少与数据库的交互,我们一次性把所有的满足条件的美食及该美食所在的城市ID,商区ID,美食类别ID,从数据库中查询出来放到集合List<FoodsModel>中,然后把QueryFilter表中的所有记录一次性查出来,存在List<QueryFilterModel>中。然后我们再创建一个名为readyToInsert的List< QueryFilterModel >集合以及一个readyToUpdate的List< QueryFilterModel >集合来保存我们所有需要插入或者更新的记录。优化后的代码逻辑如下:

  1. 查找出来所有的美食及其对应的CityID,DistrictID,CuisineID,放在AllFoodList集合中。
  2. 查询QueryFilter表中的所有记录,放在AllQueryFilterList集合中
  3. 查找出所有的CityID,所有的DistirctID,所有的CuisineID,这些可以从相应的表中查出来。
  4. 然后,对于一个CityID,DistrictID,CuisineID的组合,在AllFoodList中查找满足条件的记录个数,然后构造一个QueryFilterModel对象。
  5. 然后检查AllQueryFilterList中,是否存在CityID,DistrictID,CuisineID这样一条记录。
  • 如果不存在,将先前构造的QueryFilterModel放到readyToInsert集合中
  • 不过存在,将先前构造的QueryFilterModel放到readyToUpdate集合中

大概代码如下:

List<FoodModel> allFoods;
List<QueryFilterModel> allQueryFilters;
List<QueryFilterModel> readyToInsert;
List<QueryFilterModel> readyToUpdate;

List<CityModel> allCitys;
List<BusinessDistrictModel> allDistricts;
List<CuisineModel> allCuisines;

allCitys = CityRepository.GetAllCitys();
allDistricts = BusinessDistrictRepository.GetAllBusinessDistricts();
allCuisines = CuisineRepository.GetAllCuisines();

allQueryFilters = QueryFilterRepository.GetAllQueryFilters();
allFoods = FoodRepository.GetAllFoods();
readyToInsert = new List<QueryFilterModel>();
readyToUpdate = new List<QueryFilterModel>();
foreach (CityModel city in allCitys)
{
    foreach (CuisineModel cuisine in allCuisines)
    {
        foreach (BusinessDistrictModel district in allDistricts)
        {
            //查找 城市-商区-美食类表- 的所有美食个数
            int productCount = allFoods.Count(x => x.CityId == city.Id && x.CuisineId == cuisine.Id && x.DistrictId == district.Id);
            //查找该记录是否已经存在
            QueryFilterModel queryFilter = allQueryFilters.Find(x => x.CityId == city.Id && x.CuisineId == cuisine.Id && x.DistrictId == district.Id);

            QueryFilterModel queryModel = new QueryFilterModel { 
                CityId = city.Id, 
                CuisineId = cuisine.Id, 
                DistrictId = district.Id, 
                ProductCount = productCount };

            if (queryFilter == null)
            {
                //不存在,插入待插入结合
                readyToInsert.Add(queryModel);
            }
            else
            {
                //存在在,插入待更新结合
                readyToUpdate.Add(queryModel);
            }
        }
    }
}

改造后的代码,只有两次与数据库的交互,一次是获取allFoods,一次是获取allQueryFilters, 在循环提中,我们仅对allFoods集合进行统计,然后再在allQueryFilters中查询是否存在,并插入到相应的待更新或者待插入列表中,这些都是在内存中进行的操作,速度极快。经过这一项改进,我们将与数据库的交互从45000次减少到了10次以内。

优化二:批量插入与更新

在优化一中,我们得到了readyToInsert和readyToUpdate两个集合,这两个表示待插入和待更新的集合,只有在服务第一次跑的时候才有readyToInsert,后面该集合为空,因为全部都是readyToUpdate。

有了这两个集合,我们需要与数据库进行交互,把这些数据更新回数据库。这里又有两种选择,一种是for循环,在循环中一次次插入,一种是批量发送到SQLServer中进行批量更新。

如果采用第一种方式,那么就又会增加与数据库的交互,虽然比没优化之前的在三层循环中对数据库进行操作要好,但是还是会极大影响性能。所以我们采用批量更新和插入的策略。

关于SQLServer批量更新和插入记录的方式有很多种,比如ADO.NET 2.0种的SqlBulkCopy, SQLServer 2008中的表值参数(Table-Valued Parameter)。

这里我们使用SQLServer2008中的表值参数,其基本思路是,在SQLServer中新建一个存储过程,该存储过程定义了一个和QueryFilter表结构一致的表变量:

CREATE PROC BulkInsert
    (
      @QueryFilter QueryFilter READONLY
    )
AS
    INSERT  INTO QueryFilter
            ( CityID ,
              DistrictID ,
              CuisineID ,
              ProductCount
            )
            SELECT  qf.CityID ,
                    qf.DistrictID ,
                    qf.CuisineID ,
                    qf.ProductCount
            FROM    @QueryFilter AS qf

然后在C# 中,我们通过构建一个DataTable的类型,该类型要和存储过程中定义的表值参数中的类型一致,且ColumnName也应该一致,方法如下:

public static void Insert(List<QueryFilterModel> readyToInsert)
{
    //构造表结构
    DataTable queryFilterTable = new DataTable();
    queryFilterTable.Columns.Add(new DataColumn("CityID", typeof(long)));
    queryFilterTable.Columns.Add(new DataColumn("DistrictID", typeof(long)));
    queryFilterTable.Columns.Add(new DataColumn("CuisineID", typeof(long)));
    queryFilterTable.Columns.Add(new DataColumn("ProductCount", typeof(int)));

    //填充值
    foreach (QueryFilterModel model in readyToInsert)
    {
        DataRow dr = queryFilterTable.NewRow();
        dr["CityID"] = model.CityId;
        dr["DistrictID"] = model.DistrictId;
        dr["CuisineID"] = model.CuisineId;
        dr["ProductCount"] = model.ProductCount;
        queryFilterTable.Rows.Add(dr);
    }

    SqlConnection connection = new SqlConnection(myConnString);
    connection.Open();
    //BulkInsert存储过程
    SqlCommand cmd = new SqlCommand("BulkInsert", connection);
    cmd.CommandType = CommandType.StoredProcedure;
    //将表值参数传递给存储过程
    SqlParameter sqlParam = cmd.Parameters.AddWithValue("@QueryFilter", queryFilterTable);
    sqlParam.SqlDbType = SqlDbType.Structured;
    cmd.ExecuteNonQuery();
    connection.Close();
}

然后我们调用该方法,把我们之前搜集的所有需要插入的记录放进去即可批量更新到数据库里面了。这里仅列出了插入方法,更新的方法逻辑和这个类似,这里不再赘述。另外,我们在编写批量查询的存储过程的时候,还需要考虑一些诸如锁,数据完整性比如是否加事务等,这里为了演示没有做任何处理。

这样,我们通过与数据库的两次交互,就将所有的待变更的记录更新到了数据库中。

经过这两点的优化,整个服务运行的时间从之前的近半小时缩短为10秒内。

优化三:充分使用SQL强大的查询统计功能

现在,已经通过后台的服务将所有CityID,DistrictID,CuisineID组合的美食个数插入到了QueryFilter表中,现在,我们要查找所有热门的DistrictID,热门的CuisineID,并且统计所有DistrictID和CuisineID在不同组合下,美食的个数,用于用户在点击筛选菜单时显示在该条件后面的美食的个数。

要解决这一问题也有两种方式,一种是,直接将所有的根据传递进来的CityID中到QueryFilter中把所有记录查找出来,然后再在内存中,使用LINQ对集合进行统计。结果出来之后,再到City,District及Cuisine表中找到Id对应的中文名称。这些操作,如果在C#里面在内存中进行处理,会发现非常麻烦,而且占用内存,比如需要使用group by,需要Order,需要Sum,然后需要在内存中去连接City,District及Cuisine实体查找出名称,还要构造相应的对象,这些临时的中间对象会给GC带来很大压力。

其实这些统计查询功能正是SQL语句的强项,所以我们完全可以把查询条件下发中的统计完全使用SQL语句完成,比如在SQL中,可以很方便的使用查询,统计等操作,也可以很容易使用join操作来链表查询ID对应的中文名称,这样我们只需要把用户选中的CityID,DistrictID作为参数传到存储过程中,然后直接将查询结果以DataTable返回过来。

比如,要查找所有热门商区,我们只需要编写以下SQL语句,统计出,所有的商区包含的全部美食个数,倒序排列,取前十:

SELECT TOP 10
        DistrictID,
        SUM(ProductCount) AS ProductCounts
FROM    QueryFilter 
WHERE   CityID = 2
        AND ProductCount > 0
        AND CuisineID = 0
GROUP BY DistrictID
ORDER BY ProductCounts DESC

查询结果如下:

Most Popular District

如果要根据DistrictID显示是那个商区,也很简单,只需要inner join关联商区表即可。

可以看到,通过将统计,连表查询逻辑,从.NET 中移至SQLServer中,使得原本复杂难以控制的C# 代码变为了简洁充满语义的SQL语句。不仅提高了处理效率,减少了内存使用,而且使得代码更好维护。

四 结论


本文通过实践,讨论了在应用程序和数据库进行交互时值的注意和可能会影响性能的几个问题:

  • 由于数据库交互是一个比较耗时和耗资源的过程,所以应用程序中应该尽量减少与数据库的交互,在一些处理过程中,可以一次性查询出来,然后在内存中进行处理,处理好了之后,通过批量方式批量更新回数据库以持久化。
  • 对于一些需要用到的统计,排序,查询等功能,可以将一些业务逻辑直接写到存储过程中,直接利用数据库SQL语言强大统计查询分析功能,在数据库中完成一些诸如统计,排序,连表查询功能,然后将处理结果返回处理。这样减少应用程序中对数据处理导致的内存消耗以及增加的代码复杂性。使得程序更加简洁和易维护。

这些问题其实都很简单,但是处理好了可以提高应用程序的性能,希望本文对您在处理应用程序与数据库进行交互时,遇到性能问题时有所帮助。