小故事
在開始講這篇文章之前,我們來說一個小故事,純素虛構(真實的存錢邏輯并非如此)
小劉發工資後,趕忙拿着現金去銀行,準備把錢存起來,而與此同時,小劉的老婆劉嫂知道小劉的品性,知道他發工資的日子,也知道他喜歡一發工資就去銀行存起來,擔心小劉卡裡存的錢太多拿去“大寶劍”,于是,也去了銀行,想趁着小劉把錢存進去後就把錢給取出來,省的夜長夢多。
小劉與劉嫂取得是兩家不同的銀行的ATM,是以兩人沒有碰面。
小劉插入銀行卡存錢之前查詢了自己的餘額,ATM這樣顯示的:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIn5GcuYTN5YTN5kDOlljZhFmZmlDN1MTOmNjNxU2NwEGM2AjMfdWbp9CXt92Yu4GZjlGbh5SZslmZxl3Lc9CX6MHc0RHaiojIsJye.png)
與次同時,劉嫂也通過卡号和密碼查詢該卡内的餘額,也是這麼顯示的:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIn5GcuYTN5YTN5kDOlljZhFmZmlDN1MTOmNjNxU2NwEGM2AjMfdWbp9CXt92Yu4GZjlGbh5SZslmZxl3Lc9CX6MHc0RHaiojIsJye.png)
劉嫂,很生氣,沒想到小劉偷偷藏了5000塊錢的私房錢,就把5000塊錢全部取出來了。是以把賬戶6217****888888的金額更新成0.(查詢結果5000基礎上減5000)
在這之後,小劉把自己發的3000塊錢也存到了銀行卡裡,是以這邊的這台ATM把賬戶6217****888888的金額更新成了8000.(在查詢的5000基礎上加3000)
最終的結果是,小劉的銀行卡金額8000塊錢,劉嫂也拿到了5000塊錢。
反思?
故事結束了,很多同學肯定會說,要真有這樣的銀行不早就倒閉了?确實,真是的銀行不可能是這樣來計算的,可是我們的同學在設計程式的時候,卻經常是這樣的一個思路,先從資料庫中取值,然後在取到的值的基礎上對該值進行修改。可是,卻有可能在取到值之後,另外一個客戶也取了值,并在你儲存之前對資料進行了更新。那麼如何解決?
解決辦法—樂觀鎖
常用的辦法是,使用客觀鎖,那麼什麼是樂觀鎖?
下面是來自百度百科關于樂觀鎖的解釋:
樂觀鎖,大多是基于資料版本( Version )記錄機制實作。何謂資料版本?即為資料增加一個版本辨別,在基于資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 “version” 字段來實作。讀取出資料時,将此版本号一同讀出,之後更新時,對此版本号加一。此時,将送出資料的版本資料與資料庫表對應記錄的目前版本資訊進行比對,如果送出的資料版本号大于資料庫表目前版本号,則予以更新,否則認為是過期資料。
通俗地講,就是在我們設計資料庫的時候,給實體添加一個Version的屬性,對實體進行修改前,比較該實展現在的Version和自己當年取出來的Version是否一緻,如果一緻,對該實體修改,同時,對Version屬性+1;如果不一緻,則不修改并觸發異常。
作為強大的EF(Entiry FrameWork)當然對這種操作進行了封裝,不用我們自己獨立地去實作,但是在查詢微軟官方文檔時,我們發現,官方文檔是利用給Sql Server資料庫添加timestamp标簽實作的,Sql Server在資料發生更改時,能自動地對timestamp進行更新,但是Mysql沒有這樣的功能的,我是通過并發令牌(ConcurrencyToken)實作的。
什麼是并發令牌(ConcurrencyToken)?
所謂的并發令牌,就是在實體的屬性中添加一塊令牌,當對資料執行修改操作時,系統會在Sql語句後加一個Where條件,篩選被标記成令牌的字段是否與取出來一緻,如果不一緻了,傳回的肯定是影響0行,那麼此時,就會對抛出異常。
具體怎麼用?
首先,建立一個WebApi項目,然後在該項目的Model目錄(如果沒有就手動建立)建立一個student實體。其代碼如下:
1 using System;
2 using System.Collections.Generic;
3 using System.Linq;
4 using System.Threading.Tasks;
5
6 namespace Bingfa.Model
7 {
8 public class Student
9 {
10 public int id { get; set; }
11 public string Name { get; set; }
12 public string Pwd { get; set; }
13 public int Age { get; set; }
14 public DateTime LastChanged { get; set; }
15 }
16 }
然後建立一個資料庫上下文,其代碼如下:
1 using System;
2 using System.Collections.Generic;
3 using System.ComponentModel.DataAnnotations.Schema;
4 using System.Linq;
5 using System.Threading.Tasks;
6 using Microsoft.EntityFrameworkCore;
7
8 namespace Bingfa.Model
9 {
10 public class SchoolContext : DbContext
11 {
12 public SchoolContext(DbContextOptions<SchoolContext> options) : base(options)
13 {
14
15 }
16
17 public DbSet<Student> students { get; set; }
18
19 protected override void OnModelCreating(ModelBuilder modelBuilder)
20 {
21 modelBuilder.Entity<Student>().Property(p => p.LastChanged).IsConcurrencyToken() ;
22 }
23 }
24 }
紅色部分,我們把Student的LastChange屬性标記成并發令牌。
然後在依賴項中選擇Nuget包管理器,安裝 Pomelo.EntityFrameworkCore.MySql 改引用,該引用可以了解為Mysql的EF Core驅動。
安裝成功後,在appsettings.json檔案中寫入Mysql資料庫的連接配接字元串。寫入後,該檔案如下:其中紅色部分為連接配接字元串
1 {
2 "Logging": {
3 "IncludeScopes": false,
4 "Debug": {
5 "LogLevel": {
6 "Default": "Warning"
7 }
8 },
9 "Console": {
10 "LogLevel": {
11 "Default": "Warning"
12 }
13 }
14 },
15 "ConnectionStrings": { "Connection": "Data Source=127.0.0.1;Database=school;User ID=root;Password=123456;pooling=true;CharSet=utf8;port=3306;" }
16 }
然後,在Stutup.cs中對Mysql進行依賴注入:
1 using System;
2 using System.Collections.Generic;
3 using System.Linq;
4 using System.Threading.Tasks;
5 using Bingfa.Model;
6 using Microsoft.AspNetCore.Builder;
7 using Microsoft.AspNetCore.Hosting;
8 using Microsoft.EntityFrameworkCore;
9 using Microsoft.Extensions.Configuration;
10 using Microsoft.Extensions.DependencyInjection;
11 using Microsoft.Extensions.Logging;
12 using Microsoft.Extensions.Options;
13
14 namespace Bingfa
15 {
16 public class Startup
17 {
18 public Startup(IConfiguration configuration)
19 {
20 Configuration = configuration;
21 }
22
23 public IConfiguration Configuration { get; }
24
25 // This method gets called by the runtime. Use this method to add services to the container.
26 public void ConfigureServices(IServiceCollection services)
27 {
28 var connection = Configuration.GetConnectionString("Connection");
29 services.AddDbContext<SchoolContext>(options =>
30 {
31 options.UseMySql(connection);
32 options.UseLoggerFactory(new LoggerFactory().AddConsole());
33 });
34 services.AddMvc();
35 }
36
37 // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
38 public void Configure(IApplicationBuilder app, IHostingEnvironment env)
39 {
40 if (env.IsDevelopment())
41 {
42 app.UseDeveloperExceptionPage();
43 }
44
45 app.UseMvc();
46 }
47 }
48 }
其中,紅色字型部分即為對Mysql資料庫上下文進行注入,藍色背景部分,為将sql語句在控制台中輸出,便于我們檢視運作過程中的sql語句。
以上操作完成後,即可在資料庫中生成表了。打開程式包管理控制台,打開方式如下:
打開後分别輸入以下兩條指令:、
add-migration init
update-database
是分别輸入哦,不是一次輸入兩條,語句執行效果如圖:
執行完成後即可在Mysql資料庫中看到生成的資料表了,如圖。
最後,我們就要進行實際的業務處理過程的編碼了。打開ValuesController.cs的代碼,我修改後代碼如下
1 using System;
2 using System.Collections.Generic;
3 using System.Linq;
4 using System.Threading.Tasks;
5 using Bingfa.Model;
6 using Microsoft.AspNetCore.Mvc;
7
8 namespace Bingfa.Controllers
9 {
10 [Route("api/[controller]")]
11 public class ValuesController : Controller
12 {
13 private SchoolContext schoolContext;
14
15 public ValuesController(SchoolContext _schoolContext)//控制反轉,依賴注入
16 {
17 schoolContext = _schoolContext;
18 }
19
20 // GET api/values/5
21 [HttpGet("{id}")]
22 public Student Get(int id)
23 {
24 return schoolContext.students.Where(p => p.id == id).FirstOrDefault(); //通過Id擷取學生資料
25 }
26 [HttpGet]
27 public List<Student> Get()
28 {
29 return schoolContext.students.ToList(); //擷取所有的學生資料
30 }
31
32 // POST api/values
33 [HttpPost]
34 public string Post(Student student) //更新學生資料
35 {
36 if (student.id != 0)
37 {
38 try
39 {
40 Student studentDataBase = schoolContext.students.Where(p => p.id == student.id).FirstOrDefault(); //首先通過Id找到該學生
41
42 //如果查找到的學生的LastChanged與Post過來的資料的LastChanged的時間相同,則表示資料沒有修改過
43 //為了控制時間精度,對時間進行秒後取三位小數
44 if (studentDataBase.LastChanged.ToString("yyyy-MM-dd HH:mm:ss.fff").Equals(student.LastChanged.ToString("yyyy-MM-dd HH:mm:ss.fff")))
45 {
46 studentDataBase.LastChanged=DateTime.Now;//把資料的LastChanged更改成現在的時間
47 studentDataBase.Age = student.Age;
48 studentDataBase.Name = student.Name;
49 studentDataBase.Pwd = student.Pwd;
50 schoolContext.SaveChanges(); //儲存資料
51 }
52 else
53 {
54 throw new Exception("資料已經修改,請重新整理檢視");
55 //return "";
56 }
57 }
58 catch (Exception e)
59 {
60 return e.Message;
61 }
62 return "success";
63 }
64 return "沒有找到該Student";
65 }
66
67 // PUT api/values/5
68 [HttpPut("{id}")]
69 public void Put(int id, [FromBody]string value)
70 {
71
72 }
73
74 // DELETE api/values/5
75 [HttpDelete("{id}")]
76 public void Delete(int id)
77 {
78 }
79 }
80 }
主要代碼在Post方法中。
為了友善看到運作的Sql語句,我們需要把啟動程式更改成項目本身而不是IIS。如圖
啟動後效果如圖:
我們先往資料庫中插入一條資料
然後,通過通路http://localhost:56295/api/values/1即可擷取該條資料,如圖:
我們把該資料修改age成2之後,利用postMan把資料post到控制器,進行資料修改,如圖,修改成功
那麼,我們把age修改成3,LastChange的資料依然用第一次擷取到的時間進行Post,那麼傳回的結果如圖:
可以看到,執行了catch内的代碼,觸發了異常,沒有接受新的送出。
最後,我們看看加了并發鎖之後的sql語句:
從控制台中輸出的sql語句可以看到 對LastChanged屬性進行了篩選,隻有當LastChanged與取出該實體時一緻,該更新才會執行。
這就是樂觀鎖的實作過程。
并發通路測試程式
為了對該程式進行測試,我特意編寫了一個程式,多線程地對資料庫的資料進行get和post,模拟一個并發通路的過程,代碼如下:
1 using System;
2 using System.Net;
3 using System.Net.Http;
4 using System.Threading;
5 using Newtonsoft.Json;
6
7 namespace Test
8 {
9 class Program
10 {
11 static void Main(string[] args)
12 {
13 Console.WriteLine("輸入回車開始測試...");
14 Console.ReadKey();
15 ServicePointManager.DefaultConnectionLimit = 1000;
16 for (int i = 0; i < 10; i++)
17 {
18 Thread td = new Thread(new ParameterizedThreadStart(PostTest));
19 td.Start(i);
20 Thread.Sleep(new Random().Next(1,100));//随機休眠時長
21 }
22 Console.ReadLine();
23 }
24 public static void PostTest(object i)
25 {
26 try
27 {
28 string url = "http://localhost:56295/api/values/1";//擷取ID為1的student的資訊
29 Student student = JsonConvert.DeserializeObject<Student>(RequestHandler.HttpGet(url));
30 student.Age++;//對年齡進行修改
31 string postData = $"Id={ student.id}&age={student.Age}&Name={student.Name}&Pwd={student.Pwd}&LastChanged={student.LastChanged.ToString("yyyy-MM-dd HH:mm:ss.fff")}";
32 Console.WriteLine($"線程{i.ToString()}Post資料{postData}");
33 string r = RequestHandler.HttpPost("http://localhost:56295/api/values", postData);
34 Console.WriteLine($"線程{i.ToString()}Post結果{r}");
35 }
36 catch (Exception ex)
37 {
38 Console.WriteLine(ex.Message);
39 }
40
41 }
42 }
43 }
測試效果:
可以看到,部分修改成功了,部分沒有修改成功,這就是樂觀鎖的效果。
項目的完整代碼我已經送出到github,有興趣的可以通路以下位址檢視:
https://github.com/liuzhenyulive/Bingfa
第一次這麼認真地寫一篇文章,如果喜歡,請推薦支援,謝謝!