小故事
在開始講這篇文章之前,我們來說一個小故事,純素虛構(真實的存錢邏輯並非如此)
小劉發工資后,趕忙拿着現金去銀行,准備把錢存起來,而與此同時,小劉的老婆劉嫂知道小劉的品性,知道他發工資的日子,也知道他喜歡一發工資就去銀行存起來,擔心小劉卡里存的錢太多拿去“大寶劍”,於是,也去了銀行,想趁着小劉把錢存進去后就把錢給取出來,省的夜長夢多。
小劉與劉嫂取得是兩家不同的銀行的ATM,所以兩人沒有碰面。
小劉插入銀行卡存錢之前查詢了自己的余額,ATM這樣顯示的:
與次同時,劉嫂也通過卡號和密碼查詢該卡內的余額,也是這么顯示的:
劉嫂,很生氣,沒想到小劉偷偷藏了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
第一次這么認真地寫一篇文章,如果喜歡,請推薦支持,謝謝!