问题背景

在多用户同时使用餐厅点餐系统时,可能会出现数据覆盖的并发问题,导致用户体验下降和系统数据不一致。以下是问题的详细描述及解决方案。


问题描述

正常业务流程

单用户点餐的标准流程如下:

  1. 扫码识别:用户扫描餐桌二维码,系统获取餐厅ID和桌号。
  2. 添加菜品:用户在购物车页面点击“+”按钮添加菜品。
  3. 服务端处理
    • 根据餐厅ID和桌号查询现有购物车;
    • 若购物车存在,则添加菜品;否则,创建新购物车;
    • 保存数据并返回成功状态。
  4. 流程结束

并发问题场景

当多用户(如用户A和用户B)同时扫码并操作购物车时,可能出现以下问题:

问题重现步骤
  1. 同时扫码:用户A和用户B同时扫描同一餐桌二维码。

  2. 并发添加:两人同时在购物车页面点击“+”添加不同的菜品。

  3. 数据丢失

    时间线:
    T1: 用户A查询购物车 → 获取购物车状态V1
    T2: 用户B查询购物车 → 获取相同的购物车状态V1
    T3: 用户A添加菜品并提交 → 购物车更新为V2
    T4: 用户B基于V1状态添加菜品并提交 → 购物车被覆盖为V3
    

    结果:用户A的操作被用户B的覆盖,导致数据丢失。

问题本质

这是典型的数据库并发写入冲突问题,也称为“丢失更新”问题。当多个事务同时读取相同数据并基于读取的数据修改时,后提交的事务会覆盖先提交事务的结果。

潜在影响

  • 用户体验下降:用户添加的菜品数据丢失,令人困惑。
  • 商家损失:订单数据不准确,影响餐厅的收入。
  • 系统一致性问题:购物车数据完整性遭到破坏。

解决方案

为了解决上述问题,我们提出以下解决方案:

核心思想:利用 Redis 分布式锁和 Decorrelated Jitter 退避算法

  1. Redis 分布式锁:通过锁机制保证同一时间内只有一个用户能够操作购物车数据,防止并发写入冲突。
  2. 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 分布式锁加上随机退避重试的方式,完全能够满足这一需求。

最终,业务实现过程中,技术方案的选择并非越高级越好,而是应根据具体的业务场景选择最为合适的解决方案。