业务背景
为了满足用户对一趟列车不同站点不同座位类型的余量查询需求,我们采取了一种优化方案。我们将这些余量信息存储在缓存中,以便用户可以快速查询。
然而,在用户创建订单并完成支付时,我们需要同时从数据库和缓存中扣减相应的列车站点余票。这种设计不仅提高了查询效率,也保证了数据的一致性,确保订单操作的准确性。
在这个业务场景中的缓存与数据库一致性如何保证?
项目实战
通过数据库缓存一致性文章我们了解到,适合咱们这个车票余量缓存的方案是通过 Binlog 异步更新列车缓存,详细逻辑查看上文。
如何保证缓存一致性
我们采用了一种确保缓存和数据库一致性的方案,使用 Canal 监听 Binlog 模式。该方案将数据库的数据变更通过 Canal 转发给消息队列的特定 Topic。客户端应用程序可以监听该消息队列的 Topic,以保持缓存与数据库的一致性。
时序图如下所示:
这套逻辑在应用层面上非常严谨,但存在一个问题,即复杂性较高。如果大家想进行测试,需要启动 Canal 监听并结合消息队列进行测试。
为了解决这个问题,我在代码中添加了两种缓存同步模型。其中一种是在业务代码中同步地操作缓存扣减,这种方案比较简单,可以帮助大家快速熟悉业务流程。但是,这种方案在中间或快结束时如果出现异常,可能会导致缓存数据出现问题。
方案具体体现在购票抽象方法 AbstractTrainPurchaseTicketTemplate#executeResp
中添加了条件判断,如果是非 binlog 模式,也就是 ticket.availability.cache-update.type
不等于 binlog,就按照同步更新缓存模式进行。
另一种缓存同步模型被称为 Binlog 模式,它需要在配置文件中添加相关配置并启动 Canal 中间件才能启用。
为什么订阅 Canal 的消费者不用线程池?
目前我们的方案是通过一个 RocketMQ Topic 消费 Canal 传递的 Binlog 数据变更事件。为了提高消费速度,我们可以考虑在客户端消费时增加一个线程池。此外,在消息积压情况下,我们可以利用 Hippo4j 动态调整线程池大小来进一步增加消费速度。
然而,需要注意的是,使用线程池消费消息会使得投递线程池的成功与消息消费成功绑定。如果消费过程中发生失败,我们无法再利用 RocketMQ 的消费失败重试逻辑。尽管我们可以通过其他技术方案实现重试消费逻辑,但这会增加方案的复杂性和不确定性。相比之下,直接使用 RocketMQ 方案更加简单。
因此,我们最终选择将 RocketMQ 底层线程池的线程数参数适当增大,以提高消费速度。此外,Hippo4j 可以操作 RocketMQ 底层线程池,在消息积压时,通过动态调整线程池参数来解决积压问题。
为什么扣减数据库需要扣减沿途车站,而缓存扣减不需要?
由于操作数据库时更改了出发站点和到达站点之间的沿途站点,导致对应的沿途车站座位数据的 Binlog 会被投递到 RocketMQ 队列中。应用客户端将完全消费这些 Binlog,因此在扣减缓存时无需再对沿途站点进行扣减操作。
为什么创建订单扣减库存而不是支付后扣减库存?
在我进行了真实实验后发现,在 12306 购买车票时,购买成功后几分钟乃至十几分钟内余票数量并不会减少。
在设计过程中,我主要考虑了两种库存扣减方案:下单扣减库存和支付扣减库存。
1/ 下单扣减库存:
- 操作时机:库存在下单时就进行扣减,即在用户下单时就减少库存。
- 优势:
- 立即锁定库存:下单扣减库存可以立即锁定列车站点的库存,避免其他用户同时购买同一列车车站导致库存不足的问题。
- 简化流程:不需要等待支付完成才进行库存扣减,简化了流程。
- 劣势:
- 库存占用:下单后,如果用户不支付,库存将被长时间占用,可能导致其他用户无法购买该列车站点座位。
- 订单超时问题:如果用户下单后长时间不支付,库存将一直被占用,影响其他用户购买。
2/ 支付扣减库存:
- 操作时机:库存在支付完成时才进行扣减,即在用户支付成功后再减少库存。
- 优势:
- 避免库存占用:只有支付成功的订单才会减少库存,避免了长时间占用库存的问题。
- 提高订单完成率:只有支付完成的订单才会扣减库存,避免了因用户不支付而导致库存被占用的情况。
- 劣势:
- 竞争情况:如果多个用户同时下单购买同一商品,可能出现竞争情况,导致库存不足。
- 需要处理超时问题:需要考虑订单支付超时的情况,如果超时未支付,需要释放相应的库存。
从实际场景考虑,当用户购买列车车票时,他们的目的是为了支付购买成功。如果用户已经进入支付流程,却被告知库存已售罄,这将给用户带来非常糟糕的体验。