Building a Lock Points to consider when implementing a distributed lock:
Support retries and renewal, and ensure consistency between lock acquisition and release.
What should happen if Redis cannot be reached? What should happen if a thread fails to acquire the lock?
Business Logic Around Lock Acquisition
Include business-required retries and handling for Redis connection failures and unsuccessful lock acquisition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public static <T, R> R runWithLock (Function<T, R> function, T req, Class<? extends AssuranceSyncHandler<T,R>> clazz, int expiredSec, String... factors) { AssuranceSyncHandler<T, R> zedSynchronizedHandler = getSyncHandlerFromClazz(clazz); ZedBody zedBody = buildBody(req, expiredSec, factors); boolean lockSuc = tryLock(zedBody, zedSynchronizedHandler); if (!lockSuc && !retryLock(zedBody, zedSynchronizedHandler)) { LOGGER.info("thread can not get lock, retry times is {}" , zedSynchronizedHandler.getRetryTimes()); if (SmartZedThreadLocal.isSynchronizedLockInvalid()) { LOGGER.error("lock failed because of the system error, so func apply directly" ); SmartZedThreadLocal.clear(zedBody.getRowKey()); return function.apply(req); } return zedSynchronizedHandler.strategyWhenSync(req); } R result; try { result = function.apply(req); } catch (Exception ex) { LOGGER.error("AssuranceLockUtils.runWithLock error when apply func" , ex); throw ex; } finally { tryUnLock(zedBody); } return result; } @SuppressWarnings("unchecked") private static <T, R> AssuranceSyncHandler<T, R> getSyncHandlerFromClazz (Class<? extends AssuranceSyncHandler<T,R>> clazz) { return Optional.ofNullable(clazz) .map(ApplicationContextUtil::getBean) .map(e -> (AssuranceSyncHandler<T, R>)e) .orElse((AssuranceSyncHandler<T, R>)defaultHandler); } private static boolean retryLock (ZedBody zedBody, AssuranceSyncHandler<?,?> handler) { int retryTimes = 0 ; boolean lockSuc = false ; while (!lockSuc && handler.getRetryTimes() > retryTimes++) { lockSuc = tryLock(zedBody, handler); } return lockSuc; }
Internal Lock Acquisition
Control the granularity of the internal lock, and consider what happens if releasing either of the two locks fails .
An internal lock may not be necessary. With low contention, each call can acquire the Redis lock anyway, so adding a local lock reduces performance. Consider an internal lock when contention on a single machine is high.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 private static boolean tryLock (ZedBody zedBody, AssuranceSyncHandler<?,?> zedSynchronizedHandler) { boolean ans = false ; boolean innerTryLock = false ; try { innerTryLock = LocalLockHolder.tryLock(zedBody.getRowKey(), zedSynchronizedHandler.getRetryInterval()); if (innerTryLock) { ans = assuranceSyncService.lock(zedBody); } } catch (Throwable e) { LOGGER.error("AssuranceLockUtils.runWithLock error" , e); } finally { if (!ans && innerTryLock) { LocalLockHolder.unlock(zedBody.getRowKey()); } } return ans; } private static void tryUnLock (ZedBody zedBody) { try { LocalLockHolder.unlock(zedBody.getRowKey()); assuranceSyncService.unLock(zedBody); } catch (Throwable e) { LOGGER.error("AssuranceLockUtils.runWithUnLock error" , e); } } private static class LocalLockHolder { private static final Map<String, Lock> LOCK_MAP = new ConcurrentHashMap <>(); public static boolean tryLock (String key, long timeout) throws InterruptedException { Lock lock = LOCK_MAP.computeIfAbsent(key, k -> new ReentrantLock ()); return lock.tryLock(timeout, TimeUnit.MILLISECONDS); } public static void unlock (String key) { Lock lock = LOCK_MAP.get(key); if (lock != null ) { lock.unlock(); } LOCK_MAP.remove(key); } }
Core Locking Logic
Include support for reentrant locking.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 public Boolean lock (ZedBody zedBody) { boolean suc = syncService.set(zedBody.getRowKey(), getValue(), zedBody.getTimeout(), () -> { SmartZedThreadLocal.markSynchronizedLockInvalid(true ); return null ; }); if (suc || isOwnLock(zedBody)) { int count = increaseCount(zedBody.getRowKey()); LOGGER.info("lock suc key:{},threadId:{}, count {}" , zedBody.getRowKey(), Thread.currentThread().getId(), count); return true ; } return false ; } @Override public Boolean unLock (ZedBody zedBody) { int lockCount = threadLocal.get().getByKeyWithDefault(zedBody.getRowKey()).count.get(); LOGGER.info("un lock start key:{},threadId:{},count:{}" , zedBody.getRowKey(), Thread.currentThread().getId(), lockCount); if (decreaseCount(zedBody.getRowKey()) <= 0 ) { return syncService.del(zedBody.getRowKey()); } return true ; } private int increaseCount (String key) { LockCounterHolder counterHolder = threadLocal.get(); return counterHolder.increaseWithKey(key); } private int decreaseCount (String key) { LockCounterHolder counterHolder = threadLocal.get(); return counterHolder.decreaseWithKey(key); } private boolean isOwnLock (ZedBody zedBody) { String s = syncService.get(zedBody.getRowKey()); return StringUtils.equals(s, getValue()); } private String getValue () { return LOCAL_HOSTNAME + ":" + Thread.currentThread().getId(); } private static String getHostname () { try { return InetAddress.getLocalHost().getHostName(); } catch (UnknownHostException e) { LOGGER.warn("Failed to get hostname" , e); return "[unknown]" ; } } private static class LockCounterHolder { private final Map<String, LockCounter> counters = new HashMap <>(); public LockCounter getByKeyWithDefault (String key) { return counters.computeIfAbsent(key, e -> new LockCounter ()); } public int increaseWithKey (String key) { LockCounter counter = getByKeyWithDefault(key); return counter.count.incrementAndGet(); } public int decreaseWithKey (String key) { LockCounter counter = counters.get(key); if (counter == null ) { throw new ZedSynchronizedException ("key is not found when release. key=" + key); } int count = counter.count.decrementAndGet(); if (count <= 0 ) { counters.remove(key); } return count; } } private static class LockCounter { private final AtomicInteger count; public LockCounter () { this .count = new AtomicInteger (0 ); } }
Redis Connection Logic
Include retries when a Redis connection cannot be established.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public boolean set (String key, String value, int expireTimeSec) { int connectTime = 0 ; String v = null ; SetParams setParams = new SetParams ().nx().ex(expireTimeSec); while (Objects.isNull(v)) { try (Jedis jedis = jedisPool.getResource()) { v = jedis.set(PREFIX + key, value, setParams); return StringUtils.equals(SUCCESS, v); } catch (Exception e) { LOGGER.error("rdb3 setNx error key:{} value:{}" , key, value, e); if (connectTime++ >= RECONNECT_TIMES) { LOGGER.error("rdb3 retry {} times setNx error key:{} value:{}" , RECONNECT_TIMES, key, value, e); throw e; } } } return StringUtils.equals(SUCCESS, v); }
Testing the Lock Given the distributed lock above, how should I write unit tests on one machine to verify its correctness?
First Version My first test looked like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 @Test public void testLockSync () throws InterruptedException { new Thread (() -> { String res = AssuranceLockUtils.runWithLock((req) -> { try { Thread.sleep(1000 ); } catch (InterruptedException e) { e.printStackTrace(); } return "test" ; }, null , 10 , "test" ); Assert.assertEqual("test" , res); }).start(); new Thread (() -> { try { AssuranceLockUtils.runWithLock((req) -> "test" , null , 10 , "test" ); } catch (Exception exception) { Assert.assertTrue(exception instanceOf ZedSynchronizedException); } }).start(); }
Sharp-eyed readers may immediately spot the problem: the main thread cannot observe the test results. Two things are necessary to fix this. First, pass the worker threads’ results to the main thread. Second, ensure the main thread checks them after the worker threads have finished. Common approaches include CountDownLatch, BlockingQueue, and shared memory.
Second Version I therefore tried this second version:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 @Test public void testLockSync () throws InterruptedException { CountDownLatch latch = new CountDownLatch (2 ); AtomicReference<String> firstThreadAns = new AtomicReference <>(); AtomicReference<Exception> secondThreadAns = new AtomicReference <>(); new Thread (() -> { firstThreadAns.set(AssuranceLockUtils.runWithLock((req) -> { try { Thread.sleep(1000 ); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); } return "test" ; }, null , 10 , "test" )); }).start(); new Thread (() -> { try { AssuranceLockUtils.runWithLock((req) -> "test" , null , 10 , "test" ); } catch (Exception exception) { secondThreadAns.set(exception); } finally { latch.countDown(); } }).start(); latch.await(); Assert.assertEquals("test" , firstThreadAns.get()); Assert.assertTrue(secondThreadAns.get() instanceof ZedSynchronizedException); }
Running this test exposes several failure cases:
The assertion on line 31 fails because firstThreadAns.get() == null. The worker calls latch.countDown() prematurely on line 14. The main thread starts checking before the worker has passed its result back.
The main thread waits forever, preventing the program from finishing. The second thread runs first, so the first thread fails to acquire the lock and throws an exception without calling latch.countDown(). The main thread remains pending. The solution is straightforward: ensure the first test thread reaches the required point before the second. A Semaphore, CountDownLatch, or lock can do this, but note that Thread.join() is unsuitable here.
Third Version 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Test public void testLockSync () throws InterruptedException { CountDownLatch latch = new CountDownLatch (2 ); Semaphore semaphore = new Semaphore (0 ); AtomicReference<String> firstThreadAns = new AtomicReference <>(); AtomicReference<Exception> secondThreadAns = new AtomicReference <>(); new Thread (() -> { firstThreadAns.set(AssuranceLockUtils.runWithLock((req) -> { req.release(); try { Thread.sleep(1000 ); } catch (InterruptedException e) { e.printStackTrace(); } return "test" ; }, semaphore, 10 , "test" )); latch.countDown(); }).start(); new Thread (() -> { try { semaphore.acquire(); AssuranceLockUtils.runWithLock((req) -> "test" , null , 10 , "test" ); } catch (Exception exception) { secondThreadAns.set(exception); } finally { latch.countDown(); } }).start(); latch.await(); Assert.assertEquals("test" , firstThreadAns.get()); Assert.assertTrue(secondThreadAns.get() instanceof ZedSynchronizedException); }
Perfect.