Stateless Authentication with Redis
In a distributed architecture, relying on Tomcat's local sessions for user state leads to inconsistencies across instances. Redis serves as a centralized session store to solve this. When a user logs in via SMS, a unique token is generated and stored in Redis as the key, with the serialized user DTO as the value. The client stores this token and sends it in subsequent request headers.
To maintain security and refresh token expiration seamlessly, a two-interceptor strategy is employed. The first interceptor intercepts all paths, extracts the token, retrieves user data from Redis, saves it to a ThreadLocal context, and resets the token's TTL. The second interceptor checks the ThreadLocal context for user presence, blocking unauthorized access.
@Component
public class TokenRefreshInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
public TokenRefreshInterceptor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String authToken = request.getHeader("auth-token");
if (StrUtil.isBlank(authToken)) {
return true;
}
String redisKey = "user:session:" + authToken;
Map<Object, Object> userMap = redisTemplate.opsForHash().entries(redisKey);
if (userMap.isEmpty()) {
return true;
}
UserContext.setCurrentUser(BeanUtil.mapToBean(userMap, new UserDTO(), false));
redisTemplate.expire(redisKey, 30, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
}Advanced Caching Patterns
Implementing a cache layer requires handling various anomalies like penetration, breakdown, and avalanche.
- Cache Penetration: Occurs when querying for data that doesn't exist in either cache or database. Solution: Cache null values with a short TTL or use a Bloom filter.
- Cache Breakdown: A hot key expires, causing a flood of requests to hit the database. Solutions include mutex locks or logical expiration.
- Cache Avalanche: Mass key expiration or Redis downtime. Mitigated by adding random offsets to TTLs and ensuring high availability.
For data consistency between the database and cache, the Cache Aside Pattern is preferred. The standard approach is to update the database first and then delete the cache. To avoid dirty reads caused by concurrency, the cache deletion must succeed. We can encapsulate a utility client to handle different cache strategies.
public <T, ID> T getWithLogicalExpire(String prefix, ID id, Class<T> clazz, Function<ID, T> dbQuery, Long duration, TimeUnit unit) {
String cacheKey = prefix + id;
String = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isBlank()) {
return null;
}
CacheWrapper<T> wrapper = JSONUtil.toBean(, CacheWrapper.class);
T data = JSONUtil.toBean((JSONObject) wrapper.getData(), clazz);
if (wrapper.getExpireTime().isAfter(LocalDateTime.now())) {
return data;
}
String lockKey = "lock:" + prefix + id;
if (acquireLock(lockKey)) {
executorService.submit(() -> {
try {
T freshData = dbQuery.apply(id);
setWithLogicalExpire(cacheKey, freshData, duration, unit);
} finally {
releaseLock(lockKey);
}
});
}
return data;
}Flash Sale Implementation
Global Unique ID Generator
Database auto-increment IDs reveal business volume and struggle with distributed sharding. A common solution combines a timestamp, a serialized counter (using Redis INCR), and a sign bit.
public long generateId(String prefix) {
long currentTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - EPOCH_START;
String dateKey = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long sequence = redisTemplate.opsForValue().increment("id_seq:" + prefix + ":" + dateKey);
return (currentTime << 32) | sequence;
}Preventing Overselling
Concurrent stock deduction leads to overselling. Optimistic locking resolves this by ensuring the stock remains positive during the update.
@Transactional
public OrderResult processFlashSale(Long promoId, Long buyerId) {
boolean success = promoStockMapper.update(null,
new UpdateWrapper<>()
.setSql("available_stock = available_stock - 1")
.eq("promo_id", promoId)
.gt("available_stock", 0)
) > 0;
if (!success) {
throw new BizException("Out of stock");
}
// Create order...
}One Order Per User
To prevent a user from buying multiple times, a distributed lock per user is necessary. synchronized blocks only work within a single JVM. A Redis-based lock ensures cross-instance mutual exclusion.
Robust Distributed Locking
A basic Redis lock uses SETNX with an expiration. However, if a thread's execution exceeds the lock's TTL, it might delete another thread's lock. To fix this, the lock value must contain a unique thread identifier, and deletion must be conditional.
Because checking the value and deleting the key are two separate operations, a Lua script is required for atomicity.
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0Redisson Framework
Redisson provides advanced locking features out-of-the-box: reentrancy using a Hash structure (field = thread ID, value = reentry count), automatic renewal via a Watchdog mechanism, and retry logic. For master-slave failover inconsistencies, Redisson offers MultiLock, requiring all independent nodes to acquire the lock successfully.
Asynchronous Order Processing
Synchronous flash sales block the user until database operations complete. To maximize throughput, eligibility checks (stock and user limit) can be moved to a Lua script executing against Redis. If successful, the order details are pushed to a Redis Stream. A background thread consumes messages from the Stream to persist the order to the database.
local stockKey = "promo:stock:" .. KEYS[1]
local userSetKey = "promo:users:" .. KEYS[1]
if tonumber(redis.call('GET', stockKey)) <= 0 then
return 1
end
if redis.call('SISMEMBER', userSetKey, ARGV[1]) == 1 then
return 2
end
redis.call('INCRBY', stockKey, -1)
redis.call('SADD', userSetKey, ARGV[1])
redis.call('XADD', 'order:stream', '*', 'userId', ARGV[1], 'promoId', KEYS[1])
return 0Social Features and Feed Streams
Likes and Rankings
While Sets track likes, Sorted Sets (ZSet) enable chronological ranking. When a user likes a post, their ID is added to the ZSet with a score of the current timestamp. This allows retrieving the Top 5 Liked users efficiently.
public void toggleLike(Long postId, Long userId) {
String likeKey = "post:likes:" + postId;
Double score = redisTemplate.opsForZSet().score(likeKey, userId.toString());
if (score != null) {
redisTemplate.opsForZSet().remove(likeKey, userId.toString());
postMapper.decrementLikes(postId);
} else {
redisTemplate.opsForZSet().add(likeKey, userId.toString(), System.currentTimeMillis());
postMapper.incrementLikes(postId);
}
}Follow System and Feed Timeline
Following a user adds their ID to a Set. Mutual follows are calculated using SINTER. For the feed timeline, a push model is used: when a user publishes a post, the post ID is pushed to the ZSet inbox of all followers. Scroll pagination avoids missing or duplicating items when new posts arrive, using the lowest timestamp of the current page as the cursor for the next request.
Geospatial Queries
Redis GEO allows storing coordinates and querying nearby points. Merchant locations are grouped by category using GEOADD. GEOSEARCH returns merchants within a specified radius, sorted by distance, enabling the Nearby feature.
public List<ShopVO> findNearbyShops(Long categoryId, Double lng, Double lat, Integer page) {
String geoKey = "geo:shop:" + categoryId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
.search(geoKey, GeoReference.fromCoordinate(lng, lat), new Distance(5, Metrics.KILOMETERS),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(page * 10));
// Map results to VO...
}BitMap for Sign-ins
Storing daily sign-in records in a relational database consumes significant space. Redis BitMaps use a single bit per day per user. The key is constructed from the user ID and year-month, and the offset represents the day of the month. SETBIT marks a sign-in, while BITFIELD retrieves the entire month's bitmap to calculate continuous sign-in days via bitwise operations.
public int calculateContinuousSignIns(Long userId) {
String signKey = "sign:" + userId + LocalDate.now().format(DateTimeFormatter.ofPattern(":yyyyMM"));
int dayOfMonth = LocalDate.now().getDayOfMonth();
List<Long> bitResult = redisTemplate.opsForValue().bitField(signKey,
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
if (CollectionUtils.isEmpty(bitResult) || bitResult.get(0) == null) return 0;
long bits = bitResult.get(0);
int count = 0;
while ((bits & 1) == 1) {
count++;
bits >>>= 1;
}
return count;
}HyperLogLog for UV Metrics
Counting Unique Visitors (UV) per page using Sets consumes excessive memory. HyperLogLog provides a probabilistic data structure that counts unique elements with a standard error of ~0.81%, using a maximum of 12KB per key.
public void recordPageView(String pageId, Long visitorId) {
String uvKey = "uv:" + pageId + ":" + LocalDate.now();
redisTemplate.opsForHyperLogLog().add(uvKey, visitorId.toString());
}
public long getUniqueVisitors(String pageId) {
String uvKey = "uv:" + pageId + ":" + LocalDate.now();
return redisTemplate.opsForHyperLogLog().size(uvKey);
}