Redisson分布式鎖實現(xiàn)原理示例詳解
前言
說到redis的分布式鎖容易想到了setNx,好處是實現(xiàn)簡單,但是會有一些問題比如誤刪鎖問題、鎖不可重入問題。所以Redisson并沒有通過setNx命令來實現(xiàn)加鎖,而是自己實現(xiàn)了一套完成的加鎖的邏輯
加鎖與解鎖
RLock繼承了Java的lock接口,RedissonLock繼承自RedissonBaseLock(抽象類),而RedissonBaseLock又實現(xiàn)了RLock。
//調用getLock時候就是new了一個RedissonLock
public RLock getLock(String name) {
return new RedissonLock(this.commandExecutor, name);
}我們重點看lock方法:
public void lock() {
try {
this.lock(-1L, (TimeUnit)null, false);
} catch (InterruptedException var2) {
throw new IllegalStateException();
}
}
?
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
//調用了tryAcquire獲取鎖,這里傳入了-1L代表沒有指定鎖的釋放時間,正常情況下如果不釋放是永久持有。
Long ttl = this.tryAcquire(-1L, leaseTime, unit, threadId);
//以下代碼先忽略
//.....
}
?
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
//這里調用get,等待future返回。
return (Long)this.get(this.tryAcquireAsync0(waitTime, leaseTime, unit, threadId));
}
?
private RFuture<Long> tryAcquireAsync0(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
//使用了線程池去獲取鎖
return this.getServiceManager().execute(() -> {
return this.tryAcquireAsync(waitTime, leaseTime, unit, threadId);
});
}
//重點方法來了
private RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture ttlRemainingFuture;
if (leaseTime > 0L) {
//如果這里leaseTime不為0說明用戶設置了鎖的租約時間直接傳入。
ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
//如果這里leaseTime為0說明用戶沒有限制鎖的租約時間,但是這里仍然會傳30秒的持有時間
ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
?
CompletionStage<Long> s = this.handleNoSync(threadId, ttlRemainingFuture);
RFuture<Long> ttlRemainingFuture = new CompletableFutureWrapper(s);
CompletionStage<Long> f = ttlRemainingFuture.thenApply((ttlRemaining) -> {
//如果為空說明lua獲取鎖腳本獲得了鎖
if (ttlRemaining == null) {
//判斷是否開啟看門狗機制。
if (leaseTime > 0L) {
this.internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
this.scheduleExpirationRenewal(threadId);
}
}
?
return ttlRemaining;
});
return new CompletableFutureWrapper(f);
}
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return this.evalWriteSyncedNoRetryAsync(this.getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); "+
"return nil; "+
"end;"+
//返回鎖的過期時間。
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
}獲取鎖的整個邏輯是:

首先,如果用戶調用獲取鎖時候沒有限制租約時間,redisson會自動給tryLockInnerAsync加上一個30秒的租約時間,并調用scheduleExpirationRenewal進行看門狗機制
tryLockInnerAsync是執(zhí)行了一個lua腳本,首先redisson他采用的是hash來存放這個鎖,key是鎖的名字,field由UUID和線程id組成(UUID是區(qū)分不同客戶端,防止不同客戶端但是線程名恰好相同),value是鎖的重入次數(shù)。這樣就避免了鎖的誤刪,重入和死鎖問題了。lua的大致流程為:
- 先判斷當前鎖是否被持有或者持有者是否是當前線程,如果是的話重入次數(shù)加1,并設置/重置整個鎖 Key 的過期時間(防止死鎖)然后返回。
- 如果上面if不成立說明鎖被別人持有了,則返回當前鎖剩余的存活時間(TTL)??蛻舳四玫竭@個時間后,會等待這么久再重試。
回到剛剛我們忽略的代碼:
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = this.tryAcquire(-1L, leaseTime, unit, threadId);
//如果這里ttl不為空說明鎖被人占有了。
if (ttl != null) {
//此時使用pub/sub訂閱這個管道
CompletableFuture<RedissonLockEntry> future = this.subscribe(threadId);
this.pubSub.timeout(future);
//內部維護了一個Semaphore用于控制本地線程的阻塞和喚醒
RedissonLockEntry entry;
if (interruptibly) {
entry = (RedissonLockEntry)this.commandExecutor.getInterrupted(future);
} else {
entry = (RedissonLockEntry)this.commandExecutor.get(future);
}
?
try {
//死循環(huán)直到獲取鎖或者被中斷
while(true) {
//先樂觀查詢一次
ttl = this.tryAcquire(-1L, leaseTime, unit, threadId);
if (ttl == null) {
return;
}
//仍然沒有獲取到鎖則進入阻塞階段
if (ttl >= 0L) {
try {
//通過ttl時間判斷鎖還有多久釋放,從而判斷阻塞多久,避免cpu空轉帶來性能消耗,精準在鎖釋放時候喚醒
//當有人釋放鎖了,redisson監(jiān)聽到了之后調用entry.getLatch().release(),或者到達ttl了都會使線程被喚醒
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException var14) {
InterruptedException e = var14;
if (interruptibly) {
throw e;
}
//出現(xiàn)異常了,重新?lián)屾i
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else if (interruptibly) {
entry.getLatch().acquire();
} else {
entry.getLatch().acquireUninterruptibly();
}
}
} finally {
//最后終止訂閱。
this.unsubscribe(entry, threadId);
}
}
}解鎖的邏輯:
跟加鎖邏輯一樣都是異步轉換同步。
public RFuture<Void> unlockAsync(long threadId) {
String requestId = this.getServiceManager().generateId();
return this.getServiceManager().execute(() -> {
return this.unlockAsync0(threadId, requestId);
});
}
?
private RFuture<Void> unlockAsync0(long threadId, String requestId) {
CompletionStage<Boolean> future = this.unlockInnerAsync(threadId, requestId);
//處理異常
CompletionStage<Void> f = future.handle((res, e) -> {
this.cancelExpirationRenewal(threadId, res);
if (e != null) {
if (e instanceof CompletionException) {
throw (CompletionException)e;
} else {
throw new CompletionException(e);
}
} else if (res == null) {
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + this.id + " thread-id: " + threadId);
throw new CompletionException(cause);
} else {
return null;
}
});
return new CompletableFutureWrapper(f);
}
?
protected final RFuture<Boolean> unlockInnerAsync(long threadId, String requestId) {
if (requestId == null) {
requestId = this.getServiceManager().generateId();
}
?
MasterSlaveServersConfig config = this.getServiceManager().getConfig();
long timeout = ((long)config.getTimeout() + config.getRetryDelay().calcDelay(config.getRetryAttempts()).toMillis()) * (long)config.getRetryAttempts();
timeout = Math.max(timeout, 1L);
//異步釋放鎖
RFuture<Boolean> r = this.unlockInnerAsync(threadId, requestId, (int)timeout);
CompletionStage<Boolean> ff = r.thenApply((v) -> {
CommandAsyncExecutor ce = this.commandExecutor;
if (ce instanceof CommandBatchService) {
ce = new CommandBatchService(this.commandExecutor);
}
?
((CommandAsyncExecutor)ce).writeAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.DEL, new Object[]{this.getUnlockLatchName(this.id)});
if (ce instanceof CommandBatchService) {
((CommandBatchService)ce).executeAsync();
}
?
return v;
});
return new CompletableFutureWrapper(ff);
}
?
protected RFuture<Boolean> unlockInnerAsync(long threadId, String requestId, int timeout) {
return this.evalWriteSyncedNoRetryAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
//防重檢查(冪等性)
"local val = redis.call('get', KEYS[3]);"+
"if val ~= false then "+
"return tonumber(val);"+
"end;"+
//判斷是否持有鎖
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then "+
"return nil;"+
"end; "+
//扣減重入次數(shù)
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); "+
//判斷是“重入釋放”還是“徹底釋放”
"if (counter > 0) then "+
//重入次數(shù)減1
"redis.call('pexpire', KEYS[1], ARGV[2]); "+
"redis.call('set', KEYS[3], 0, 'px', ARGV[5]); "+
"return 0; "+
"else "+
//徹底釋放鎖
"redis.call('del', KEYS[1]); "+
//發(fā)送隊列消息
"redis.call(ARGV[4], KEYS[2], ARGV[1]); "+
"redis.call('set', KEYS[3], 1, 'px', ARGV[5]); "+
"return 1; "+
"end; ",
Arrays.asList(this.getRawName(), this.getChannelName(), this.getUnlockLatchName(requestId)), new Object[]{LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId), this.getSubscribeService().getPublishCommand(), timeout});
}lua的流程為:
先去查一下 KEYS[3](查看解鎖請求的結果緩存)是否存在。如果存在,說明這個請求之前已經處理過了(可能是網(wǎng)絡波動導致客戶端以為超時了,重發(fā)了請求)。直接返回之前緩存的結果(0 或 1),不需要再執(zhí)行后面的邏輯。這保證了冪等性。
- 檢查 Hash KEYS[1] 中是否存在當前線程 ARGV[3]。如果不存在(== 0),說明當前線程根本沒有持有這把鎖。返回 nil(Java 客戶端會拋出 IllegalMonitorStateException)。
- 將該線程的加鎖計數(shù)器減 1,counter 是減完之后剩下的次數(shù)。
- 如果counter > 0,說明鎖重入了。pexpire:刷新鎖的過期時間(看門狗時間),只要鎖還沒徹底釋放,就得給它續(xù)命。set KEYS[3] 0:記錄本次請求結果為 0(表示未完全釋放)。返回 0,代表還未釋放鎖。
- 如果counter<=0,說明鎖此時可以釋放了,這里會先刪除這個鎖對應的key,然后調用
redis.call(ARGV[4], KEYS[2], ARGV[1]);這里ARGC[4]其實通常是publish發(fā)送消息,之所以不寫死是因為集群下可以用spublish加快性能。這里發(fā)送消息是為了前文提到的,告訴監(jiān)聽者鎖已經釋放。set KEYS[3] 1:緩存本次請求結果為 1,并將1返回,代表鎖成功釋放了
這里之所以要多出一個KEY[3]是為了做冪等。KEY[3]命名一般為{鎖前綴}:{鎖名}:{requestId},每次請求時候都會帶上不同的requestId,vlaue是requestId請求的解鎖結果
存在一種可能,客戶端重入了2次鎖,客戶端第一次調用unlock時候,redis正常執(zhí)行了解鎖邏輯,并扣減了1次鎖記錄,但是由于網(wǎng)絡波動,響應丟失了,此時客戶端會重新發(fā)起一次請求,導致重復扣減。為了解決這個問題,就利用key[3],判斷一下相同的請求id是否有記錄如果是就代表確實發(fā)送了響應丟失,直接將上次的數(shù)據(jù)返回,避免重復解鎖。

公平鎖
以上鎖是默認非公平鎖,所有線程都去爭搶鎖,而公平鎖則是進入隊列等待,防止饑餓問題。
當我們getFairLock時候其實是new了一個RedissonFairLock
public RLock getFairLock(String name) {
return new RedissonFairLock(this.commandExecutor, name);
}RedissonFairLock繼承自RedissonLock也就是說加鎖的流程都是大致相同的。RedissonFairLock重寫了tryLockInnerAsync方法
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
long wait = this.threadWaitTime;
if (waitTime > 0L) {
wait = unit.toMillis(waitTime);
}
?
long currentTime = System.currentTimeMillis();
if (command == RedisCommands.EVAL_NULL_BOOLEAN) {
return this.commandExecutor.syncedEvalNoRetry(
this.getRawName(),
LongCodec.INSTANCE,
command,
// Lua腳本 - 用于獲取鎖
"while true do " +
" local firstThreadId2 = redis.call('lindex', KEYS[2], 0); " +
" if firstThreadId2 == false then " +
" break; " +
" end; " +
" local timeout = redis.call('zscore', KEYS[3], firstThreadId2); " +
" if timeout ~= false and tonumber(timeout) <= tonumber(ARGV[3]) then " +
" redis.call('zrem', KEYS[3], firstThreadId2); " +
" redis.call('lpop', KEYS[2]); " +
" else " +
" break; " +
" end; " +
"end; " +
"if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +
" redis.call('lpop', KEYS[2]); " +
" redis.call('zrem', KEYS[3], ARGV[2]); " +
" local keys = redis.call('zrange', KEYS[3], 0, -1); " +
" for i = 1, #keys, 1 do " +
" redis.call('zincrby', KEYS[3], -tonumber(ARGV[4]), keys[i]); " +
" end; " +
" redis.call('hset', KEYS[1], ARGV[2], 1); " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
" redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return nil; " +
"end; " +
"return 1;",
Arrays.asList(this.getRawName(), this.threadsQueueName, this.timeoutSetName),
new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId), currentTime, wait}
);
} else if (command == RedisCommands.EVAL_LONG) {
return this.commandExecutor.syncedEvalNoRetry(
this.getRawName(),
LongCodec.INSTANCE,
command,
// Lua腳本 - 用于嘗試獲取鎖并返回TTL
"while true do " +
" local firstThreadId2 = redis.call('lindex', KEYS[2], 0); " +
" if firstThreadId2 == false then " +
" break; " +
" end; " +
" local timeout = redis.call('zscore', KEYS[3], firstThreadId2); " +
" if timeout ~= false and tonumber(timeout) <= tonumber(ARGV[4]) then " +
" redis.call('zrem', KEYS[3], firstThreadId2); " +
" redis.call('lpop', KEYS[2]); " +
" else " +
" break; " +
" end; " +
"end; " +
"if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then " +
" redis.call('lpop', KEYS[2]); " +
" redis.call('zrem', KEYS[3], ARGV[2]); " +
" local keys = redis.call('zrange', KEYS[3], 0, -1); " +
" for i = 1, #keys, 1 do " +
" redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]); " +
" end; " +
" redis.call('hset', KEYS[1], ARGV[2], 1); " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return nil; " +
"end; " +
"if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then " +
" redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return nil; " +
"end; " +
"local timeout = redis.call('zscore', KEYS[3], ARGV[2]); " +
"if timeout ~= false then " +
" local ttl = redis.call('pttl', KEYS[1]); " +
" return math.max(0, ttl); " +
"end; " +
"local lastThreadId = redis.call('lindex', KEYS[2], -1); " +
"local ttl; " +
"if lastThreadId ~= false and lastThreadId ~= ARGV[2] and redis.call('zscore', KEYS[3], lastThreadId) ~= false then " +
" ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]); " +
"else " +
" ttl = redis.call('pttl', KEYS[1]); " +
"end; " +
"local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]); " +
"if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then " +
" redis.call('rpush', KEYS[2], ARGV[2]); " +
"end; " +
"return ttl;",
Arrays.asList(this.getRawName(), this.threadsQueueName, this.timeoutSetName),
new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId), wait, currentTime}
);
} else {
throw new IllegalArgumentException();
}
}這里有倆段lua腳本,第一段對應的是無參的tryLock()方法,進行一次快速嘗試,如果獲取不到鎖直接返回。第二段對應的是tryLock(waitTime)/lock這倆個阻塞等鎖的方法。這里我們重點看第二段lua腳本,第一個lua腳本和第二個類似:
--循環(huán)的判斷是否有waitTime已經超過的節(jié)點,如果有就剔除掉,防止占用著隊列
while true do
--查詢等待隊列頭節(jié)點是什么
local firstThreadId2 = redis.call('lindex', KEYS[2], 0);
--如果等待隊列為空則跳出循環(huán)
if firstThreadId2 == false then
break;
end;
--不為空,判斷一下是否超過了超時時間,如果是就刪除掉,沒有就跳出循環(huán)
local timeout = redis.call('zscore', KEYS[3], firstThreadId2);
if timeout ~= false and tonumber(timeout) <= tonumber(ARGV[4]) then
redis.call('zrem', KEYS[3], firstThreadId2);
redis.call('lpop', KEYS[2]);
else
break;
end;
end;
--判斷一下鎖是否沒有被其他線程持有并且等待隊列不存在(等待隊列為空)或者對頭是此線程,如果是則進入搶鎖。
if (redis.call('exists', KEYS[1]) == 0) and ((redis.call('exists', KEYS[2]) == 0) or (redis.call('lindex', KEYS[2], 0) == ARGV[2])) then
--將自己從隊列移除
redis.call('lpop', KEYS[2]);
redis.call('zrem', KEYS[3], ARGV[2]);
local keys = redis.call('zrange', KEYS[3], 0, -1);
--循環(huán)整個隊列,更新所有節(jié)點的超時時間(減少等待預算)
for i = 1, #keys, 1 do
redis.call('zincrby', KEYS[3], -tonumber(ARGV[3]), keys[i]);
end;
--真正的加鎖邏輯,加鎖后直接返回。
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
--返回nil說明搶鎖成功
return nil;
end;
--前面判斷為false,說明鎖被持有了,判斷一下鎖是否被本線程持有,如果是重入加1并返回
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
--走到這里說明鎖被其他人持有了,此時判斷一下本線程是否已經在排隊了
local timeout = redis.call('zscore', KEYS[3], ARGV[2]);
if timeout ~= false then
--如果在排隊了,則返回鎖的剩余持有時間
local ttl = redis.call('pttl', KEYS[1]);
return math.max(0, ttl);
end;
--走到這里說明沒有入隊,先查看一下隊尾節(jié)點
local lastThreadId = redis.call('lindex', KEYS[2], -1);
local ttl;
--如果隊尾節(jié)點存在(鎖被持有,有線程等待搶鎖)則ttl是錢一個人的超時時間-當前時間。
if lastThreadId ~= false and lastThreadId ~= ARGV[2] and redis.call('zscore', KEYS[3], lastThreadId) ~= false then
ttl = tonumber(redis.call('zscore', KEYS[3], lastThreadId)) - tonumber(ARGV[4]);
else
--如果隊尾節(jié)點不存在(鎖被持有,且沒有線程等待搶鎖)則ttl就是鎖的過期時間
ttl = redis.call('pttl', KEYS[1]);
end;
local timeout = ttl + tonumber(ARGV[3]) + tonumber(ARGV[4]);
--然后入隊,將計算好的超時時間放入Zset,并將本線程放入隊尾。
if redis.call('zadd', KEYS[3], timeout, ARGV[2]) == 1 then
redis.call('rpush', KEYS[2], ARGV[2]);
end;
--返回告訴客戶端,還需要時間為ttl,使用Semaphore去阻塞。(喚醒過程和前文提到過的一樣)
return ttl;此過程中用到了Zset,List,我們直到List用于存放資源爭搶者,那Zset又是干嘛的?
List 雖然能完美實現(xiàn) FIFO(先進先出),但它有2個致命弱點:
- 不好判斷過期時間:List 只能告訴你誰排第一,但不能告訴你他還是不是活的(有沒有超時),如果非要利用List存儲過期時間就得通過value去分割,不僅需要占用cpu資源而且不好判斷如何分割,萬一用戶命名不規(guī)范導致分割錯誤,此時需要Zset,它存儲了每個人的“死亡時間”,用來在每次操作前清理 List 里的僵尸節(jié)點。member是uuid+線程id,score是過期時間
- 不好判斷是否當前線程在隊列中:List需要O(n)判斷,時間慢,通過Zset的dict數(shù)據(jù)結構O(1)判斷。
此外,由于每一個節(jié)點有可能是lock()這種無等待時間,會阻塞到一直獲取鎖,在Zset中難道我們要將Zset的過期分數(shù)設置很大或者設置為-1代表沒有過期時間嗎?那如果客戶端宕機了,該節(jié)點不就占用這個Zset和list的節(jié)點,且誰也清除不掉,導致內存泄露。且輪到它作為頭節(jié)點時候,又不會搶鎖,導致全部都在死等。
舊版本的redisson是默認給5秒過期時間,每次5秒后就刷新一下,如果客戶端宕機了就不會刷新,這個節(jié)點會被清除掉。但是當競爭激烈的時候5秒獲取不到鎖時候大量的線程醒過來同時去更新過期時間,這個驚群效應會導致性能急劇下降,后續(xù)新版本改為了5分鐘。
如圖,比起非公平鎖多了倆個數(shù)據(jù)結構

總結
到此這篇關于Redisson分布式鎖實現(xiàn)原理的文章就介紹到這了,更多相關Redisson分布式鎖實現(xiàn)原理內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
springboot不掃描@repository的問題及解決
這篇文章主要介紹了springboot不掃描@repository的問題及解決,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-05-05
Java高效實現(xiàn)CSV轉Excel的三種方法詳解
?在日常開發(fā)工作中,CSV 文件作為一種簡單、輕便的數(shù)據(jù)存儲格式,常被用于數(shù)據(jù)交換和存儲,本篇文章將分享幾種基于 Java 的 CSV 轉 Excel 方法,希望對大家有所幫助2026-02-02
SpringBoot 指標監(jiān)控actuator的專題
未來每一個微服務在云上部署以后,我們都需要對其進行監(jiān)控、追蹤、審計、控制等。SpringBoot就抽取了Actuator場景,使得我們每個微服務快速引用即可獲得生產級別的應用監(jiān)控、審計等功能,通讀本篇對大家的學習或工作具有一定的價值,需要的朋友可以參考下2021-11-11
JAVA String轉化成java.sql.date和java.sql.time方法示例
這篇文章主要給大家分享了關于JAVA String轉化成java.sql.date和java.sql.time的方法,文中給出了詳細的示例代碼,相信對大家具有一定的參考價值,需要的朋友們下面來一起看看吧。2017-03-03

