问题背景
在多用户同时使用餐厅点餐系统时,可能会出现数据覆盖的并发问题,导致用户体验下降和系统数据不一致。以下是问题的详细描述及解决方案。
问题描述
正常业务流程
单用户点餐的标准流程如下:
- 扫码识别:用户扫描餐桌二维码,系统获取餐厅ID和桌号。
- 添加菜品:用户在购物车页面点击“+”按钮添加菜品。
- 服务端处理:
- 根据餐厅ID和桌号查询现有购物车;
- 若购物车存在,则添加菜品;否则,创建新购物车;
- 保存数据并返回成功状态。
- 流程结束。
并发问题场景
当多用户(如用户A和用户B)同时扫码并操作购物车时,可能出现以下问题:
问题重现步骤
-
同时扫码:用户A和用户B同时扫描同一餐桌二维码。
-
并发添加:两人同时在购物车页面点击“+”添加不同的菜品。
-
数据丢失:
时间线: T1: 用户A查询购物车 → 获取购物车状态V1 T2: 用户B查询购物车 → 获取相同的购物车状态V1 T3: 用户A添加菜品并提交 → 购物车更新为V2 T4: 用户B基于V1状态添加菜品并提交 → 购物车被覆盖为V3
结果:用户A的操作被用户B的覆盖,导致数据丢失。
问题本质
这是典型的数据库并发写入冲突问题,也称为“丢失更新”问题。当多个事务同时读取相同数据并基于读取的数据修改时,后提交的事务会覆盖先提交事务的结果。
潜在影响
- 用户体验下降:用户添加的菜品数据丢失,令人困惑。
- 商家损失:订单数据不准确,影响餐厅的收入。
- 系统一致性问题:购物车数据完整性遭到破坏。
解决方案
为了解决上述问题,我们提出以下解决方案:
核心思想:利用 Redis 分布式锁和 Decorrelated Jitter 退避算法
- Redis 分布式锁:通过锁机制保证同一时间内只有一个用户能够操作购物车数据,防止并发写入冲突。
- Decorrelated Jitter 退避算法:在获取锁失败时,使用退避算法打散重试请求,避免热点竞争。
技术实现
以下是完整的解决方案代码实现,分为三个部分:退避工具类、Redis 锁服务和业务逻辑实现。
1. Decorrelated Jitter 退避工具类
该工具类实现了随机退避重试逻辑,在获取锁失败时以指数增长的方式增加延迟,同时引入随机抖动,避免重试请求集中。
@Component
@Slf4j
public class DecorrelatedJitterRetry {
/**
* 去相关抖动重试实现。
*
* @param operation 要执行的操作
* @param baseDelay 基础延迟时间(毫秒)
* @param maxDelay 最大延迟时间(毫秒)
* @param maxRetries 最大重试次数
* @param multiplier 延迟倍数(通常为3)
* @return 操作是否成功
*/
public boolean decorrelatedJitterRetry(final Supplier<Boolean> operation,
final long baseDelay,
final long maxDelay,
final int maxRetries,
final int multiplier) {
final Random random = new Random();
long currentDelay = baseDelay;
LOGGER.debug("开始重试操作,参数:{}, {}, {}, {}",
kv("baseDelay", baseDelay),
kv("maxDelay", maxDelay),
kv("maxRetries", maxRetries),
kv("multiplier", multiplier)
);
for (int attempt = 0; attempt < maxRetries; attempt++) {
long startTimeMillis = System.currentTimeMillis();
if (Boolean.TRUE.equals(operation.get())) {
LOGGER.info("操作在第{}次尝试后成功,耗时{}毫秒",
kv("attempt", attempt),
kv("elapsedTimeMillis", System.currentTimeMillis() - startTimeMillis));
return true;
}
final long maxNextDelay = Math.min(currentDelay * multiplier, maxDelay);
final long minDelay = baseDelay;
// 确保有效的随机范围
if (maxNextDelay <= minDelay) {
currentDelay = minDelay;
} else {
currentDelay = minDelay + random.nextLong(maxNextDelay - minDelay + 1);
}
LOGGER.debug("重试操作,延迟时间:{}", kv("delay", currentDelay));
sleepSafely(currentDelay);
}
LOGGER.warn("操作在{}次尝试后失败", kv("maxRetries", maxRetries));
return false;
}
private void sleepSafely(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("重试被中断", e);
}
}
}
2. Redis 锁服务
Redis 锁服务实现了分布式锁的获取和释放,同时结合退避算法实现重试获取锁的能力。
@Service
@RequiredArgsConstructor
public class RedisLockService {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Long> redisLockScript;
private final DecorrelatedJitterRetry retryService;
public boolean acquireLock(final String key, final Long expire) {
final Long res = redisTemplate.execute(redisLockScript, Collections.singletonList(key), expire.toString());
return res != null && res == 1;
}
public boolean acquireLockWithRetry(final String key, final Long expire) {
return acquireLockWithRetry(key, expire, 100L, 1500L, 5, 2);
}
/**
* acquire lock (customer).
*
* @param key redis key
* @param expire The lock expire time (milliseconds)
* @param baseDelay The base delay time (milliseconds)
* @param maxDelay The maximum delay time (milliseconds)
* @param maxRetries The maximum number of retries
* @param multiplier The delay multiplier (typically 3)
* @return whether the lock was acquired successfully.
*/
public boolean acquireLockWithRetry(
final String key,
final Long expire,
final long baseDelay,
final long maxDelay,
final int maxRetries,
final int multiplier
) {
return retryService.decorrelatedJitterRetry(
() -> acquireLock(key, expire),
baseDelay,
maxDelay,
maxRetries,
multiplier
);
}
public void releaseLock(final String key) {
redisTemplate.delete(key);
}
}
3. 购物车保存逻辑
购物车保存逻辑通过 Redis 锁服务保证并发操作的互斥性,确保多个用户的操作不会相互覆盖。
@Slf4j
@Service
@RequiredArgsConstructor
public class SaveShoppingCartUseCase {
private final IShoppingCartGateway shoppingCartGateway;
private final RedisLockService redisLockService;
/**
* Execute shopping cart save operation.
*/
public SaveShoppingCartResponseDomain execute(final SaveShoppingCartRequestDomain request) {
LOGGER.debug("Starting cart save operation");
final String lockKey = buildLockKey(request.getTableId(), request.getRestaurantId());
try {
if (redisLockService.acquireLockWithRetry(lockKey, 5000L)) {
return doExecute(request);
} else {
LOGGER.warn("Failed to acquire lock for cart: {}", request.getCartId());
return null;
}
} finally {
redisLockService.releaseLock(lockKey);
}
}
private SaveShoppingCartResponseDomain doExecute(final SaveShoppingCartRequestDomain request) {
// 实际保存购物车逻辑
}
private String buildLockKey(String tableId, String restaurantId) {
return String.format("cart:lock:%s:%s", restaurantId, tableId);
}
}
总结
在实现购物车功能时,我也曾考虑过其他解决并发问题的方案,例如乐观锁、消息队列异步下单或 Redis 队列排队等。然而,这些方案的实现相对复杂,并且在当前场景下显得有些过于繁琐。毕竟,一个餐桌同时操作购物车的用户通常不会超过十人,而加菜操作的响应时间仅需百毫秒左右。通过 Redis 分布式锁加上随机退避重试的方式,完全能够满足这一需求。
最终,业务实现过程中,技术方案的选择并非越高级越好,而是应根据具体的业务场景选择最为合适的解决方案。
评论