一张优惠券引发的血案

简介: 整个优惠券中心分为前端和后端,小灰所负责的是后端RPC接口的开发。接口中包含“查券”和“领券”两个方法,项目大体结构如下。


640.jpg640.jpg640.jpg640.jpg

           

一个月前——



640.jpg640.jpg


整个优惠券中心分为前端和后端,小灰所负责的是后端RPC接口的开发。接口中包含“查券”和“领券”两个方法,项目大体结构如下图:

640.jpg640.jpg

image.gif


两周后——


       小灰:看,这是优惠券查询功能的效果!        

image.gif

640.jpg


 小灰:看,这是优惠券领取功能的效果!  


image.gif

640.jpg640.jpg


三天后——


640.jpg640.jpg640.jpg640.jpg640.jpg

小灰原本的优惠券查询接口是这样实现的:

640.jpg

image.gif

优惠券列表在Redis中以List的形式存储,查询时的逻辑很简单:


1.查询缓存,如果缓存存在,返回结果

2.缓存不存在,查询数据库

3.把查询数据库的结果循环放入缓存

然而,当某个时间点缓存不存在,请求量又很大的时候,会出现缓存并发的问题。也就是多个线程会重复去查询DB,又重复去更新缓存。(注意,这并不是缓存击穿,很多人在这两个概念上混淆。)

这其中重复查询DB是次要问题,而重复更新缓存则是主要问题。假如有两个线程同时进入上述的第三个阶段,各自进行rpush操作,那么最终会在优惠券列表的缓存中插入两组同样的数据。

怎么解决呢?用Java的锁机制?显然不行,因为线上环境通常都是多个服务器组成的集群。于是小灰想到了利用分布式锁。

image.gif

640.jpg

所谓分布式锁有很多种,可以利用ZooKeeper、MemCache、Redis来实现。其中Redis的方式比较简单,无非是利用一个服务器之间共享的Key,以及Setnx指令。

当第一个线程执行Setnx,会存储对应的键值,相当于成功获得锁。当后续再有线程对同于的Key执行Setnx指令,则会返回空,相当于抢锁失败。同时,为了防止一个线程因意外情况而长久把持着锁,程序对Key设置了1秒的过期时间。


归纳一下修改后的逻辑:


1.查询缓存,如果缓存存在,返回结果

2.缓存不存在,查询数据库

3.争夺分布式锁

4.成功获得锁,把查询数据库的结果循环放入缓存

5.释放分布式锁

640.jpg640.jpg


三天后——


640.jpg640.jpg640.jpg640.jpg

image.gif


640.jpg


诡异的bug又重现了,因为小灰上次的改动仍然存在一个致命的漏洞。在这里我们假定缓存不存在,刚好有两个线程A和B一后一先进入到代码块。


第一阶段,线程A刚开始查询优惠券缓存,线程B正尝试获取分布式锁:

             

image.gif640.jpg


第二阶段,由于缓存不存在,线程A开始查询数据库,线程B成功获得锁,开始更新缓存:

640.jpg

image.gif


第三阶段,线程A尝试获得分布式锁,而线程B已经释放分布式锁


image.gif640.jpg


第四阶段,线程A获得了锁,又一次更新缓存,而线程B已经成功返回:

640.jpg

image.gif

                       

就这样,缓存被重复更新了两次,所以再次出现数据重复的bug。

这种局面如何破解呢?其实不难,只需在线程成功得到锁以后,再次判断优惠券缓存的存在:


image.gif640.jpg


归纳一下修改后的逻辑:

1.查询缓存,如果缓存存在,返回结果

2.缓存不存在,查询数据库

3.争夺分布式锁

4.成功获得锁,再次判断缓存的存在

5.如果缓存仍旧不存在,把查询数据库的结果循环放入缓存

6.释放分布式锁

这种二次判断存在性的机制有一个专门的名字,叫做双重检测。该方法在线程安全的单例模式中也常常被用到。



image.gif640.jpg640.jpg


小灰的回忆告一段落——


640.jpg640.jpg640.jpg

image.gif


几点补充:


1.文中所使用的分布式锁,其实并不是“正宗”的分布式锁,当线程争夺锁失败的时候,会直接返回查询DB的结果,而不会依靠自旋机制来等锁。

2.为什么优惠券列表的信息要使用List类型来存入缓存,而不是把整个列表存为一个很长的Json字符串?这是由于业务需要,使用List在某些情况下更方便对单个优惠券信息进行修改(LSET指令)。

3.为什么优惠券列表的信息不使用Redis的Set或者Hash数据类型来存储,实现自动去重呢?对于Set类型,去重前需要对比整个字符串是否完全相同,而每一张优惠券是一个较长的Json字符串,对比的效率会比较低。使用Hash倒是可以实现高效的去重,但并未在根本上解决重复更新的问题。


相关文章
|
Web App开发
Python+selenium 实现自动上传并发布微信视频号短视频实例演示
Python+selenium 实现自动上传并发布微信视频号短视频实例演示
1655 0
Python+selenium 实现自动上传并发布微信视频号短视频实例演示
|
Java 数据安全/隐私保护
jasypt 配置文件关键信息配置 加密
jasypt 配置文件关键信息配置 加密
1057 0
|
4月前
|
安全 测试技术 API
MiniMax 开源新评测集:定义Coding Agent 的生产级标准
Coding Agent常因“过程违规”遭诟病,如无视指令、破坏规范。MiniMax推出OctoCodingBench,首创面向工程可靠性的过程评估体系,揭示当前模型在多规则协同下成功率极低,呼吁行业关注“过程正确性”,推动Agent从能用走向可用。
672 5
|
10月前
|
消息中间件 供应链 前端开发
如何开发进销存系统中的库存管理板块?(附架构图+流程图+代码参考)
进销存系统中的库存管理是企业运营关键环节,影响效率、资金周转与物流成本。本文详解库存管理概念、模块结构、业务流程及开发技巧,通过示例代码展示入库、出库等功能实现,助力企业优化库存管理,提升运作效率。
|
9月前
|
监控 Linux 数据安全/隐私保护
Python实现Word转PDF全攻略:从入门到实战
在数字化办公中,Python实现Word转PDF自动化,可大幅提升处理效率,解决格式兼容问题。本文详解五种主流方案,包括跨平台的docx2pdf、Windows原生的pywin32、服务器部署首选的LibreOffice命令行、企业级的Aspose.Words,以及轻量级的python-docx+pdfkit组合。每种方案均提供核心代码与适用场景,并涵盖中文字体处理、表格优化、批量进度监控等实用技巧,助力高效办公自动化。
1895 0
|
机器学习/深度学习 人工智能 自然语言处理
AI训练师入行指南(三):机器学习算法和模型架构选择
从淘金到雕琢,将原始数据炼成智能珠宝!本文带您走进数字珠宝工坊,用算法工具打磨数据金砂。从基础的经典算法到精密的深度学习模型,结合电商、医疗、金融等场景实战,手把手教您选择合适工具,打造价值连城的智能应用。掌握AutoML改装套件与模型蒸馏术,让复杂问题迎刃而解。握紧算法刻刀,为数字世界雕刻文明!
489 6
|
机器学习/深度学习 网络架构
深度学习之对抗攻击的防御
基于深度学习的对抗攻击防御是一项重要的研究方向,旨在提高模型在面对对抗样本时的鲁棒性和安全性。对抗攻击通常通过向输入数据中添加微小扰动,使得深度学习模型做出错误的预测。
555 1
|
算法 NoSQL 关系型数据库
分布式系统第三讲:全局唯一ID实现方案
分布式系统第三讲:全局唯一ID实现方案
1245 0
|
SQL XML Java
Mybatis-Plus插件扩展MybatisX
Mybatis-Plus插件扩展MybatisX
520 0
Windows中如何查看被占用的端口、杀掉对应的进程
这篇文章介绍了在Windows系统中如何查看被占用的端口号以及如何杀掉占用端口的进程,包括使用命令提示符的`netstat -ano | findstr 端口号`命令查找进程PID,然后通过任务管理器或`taskkill /PID PID号`命令来结束进程。
Windows中如何查看被占用的端口、杀掉对应的进程