狠狠色丁香婷婷综合尤物/久久精品综合一区二区三区/中国有色金属学报/国产日韩欧美在线观看 - 国产一区二区三区四区五区tv

LOGO OA教程 ERP教程 模切知識交流 PMS教程 CRM教程 開發文檔 其他文檔  
 
網站管理員

SQL Server 2000+ADO.NET實現并發控制

admin
2011年3月3日 16:32 本文熱度 3268
1 并發一致性問題

常見并發并發一致性問題包括:丟失的修改、不可重復讀、讀臟數據、幻影讀(幻影讀在一些資料中往往與不可重復讀歸為一類)。


1.1 丟失修改


下面我們先來看一個例子,說明并發操作帶來的數據的不一致性問題。


考慮飛機訂票系統中的一個活動序列:



  1. 甲售票點(甲事務)讀出某航班的機票余額A,設A=16.
  2. 乙售票點(乙事務)讀出同一航班的機票余額A,也為16.
  3. 甲售票點賣出一張機票,修改余額A←A-1.所以A為15,把A寫回數據庫.
  4. 乙售票點也賣出一張機票,修改余額A←A-1.所以A為15,把A寫回數據庫.

結果明明賣出兩張機票,數據庫中機票余額只減少1。


歸納起來就是:兩個事務T1和T2讀入同一數據并修改,T2提交的結果破壞了T1提交的結果,導致T1的修改被丟失。前文(2.1.4數據刪除與更新)中提到的問題及解決辦法往往是針對此類并發問題的。但仍然有幾類問題通過上面的方法解決不了,那就是:


1.2 不可重復讀


不可重復讀是指事務T1讀取數據后,事務T2執行更新操作,使T1無法再現前一次讀取結果。具體地講,不可重復讀包括三種情況:



  • 事務T1讀取某一數據后,事務T2對其做了修改,當事務1再次讀該數據時,得到與前一次不同的值。例如,T1讀取B=100進行運算,T2讀取同一數據B,對其進行修改后將B=200寫回數據庫。T1為了對讀取值校對重讀B,B已為200,與第一次讀取值不一致。
  • 事務T1按一定條件從數據庫中讀取了某些數據記錄后,事務T2刪除了其中部分記錄,當T1再次按相同條件讀取數據時,發現某些記錄神密地消失了。
  • 事務T1按一定條件從數據庫中讀取某些數據記錄后,事務T2插入了一些記錄,當T1再次按相同條件讀取數據時,發現多了一些記錄。(這也叫做幻影讀)

1.3 讀"臟"數據


讀"臟"數據是指事務T1修改某一數據,并將其寫回磁盤,事務T2讀取同一數據后,T1由于某種原因被撤消,這時T1已修改過的數據恢復原值,T2讀到的數據就與數據庫中的數據不一致,則T2讀到的數據就為"臟"數據,即不正確的數據。


產生上述三類數據不一致性的主要原因是并發操作破壞了事務的隔離性。并發控制就是要用正確的方式調度并發操作,使一個用戶事務的執行不受其它事務的干擾,從而避免造成數據的不一致性。


2 并發一致性問題的解決辦法


2.2.2.1 封鎖(Locking)


封鎖是實現并發控制的一個非常重要的技術。所謂封鎖就是事務T在對某個數據對象例如表、記錄等操作之前,先向系統發出請求,對其加鎖。加鎖后事務T就對該數據對象有了一定的控制,在事務T釋放它的鎖之前,其它的事務不能更新此數據對象。


基本的封鎖類型有兩種:排它鎖(Exclusive locks 簡記為X鎖)和共享鎖(Share locks 簡記為S鎖)。


排它鎖又稱為寫鎖。若事務T對數據對象A加上X鎖,則只允許T讀取和修改A,其它任何事務都不能再對A加任何類型的鎖,直到T釋放A上的鎖。這就保證了其它事務在T釋放A上的鎖之前不能再讀取和修改A。


共享鎖又稱為讀鎖。若事務T對數據對象A加上S鎖,則其它事務只能再對A加S鎖,而不能加X鎖,直到T釋放A上的S鎖。這就保證了其它事務可以讀A,但在T釋放A上的S鎖之前不能對A做任何修改。


2.2.2.2 封鎖協議


在運用X鎖和S鎖這兩種基本封鎖,對數據對象加鎖時,還需要約定一些規則,例如應何時申請X鎖或S鎖、持鎖時間、何時釋放等。我們稱這些規則為封鎖協議(Locking Protocol)。對封鎖方式規定不同的規則,就形成了各種不同的封鎖協議。下面介紹三級封鎖協議。三級封鎖協議分別在不同程度上解決了丟失的修改、不可重復讀和讀"臟"數據等不一致性問題,為并發操作的正確調度提供一定的保證。下面只給出三級封鎖協議的定義,不再做過多探討。



  • 1級封鎖協議

1級封鎖協議是:事務T在修改數據R之前必須先對其加X鎖,直到事務結束才釋放。事務結束包括正常結束(COMMIT)和非正常結束(ROLLBACK)。1級封鎖協議可防止丟失修改,并保證事務T是可恢復的。在1級封鎖協議中,如果僅僅是讀數據不對其進行修改,是不需要加鎖的,所以它不能保證可重復讀和不讀"臟"數據。



  • 2級封鎖協議

2級封鎖協議是:1級封鎖協議加上事務T在讀取數據R之前必須先對其加S鎖,讀完后即可釋放S鎖。2級封鎖協議除防止了丟失修改,還可進一步防止讀"臟"數據。



  • 3級封鎖協議

3級封鎖協議是:1級封鎖協議加上事務T在讀取數據R之前必須先對其加S鎖,直到事務結束才釋放。3級封鎖協議除防止了丟失修改和不讀'臟'數據外,還進一步防止了不可重復讀。


2.3 事務隔離級別


盡管數據庫理論對并發一致性問題提供了完善的解決機制,但讓程序員自己去控制如何加鎖以及加鎖、解鎖的時機顯然是很困難的事情。索性絕大多數數據庫以及開發工具都提供了事務隔離級別,讓用戶以一種更輕松的方式處理并發一致性問題。常見的事務隔離級別包括:ReadUnCommitted、ReadCommitted、RepeatableRead和Serializable四種。不同的隔離級別下對數據庫的訪問方式以及數據庫的返回結果有可能是不同的。我們將通過幾個實驗深入了解事務隔離級別以及SQL Server在后臺是如何將它們轉換成鎖的。


2.3.1 ReadUnCommitted與ReadCommitted


ReadUnCommitted是最低的隔離級別,這個級別的隔離允許讀入別人尚未提交的臟數據,除此之外,在這種事務隔離級別下還存在不可重復讀的問題。


ReadCommitted是許多數據庫的缺省級別,這個隔離級別上,不會出現讀取未提交的數據問題,但仍然無法避免不可重復讀(包括幻影讀)的問題。當你的系統對并發控制的要求非常嚴格時,這種默認的隔離級別可能無法提供數據有效的保護,但對于決大多數應用來講,這種隔離級別就夠用了。


我們使用下面的實驗來進行測試:


首先配置SQL Server 2000數據庫,附加DBApp數據庫。然后在Visual Studio .net中建立一管理控制臺應用程序,添加必要的命名空間引用:

using System;using System.Data;using System.Data.SqlClient;using System.Configuration;

然后建立兩個數據庫鏈接,并分別采用不同的事務隔離級別:

   private static SqlConnection conn1;private static SqlConnection conn2;private static SqlTransaction tx1;private static SqlTransaction tx2;private static void Setup(){conn1 = new SqlConnection(connectionString);conn1.Open();tx1 = conn1.BeginTransaction(IsolationLevel.ReadUncommitted);conn2 = new SqlConnection(connectionString);conn2.Open();tx2 = conn2.BeginTransaction(IsolationLevel.ReadCommitted);}

其中事務1允許讀入未提交的數據,而事務2只允許讀入已提交數據。


在主程序中,我們模擬兩個人先后的不同操作,以產生并發一致性問題:

   public static void Main(){Setup();try{ReadUnCommittedDataByTransaction1();UnCommittedUpdateByTransaction2();ReadUnCommittedDataByTransaction1();tx2.Rollback();Console.WriteLine("\n-- Transaction 2 rollbacked!\n");ReadUnCommittedDataByTransaction1();tx1.Rollback();}catch{……}}

第一步,使用ReadUnCommittedDataByTransaction1方法利用事務1從數據庫中讀入id值為1的學生信息。此時的信息是數據庫的初始信息。


第二步,調用UnCommittedUpdateByTransaction2方法,從第2個事務中發送一UPDATE命令更新數據庫,但尚未提交。


第三步,再次調用ReadUnCommittedDataByTransaction1,從事務1中讀取數據庫數據,你會發現由事務2發布的尚未提交的更新被事務1讀取出來(ReadUnCommitted)。


第四步,事務2放棄提交,回滾事務tx2.Rollback();。


第五步,再次調用ReadUnCommittedDataByTransaction1();,讀取數據庫中的數據,此次是已經回滾后的數據。


程序運行結果如下:

-- Read age from database:Age:20-- Run an uncommitted command:UPDATE student SET age=30 WHERE id=1-- Read age from database:Age:30-- Transaction 2 rollbacked!-- Read age from database:Age:20

關于ReadUnCommittedDataByTransaction1()與UnCommittedUpdateByTransaction2()的方法定義如下:

   private static void UnCommittedUpdateByTransaction2(){string command = "UPDATE student SET age=30 WHERE id=1";Console.WriteLine("\n-- Run an uncommitted command:\n{0}\n", command);SqlCommand cmd = new SqlCommand(command, conn2);cmd.Transaction = tx2;cmd.ExecuteNonQuery();}private static void ReadUnCommittedDataByTransaction1(){Console.WriteLine("-- Read age from database:");SqlCommand cmd = new SqlCommand("SELECT age FROM student WHERE id = 1", conn1);cmd.Transaction = tx1;try{int age = (int)cmd.ExecuteScalar();Console.WriteLine("Age:{0}", age);}catch(SqlException e){Console.WriteLine(e.Message);}}

從上面的實驗可以看出,在ReadUnCommitted隔離級別下,程序可能讀入未提交的數據,但此隔離級別對數據庫資源鎖定最少。


本實驗的完整代碼可以從"SampleCode\Chapter 2\Lab 2-6"下找到。


讓我們再來做一個實驗(這個實驗要求動作要快的,否則可能看不到預期效果)。首先修改上面代碼中的Setup()方法代碼,將


tx1 = conn1.BeginTransaction(IsolationLevel.ReadUncommitted);


改為:


tx1 = conn1.BeginTransaction(IsolationLevel.ReadCommitted);


再次運行代碼,你會發現程序執行到第三步就不動了,如果你有足夠的耐心等下去的話,你會看到"超時時間已到。在操作完成之前超時時間已過或服務器未響應。"的一條提示,這條提示究竟是什么意思呢?讓我們探察一下究竟發生了什么:


第一步,在做這個實驗之前,先將SQL Server 2000的企業管理器打開,然后再將SQL Server事件探察器打開并處于探察狀態。


第二步,運行改動后的程序,程序執行到一半就暫停了。此時迅速切換到企業管理器界面,右擊"管理"下面的"當前活動",選擇"刷新"(整個過程應在大約15秒內完成即可,如圖 2-8所示),我們便得到了數據庫當前進程的一個快照。



 


圖 2-8 使用企業管理器查看當前活動


我們發現此時進程出現了阻塞,被阻塞者是52號進程,而阻塞者是53號進程。也就是說53號進程的工作妨礙了52號進程繼續工作。(不同實驗時進程號可能各不相同)


第三步,為了進一步查明原因真相,我們切換到事件探察器窗口,看看這兩個進程都是干什么的。如圖 2-9所示,事件探察器顯示了這兩個進程的詳細信息。從圖中我們可以看出,52號進程對應我們的事務1,53號進程對應我們的事務2。事務2執行了UPDATE命令,但尚未提交,此時事務1去讀尚未提交的數據便被阻塞住。從圖中我們可以看出52號進程是被阻塞者。


此時如果事務2完成提交,52號進程便可以停止等待,得到需要的結果。然而我們的程序沒有提交數據,因此52號進程就要無限等下去。所幸SQL Server 2000檢測到事務2的運行時間過長(這就是上面的錯誤提示"超時時間已到。在操作完成之前超時時間已過或服務器未響應。"),所以將事務2回滾以釋放占用的資源。資源被釋放后,52號進程便得以執行。



 


圖 2-9 事件探察器探察阻塞命令


第四步,了解了上面發生的事情后,我們現在可以深入討論一下共享鎖和排它鎖的使用情況了。重新回到企業管理器界面,讓我們查看一下兩個進程各占用了什么資源。從圖 2-10中我們可以看出,53號進程(事務2)在執行更新命令前對相應的鍵加上了排它鎖(X鎖),按照前文提到的1級封鎖協議,該排它鎖只有在事務2提交或回滾后才釋放。現在52號進程(事務1)要去讀同一行數據,按照2級封鎖協議,它要首先對該行加共享鎖,然而 該行數據已經被事務2加上了排它鎖,因此事務1只能處于等待狀態,等待排它鎖被釋放。因此我們就看到了前面的"阻塞"問題。



 


圖 2-10 進程執行寫操作前首先加了排它鎖



 


 


圖 2-11 進程讀操作前要加共享鎖,但被阻塞


 


當事務1的事務隔離級別是ReadUnCommitted時,讀數據是不加鎖的,因此排它鎖對ReadUnCommitted不起作用,進程也不會被阻塞,不過確讀到了"臟"數據。


2.3.2 RepeatableRead


RepeatableRead是指可重復讀,它的隔離級別要比ReadCommitted級別高。它允許某事務執行重復讀時數據保持不變,但是仍然無法解決幻影讀的問題。為了更深入的了解RepeatableRead所能解決的問題,我們還是使用下面的實驗來加以印證:


第一步,事務1與事務2同時設置為ReadCommitted,并同時開啟事務。


 

private static void Setup(){conn1 = new SqlConnection(connectionString);conn1.Open();tx1 = conn1.BeginTransaction(IsolationLevel.ReadCommitted);conn2 = new SqlConnection(connectionString);conn2.Open();tx2 = conn2.BeginTransaction(IsolationLevel.ReadCommitted);}

第二步,事務1讀取數據庫中數據。注意此時并沒有通過提交或回滾的方式結束事務1,事務1仍然處于活動狀態。

private static int ReadAgeByTransaction1(){return (int)ExecuteScalar("SELECT age FROM student WHERE (id = 1)");}private static object ExecuteScalar(string command){Console.WriteLine("-- Execute command: {0}", command);SqlCommand cmd = new SqlCommand(command, conn1);cmd.Transaction = tx1;return cmd.ExecuteScalar();}

第三步,事務2修改年齡數據并提交修改。

private static void ModifyAgeByTransaction2(){string command = "UPDATE student SET age=30 WHERE id=1";Console.WriteLine("-- Modify age by transaction2, command:{0}", command);SqlCommand cmd = new SqlCommand(command, conn2);cmd.Transaction = tx2;try{cmd.ExecuteNonQuery();tx2.Commit();}catch(Exception e){Console.WriteLine(e.Message);tx2.Rollback();}}

第四步,事務1重復讀取年齡數據,此時會發現讀取出來的數據是修改過的數據,與上次讀取的數據不一樣了!顧名思義,不可重復讀。主程序代碼如下:

public static void Main(){Setup();try{int age1 = ReadAgeByTransaction1();ModifyAgeByTransaction2();int age2 = ReadAgeByTransaction1();Console.WriteLine("\nFirst Read: age={0}\nSecond Read: age={1}", age1, age2);}catch(Exception e){Console.WriteLine("Got an error! " + e.Message);}finally{CleanUp();}}

程序的運行結果如下:

-- Execute command: SELECT age FROM student WHERE (id = 1)-- Modify age by transaction2, command:UPDATE student SET age=30 WHERE id=1-- Execute command: SELECT age FROM student WHERE (id = 1)First Read: age=20Second Read: age=30

之所以出現了重復讀時讀取的數據與第一次讀取的不一樣,是因為事務1被設置成了ReadCommitted隔離類型,該隔離級別無法防止不可重復讀的問題。要想在一個事務中兩次讀取數據完全相同就必須使用RepeatableRead事務隔離級別。


讓我們修改上面的Setup()方法中的代碼,將事務1的隔離級別設置為RepeatableRead:

tx1 = conn1.BeginTransaction(IsolationLevel.RepeatableRead);

再次運行該程序,你會發現程序執行到第二步就暫停了,如果等待一段時間后你就會看到"超時時間已到。在操作完成之前超時時間已過或服務器未響應。"的錯誤提示,此時,重復讀的數據確和第一次讀完全一樣。程序執行結果如下:

-- Execute command: SELECT age FROM student WHERE (id = 1)-- Modify age by transaction2, command:UPDATE student SET age=30 WHERE id=1超時時間已到。在操作完成之前超時時間已過或服務器未響應。-- Execute command: SELECT age FROM student WHERE (id = 1)First Read: age=20Second Read: age=20

為了探明原因,還是象上一個案例一樣,再次執行該程序,當出現暫停時迅速切換到企業管理器中查看當前活動的快照,并檢查阻塞進程中數據鎖定情況,你會發現如圖 2-12和圖 2-13所示的內容:



 


圖 2-12 RepeatableRead在讀數據時加S鎖,直到事務結束才釋放


 



圖 2-13 修改數據要求加X鎖,但被阻塞


根據3級封鎖協議,事務T在讀取數據之前必須先對其加S鎖,直到事務結束才釋放。因此,事務1在第一次讀取數據時便對數據加上了共享鎖,第一次數據讀取完成后事務并未結束,因此該共享鎖并不會被釋放,此時事務2試圖修改該數據,按照2級封鎖協議,在寫之前要加排它鎖,但數據上的共享鎖尚未被釋放,導致事務2不得不處于等待狀態。當事務2等待時間超時后,SQL Server就強制將該事務回滾。盡管事務2執行失敗,但保證了事務1實現了可重復讀級別的事務隔離。


RepeatableRead事務隔離級別允許事務內的重復讀操作,但是這并不能避免出現幻影讀的問題,如果您的程序中存在幻影讀的潛在問題的話,就必須采用最高的事務隔離級別:Serializable。


2.3.3 Serializable


Serializable隔離級別是最高的事務隔離級別,在此隔離級別下,不會出現讀臟數據、不可重復讀和幻影讀的問題。在詳細說明為什么之前首先讓我們看看什么是幻影讀。


所謂幻影讀是指:事務1按一定條件從數據庫中讀取某些數據記錄后,事務2插入了一些符合事務1檢索條件的新記錄,當事務1再次按相同條件讀取數據時,發現多了一些記錄。讓我們通過以下案例來重現幻影讀的問題:


第一步,將事務1和事務2均設為RepeatableRead隔離級別,并同時開啟事務。

private static void Setup(){conn1 = new SqlConnection(connectionString);conn1.Open();tx1 = conn1.BeginTransaction(IsolationLevel.RepeatableRead);conn2 = new SqlConnection(connectionString);conn2.Open();tx2 = conn2.BeginTransaction(IsolationLevel.RepeatableRead);}

第二步,事務1讀取學號為1的學生的平均成績以及所學課程的門數。此時讀到學生1學了3門課程,平均成績為73.67。注意,此時事務1并未提交。

private static double ReadAverageMarksByTransaction1(){return (double)ExecuteScalar("SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)");}private static int ReadTotalCoursesByTransaction1(){return (int)ExecuteScalar("SELECT COUNT(*) AS num FROM SC WHERE (id = 1)");}private static object ExecuteScalar(string command){Console.WriteLine("-- Execute command: {0}", command);SqlCommand cmd = new SqlCommand(command, conn1);cmd.Transaction = tx1;return cmd.ExecuteScalar();}

第三步,事務2向數據庫插入一條新記錄,讓學號為1的同學再學1門課程,成績是80。然后提交修改到數據庫。

private static void InsertRecordByTransaction2(){string command = "INSERT INTO SC VALUES(1, 5, 80)";Console.WriteLine("-- Insert to table SC by transaction 2");Console.WriteLine("-- Command:{0}\n", command);SqlCommand cmd = new SqlCommand(command, conn2);cmd.Transaction = tx2;try{cmd.ExecuteNonQuery();tx2.Commit();}catch(Exception e){Console.WriteLine(e.Message);tx2.Rollback();}}

第四步,事務1再次讀取學號為1的學生的平均成績以及所學課程的門數。此時讀到確是4門課程,平均成績為75.25。與第一次讀取的不一樣!居然多出了一門課程,多出的這門課程就像幻影一樣出現在我們的面前。測試用主程序如下:

public static void Main(){Setup();try{Console.WriteLine(">>>> Step 1");double avg = ReadAverageMarksByTransaction1();int total = ReadTotalCoursesByTransaction1();Console.WriteLine("avg={0,5:F2}, total={1}\n", avg, total);Console.WriteLine(">>>> Step 2");InsertRecordByTransaction2();Console.WriteLine(">>>> Step 3");avg = ReadAverageMarksByTransaction1();total = ReadTotalCoursesByTransaction1();Console.WriteLine("avg={0,5:F2}, total={1}\n", avg, total);}catch(Exception e){Console.WriteLine("Got an error! " + e.Message);}finally{CleanUp();}}

程序執行結果如下:

>>>> Step 1-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)avg=73.67, total=3>>>> Step 2-- Insert to table SC by transaction 2-- Command:INSERT INTO SC VALUES(1, 5, 80)>>>> Step 3-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)avg=75.25, total=4

大家可以思考一下,為什么RepeatableRead隔離模式并不能使得兩次讀取的平均值一樣呢?(可以從鎖的角度來解釋這一現象)。


仍然象前面的做法一樣,我們看看究竟發生了什么事情。在探察之前,先將Setup方法中事務1的隔離級別設置為Serializable,再次運行程序,當發現程序運行暫停時,查看數據庫當前活動快照,你會發現如圖 2-14和圖 2-15所示的鎖定問題:



 


圖 2-14 Serializable隔離模式對符合檢索條件的數據添加了RangeS-S鎖



 


圖 2-15 當試圖插入符合RangeIn條件的記錄時,只能處于等待狀態


從圖中我們可以看出,在Serializalbe隔離模式下,數據庫在檢索數據時,對所有滿足檢索條件的記錄均加上了RangeS-S共享鎖。事務2試圖去插入一滿足RangeIn條件的記錄時,必須等待這些RangS-S鎖釋放,否則就只能處于等待狀態。在等待超時后,事務2就會被SQL Server強制回滾。


修改后的程序運行結果如下:

>>>> Step 1-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)avg=73.67, total=3>>>> Step 2-- Insert to table SC by transaction 2-- Command:INSERT INTO SC VALUES(1, 5, 80)超時時間已到。在操作完成之前超時時間已過或服務器未響應。>>>> Step 3-- Execute command: SELECT AVG(mark) AS AvgMark FROM SC WHERE (id = 1)-- Execute command: SELECT COUNT(*) AS num FROM SC WHERE (id = 1)avg=73.67, total=3

事務2的運行失敗確保了事務1不會出現幻影讀的問題。這里應當注意的是,1、2、3級封鎖協議都不能保證有效解決幻影讀的問題。


2.3 建議


通過上面的幾個例子,我們更深入的了解了數據庫在解決并發一致性問題時所采取的措施。鎖機制屬于最底層的保證機制,但很難直接使用。我們可以通過不同的事務隔離模式來間接利用鎖定機制確保我們數據的完整一致性。在使用不同級別的隔離模式時,我們也應當注意以下一些問題:



  • 一般情況下ReadCommitted隔離級別就足夠了。過高的隔離級別將會鎖定過多的資源,影響數據的共享效率。
  • 你所選擇的隔離級別依賴于你的系統和商務邏輯。
  • 盡量避免直接使用鎖,除非在萬不得已的情況下。
  • 我們可以通過控制WHERE短語中的字段實現不同的更新策略,防止出現丟失的修改問題。但不必要的更新策略可能造成SQL命令執行效率低下。所以要慎用時間戳和過多的保護字段作為更新依據。 

該文章在 2011/3/3 16:32:47 編輯過
關鍵字查詢
相關文章
正在查詢...
點晴ERP是一款針對中小制造業的專業生產管理軟件系統,系統成熟度和易用性得到了國內大量中小企業的青睞。
點晴PMS碼頭管理系統主要針對港口碼頭集裝箱與散貨日常運作、調度、堆場、車隊、財務費用、相關報表等業務管理,結合碼頭的業務特點,圍繞調度、堆場作業而開發的。集技術的先進性、管理的有效性于一體,是物流碼頭及其他港口類企業的高效ERP管理信息系統。
點晴WMS倉儲管理系統提供了貨物產品管理,銷售管理,采購管理,倉儲管理,倉庫管理,保質期管理,貨位管理,庫位管理,生產管理,WMS管理系統,標簽打印,條形碼,二維碼管理,批號管理軟件。
點晴免費OA是一款軟件和通用服務都免費,不限功能、不限時間、不限用戶的免費OA協同辦公管理系統。
Copyright 2010-2025 ClickSun All Rights Reserved