前段时间工作中,有客户反应了系统中某类待办重复出现两次的情况。我核实了数据之后,分析认为是并发请求下导致的数据不一致性问题,并做了重现。其实这并不是一个需要频繁调用的功能,但是客户连续点击了两次,导致出现了并发问题。除了前端优化,这里重点探讨后台方面代码层面的处理,最终解决问题。
一、情景分析
Asp.net程序部署Web服务,是多主线程并发执行的,当多个用户请求进入同一个后台函数时,后进入的请求有可能会获取到非最新状态的数据。
结合我遇到的实际情况举个例子,假设后台函数Func1,先读取表TableA,TableB的数据,进行处理后,存入TableB中,而数据库事务执行会在函数结束前才提交。请求Req1执行Func1提交事务之前,Req2又进入Func1并读取了TableA,TableB的数据,这时Req1执行完成,这就相当于Req2拿到的已经是旧的数据,在旧的数据的基础上再做数据处理操作,结果自然不正确了。
说到这里,你可能还不能想象具体会出现什么问题,而确实这种并发情况在非幂等功能下才会导致数据错误,下面就举实例说明。
二、实例重现
现在有数据表Info,Info2,Info2的数据就是基于Info表数据产生的,两个表都有字段-证件号码IdentNo。
函数SyncWork()的功能为:
1,读取Info表和Info2表中共同的IdentNo行数据,将Info表中的其它字段同步到Info2表;
2,读取Info表中有,而Info2表中没有的IdentNo行数据,将这些数据插入Info2表。
表实体代码实现如下:
1 /// <summary> 2 /// 信息表 3 /// </summary> 4 public class Info 5 { 6 public int Id { get; set; } 7 /// <summary> 8 /// 证件号码 9 /// </summary> 10 public string IdentNo { get; set; } 11 /// <summary> 12 /// 姓名 13 /// </summary> 14 public string Name { get; set; } 15 /// <summary> 16 /// 爱好 17 /// </summary> 18 public string Hobby { get; set; } 19 /// <summary> 20 /// 备注信息 21 /// </summary> 22 public string Bz { get; set; } 23 } 24 25 /// <summary> 26 /// 信息表2 27 /// </summary> 28 public class Info2 29 { 30 public int Id { get; set; } 31 /// <summary> 32 /// 证件号码 33 /// </summary> 34 public string IdentNo { get; set; } 35 /// <summary> 36 /// 姓名 37 /// </summary> 38 public string Name { get; set; } 39 /// <summary> 40 /// 爱好 41 /// </summary> 42 public string Hobby { get; set; } 43 /// <summary> 44 /// 创建时间 45 /// </summary> 46 public DateTime CreateTime { get; set; } 47 /// <summary> 48 /// 最后修改时间 49 /// </summary> 50 public DateTime? UpdateTime { get; set; } 51 /// <summary> 52 /// 评分 53 /// </summary> 54 public int? Score { get; set; } 55 }
SyncWork()代码实现如下,代码中加入了辅助的输出信息:
1 public static string SyncWork() 2 { 3 StringBuilder sb = new StringBuilder(); 4 // 5 int threadId = Thread.CurrentThread.ManagedThreadId; 6 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.线程Id:{threadId}"); 7 // 8 MyDbContext db = new MyDbContext(); 9 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.db初始化"); 10 //新增数据 11 var dataToAdd = db.Info.Where(x => !db.Info2.Select(y => y.IdentNo).Contains(x.IdentNo)) 12 .ToList(); 13 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}.获取待新增数据{dataToAdd.Count}条"); 14 dataToAdd.ForEach(x => 15 { 16 var info2 = new Info2 17 { 18 IdentNo = x.IdentNo, 19 Name = x.Name, 20 Hobby = x.Hobby, 21 CreateTime = DateTime.Now 22 }; 23 db.Info2.Add(info2); 24 }); 25 //更新原有数据 26 var dataToEdit = db.Info.AsQueryable().Join(db.Info2.AsQueryable(), m => m.IdentNo, n => n.IdentNo, 27 (m, n) => new 28 { 29 info = m, 30 info2 = n 31 }) 32 .ToList(); 33 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}获取待更新数据{dataToEdit.Count}条"); 34 dataToEdit.ForEach(x => 35 { 36 x.info2.Name = x.info.Name; 37 x.info2.Hobby = x.info.Hobby; 38 x.info2.UpdateTime = DateTime.Now; 39 }); 40 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}开始休眠5s"); 41 Thread.Sleep(5000); 42 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveBegin"); 43 db.SaveChanges(); 44 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveEnd"); 45 return sb.ToString(); 46 }
里边的这几行代码就是问题重现的重点了:
1 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}开始休眠5s"); 2 Thread.Sleep(5000); 3 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveBegin"); 4 db.SaveChanges(); 5 sb.AppendLine($"{DateTime.Now.ToString("HH:mm:ss:fff")}dbSaveEnd");
在db提交之前,我们让此线程休眠了5s,以模仿现实中一些耗时的操作。这5s期间可能有后续的N个新请求线程进入此函数,那么这些线程获取的数据都会是同样的旧数据了。第一个执行完函数的线程,提交了更改,而后续这些线程再提交的更改,便是基于旧的数据做的更改了。
下面开始重新问题,我们执行一次请求,两表数据情况分别如下:
然后,Info表中新增了一条数据:
这时,我们再执行请求,预期结果应该将行003老虎的数据添加到Info2中,但是我们现在模拟并发,连续调用2次请求看看结果:
可以看到,居然将003老虎的数据插入了两次。这就是并发带来的副作用了。
附上两次请求的辅助输出信息:
1 //Request1 2 22:50:05:953.线程Id:44 3 22:50:05:953.db初始化 4 22:50:05:982.获取待新增数据1条 5 22:50:06:000.获取待更新数据2条 6 22:50:06:001.开始休眠5s 7 22:50:11:001.dbSaveBegin 8 22:50:11:084.dbSaveEnd 9 //Request2 10 22:50:07:240.线程Id:48 11 22:50:07:240.db初始化 12 22:50:07:270.获取待新增数据1条 13 22:50:07:287.获取待更新数据2条 14 22:50:07:287.开始休眠5s 15 22:50:12:287.dbSaveBegin 16 22:50:12:339.dbSaveEnd
三、问题解决
既然问题是并发请求导致的,而这个功能不是需要频繁调用的功能,最简便的解决方法就是,我们可以设置此功能同一时间只能由一个线程来访问,即通过lock()的方式。
最终实现代码如下:
1 public class InfoSync 2 { 3 private static object syncObject = new object(); 4 public static string Sync() 5 { 6 lock (syncObject) 7 { 8 return SyncWork(); 9 } 10 } 11 private static string SyncWork() 12 { 13 //... 14 } 15 }
同时贴出示例控制器的简单实现:
1 public class DataController : Controller 2 { 3 // GET: Data 4 public ActionResult Index() 5 { 6 try 7 { 8 var str = InfoSync.Sync(); 9 return Content(str); 10 } 11 catch (Exception ex) 12 { 13 return Content($"程序发生错误:{ex.Message} 内部错误:{ex.InnerException.Message}"); 14 } 15 } 16 }
四、总结
类似文中数据同步并发情况的实际应用情况还有很多,比如系统有时会需要产生编号,我们会访问数据库中这类编号的最新值,然后计算出下一个编号值,如果不处理并发情况,业务量大时可能就会出现重复编号了。
本文中,针对这类请求并发问题,通过代码锁的方式,将特定功能的并发请求执行转化为队列请求执行,从而避免了问题的发生。
当然,处理并发还有其它途径,如通过数据库锁的方式,再如分布式部署情况下,我们用代码锁的方式也会失效了,实际工作中还需要根据具体情况采用最小代价成本的处理方式。