流程&思考
秒杀地址隐藏
数据库的乐观锁-> 上缓存-> 解决缓存雪崩—> 先记录订单再减库存会减少行级锁的等待时间
hot key出现造成集群访问量倾斜
即热点key,导致大部分的访问流量在经过proxy分片之后,都集中访问到某一个redis实例上。hot key 通常在不同业务中,存储着不同的热点信息。比如
- 新闻应用中的热点新闻内容;
- 活动系统中某个用户疯狂参与的活动的活动配置;
- 商城秒杀系统中,最吸引用户眼球,性价比最高的商品信息;
解决方案
-
使用本地缓存
- 在client端使用本地缓存,从而降低了redis集群对hotkey的访问量。但是同时带来两个问题:1、如果对可能成为 hot key 的 key 都进行本地缓存,那么本地缓存是否会过大,从而影响应用程序本身所需的缓存开销。2、如何保证本地缓存和redis集群数据的有效期的一致性。
-
利用分片算法的特性,对key进行打散处理
-
给hotkey加上前缀或者后缀,把一个hotkey 的数量变成 redis 实例个数N的倍数M,从而由访问一个 redis key 变成访问 N * M 个redis key。
-
//redis 实例数 const M = 16 //redis 实例数倍数(按需设计,2^n倍,n一般为1到4的整数) const N = 2 func main() { //获取 redis 实例 c, err := redis.Dial("tcp", "127.0.0.1:6379") if err != nil { fmt.Println("Connect to redis error", err) return } defer c.Close() hotKey := "hotKey:abc" //随机数 randNum := GenerateRangeNum(1, N*M) //得到对 hot key 进行打散的 key tmpHotKey := hotKey + "_" + strconv.Itoa(randNum) //hot key 过期时间 expireTime := 50 //过期时间平缓化的一个时间随机值 randExpireTime := GenerateRangeNum(0, 5) data, err := redis.String(c.Do("GET", tmpHotKey)) if err != nil { data, err = redis.String(c.Do("GET", hotKey)) if err != nil { data = GetDataFromDb() c.Do("SET", "hotKey", data, expireTime) c.Do("SET", tmpHotKey, data, expireTime + randExpireTime) } else { c.Do("SET", tmpHotKey, data, expireTime + randExpireTime) } } }
在这个代码中,通过一个大于等于 1 小于 M * N 的随机数,得到一个 tmp key,程序会优先访问tmp key,在得不到数据的情况下,再访问原来的hot key,并将hot key的内容写回tmp key。tmp key的过期时间是在hot key的过期时间上加上一个随机正整数。
-
big key 造成集群数据量倾斜
big key即数据量大的key,由于其数据大小远大于其他key,导致经过分片之后,某个具体存储这个big key的实例内存使用量远大于其他实例,造成内存不足,拖累整个集群的使用。big key在不同业务上,通常体现为不同的数据,比如:
- 论坛中的大型持久盖楼活动;
- 聊天室系统中热门聊天室的消息列表;
解决方案
对 big key 进行拆分
对 big key 存储的数据 (big value)进行拆分,变成value1,value2… valueN,
- 如果big value 是个大json 通过 mset 的方式,将这个 key 的内容打散到各个实例中,减小big key 对数据量倾斜造成的影响。
- 如果big value 是个大list,可以拆成将list拆成。= list_1, list_2, list3, listN
- 其他数据类型同理。
如何发现hot key, big key
-
事前-预判
- 在业务开发阶段,就要对可能变成 hot key ,big key 的数据进行判断,提前处理,这需要的是对产品业务的理解,对运营节奏的把握,对数据设计的经验。
-
事中-监控和自动处理
监控
- 在应用程序端,对每次请求 redis 的操作进行收集上报;不推荐,但是在运维资源缺少的场景下可以考虑。开发可以绕过运维搞定);
- 在proxy层,对每一个 redis 请求进行收集上报;(推荐,运维来做自然是最好的方案);
- 对 redis 实例使用monitor命令统计热点key(不推荐,高并发条件下会有造成redis 内存爆掉的隐患);
- 机器层面,Redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。如果站在机器的角度,可以通过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计(不推荐,公司每台机器上的基本组件已经很多了,别再添乱了);
自动处理
通过监控之后,程序可以获取 big key 和 hot key,再报警的同时,程序对 big key 和 hot key 进行自动处理。或者通知程序猿利用一定的工具进行定制化处理(在程序中对特定的key 执行前面提到的解决方案)
分流
静态文件服务器
在Java开发过程以及生产环境中,最常用的web应用服务器当属Tomcat,尽管这只猫也能够处理一些静态请求,例如图片、html、样式文件等,但是效率并不是那么尽人意。所以在生产环境中,我们一般使用Nginx代理服务器来处理静态文件,来提升网站性能。用作CDN
server {
listen 80;
server_name file.52itstyle.com;
charset utf-8;
#root 指令用来指定文件在服务器上的基路径
root /data/statics;
#location指令用来映射请求到本地文件系统
location / {
autoindex on; # 索引
autoindex_exact_size on; # 显示文件大小
autoindex_localtime on; # 显示文件时间
}
}
负载均衡
Nginx 提供轮询(round robin)、IP 哈希(client IP)和加权轮询 3 种方式,默认情况下,Nginx 采用的是轮询。
轮询(默认)
每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。
upstream backserver {
server 192.168.1.14;
server 192.168.1.15;
}
加权轮询
指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。
upstream backserver {
server 192.168.1.14 weight=1;
server 192.168.1.15 weight=2;
}
ip_hash
每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。
upstream backserver {
ip_hash;
server 192.168.0.14;
server 192.168.0.15;
}
重试策略
可以为每个 backserver 指定最大的重试次数,和重试时间间隔,所使用的关键字是 max_fails 和 fail_timeout。
upstream backserver {
server 192.168.1.14 weight=1 max_fails=2 fail_timeout=30s;
server 192.168.1.15 weight=2 max_fails=2 fail_timeout=30s;
}
失败重试次数为3,且超时时间为30秒。
热机策略
upstream backserver {
server 192.168.1.14 weight=1 max_fails=2 fail_timeout=30s;
server 192.168.1.15 weight=2 max_fails=2 fail_timeout=30s;
server 192.168.1.16 backup;
}
fair
按后端服务器的响应时间来分配请求,响应时间短的优先分配。
url_hash
按访问的url的hash结果来分配请求,使每个url定向到同一个。
分布式锁解决Redis的并发竞争
- 基于缓存(Redis等)实现分布式锁;
- Redisson
- watchdog
- 基于Zookeeper实现分布式锁
- 创建一个锁目录;
- 线程A获取锁会在锁目录下创建临时顺序节点,首先先获取目录下的所有子节点,然后得到比自己小的兄弟节点,如果不存在,则说明当前序号最小,获得锁;否者监听Watcher比自己次小的节点(防止羊群效应)
- 线程A处理完后,删除自己的节点,另外一个线程监听到变更事件,判断自己是最小的节点,获得锁。
链接加盐
URL动态化,就连写代码的人都不知道,你就通过MD5之类的加密算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。
//创建path
public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
String str = MD5Utils.md5(UUIDUtil.uuid()+"123456");
redisService.set(MiaoshaKey.getMiaoshaPath, ""+user.getNickname() + "_"+ goodsId, str);
return str;
}
//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);
public boolean checkPath(MiaoshaUser user, long goodsId, String path) {
if(user == null || path == null) {
return false;
}
String pathOld = redisService.get(MiaoshaKey.getMiaoshaPath, ""+user.getNickname() + "_"+ goodsId, String.class);
return path.equals(pathOld);
}
分布式唯一ID
雪花算法生成唯一ID(订单编号的生成)
0 | 41bit-时间戳 | 工作机器id (10bit) | 序列号 (12bit) |
---|---|---|---|
0 | 当前时间 - 开始时间 | 可以容纳1024个节点 | 在同一毫秒同一节点上从0开始不断累加 |
同一毫秒的ID数量 = 1024 X 4096 = 4194304,400多万个ID。
异步订单超时处理
对于用户提交订单后,没有进行支付,我们需要设置一个超时处理方法,比如:超过30分钟,订单自动取消。
@Service
public class SchedulerService {
private static final Logger log= LoggerFactory.getLogger(SchedulerService.class);
@Autowired
private ItemKillSuccessMapper itemKillSuccessMapper;
@Autowired
private Environment env;
//定时获取status=0的订单并判断是否超过TTL,然后进行失效
@Scheduled(cron = "0 0/30 * * * ?")
public void schedulerExpireOrders(){
try {
List<ItemKillSuccess> list=itemKillSuccessMapper.selectExpireOrders();
if (list!=null && !list.isEmpty()){
list.stream().forEach(i -> {
if (i!=null && i.getDiffTime() > env.getProperty("scheduler.expire.orders.time",Integer.class)){
itemKillSuccessMapper.expireOrder(i.getCode());
}
});
}
}catch (Exception e){
log.error("定时获取status=0的订单并判断是否超过TTL,然后进行失效-发生异常:",e.fillInStackTrace());
}
}
}
/**
* 定时任务多线程处理的通用化配置
* @Author:debug (SteadyJack)
* @Date: 2019/6/29 21:45
**/
@Configuration
public class SchedulerConfig implements SchedulingConfigurer{
//针对定时任务调度-配置多线程(线程池)
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));
}
}
主从同步
数据库的主从
-
schemas.xml – Mycat对应的物理数据库和数据库表的配置
schema 是第一个,与server.xml配置的名称相对应 table 里表明student 表,主键是id,有两个dataNode节点,这两个节点是用来水平分片的,规则就是rule定义的ruleById. dataNode节点标明物理数据库和物理主机 dataHost 设置具体的数据库 balance=”0”, 不开启读写分离机制,所有读操作都发送到当前可用的 writeHost 上 balance=”1”,全部的 readHost 与 stand by writeHost 参与 select 语句的负载均衡 balance=”2”,所有读操作都随机的在 writeHost、 readhost 上分发。 balance=”3”, 所有读请求随机的分发到 wiriterHost 对应的 readhost 执行,writerHost 不负担读压力 writeType 设置 写入方式的,负载均衡方式可以设置
缓存预热
缓存、尽量不要让大量请求穿透到DB层,活动开始前商品信息可以推送至分布式缓存。实现InitializingBean接口
@Override
public void afterPropertiesSet() throws Exception {
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if (goodsList == null) {
return;
}
for (GoodsVo goods : goodsList) {
redisService.set(GoodsKey.getMiaoshaGoodsStock, "" + goods.getId(), goods.getStockCount());
localOverMap.put(goods.getId(), false);
}
}
服务单一职责
MQ异步消费消息
发送消息
RabbitMQ监听消息,监听秒杀队列:
@RabbitListener(queues=MQConfig.MIAOSHA_QUEUE)
public void receive(String message) {
log.info("receive message:"+message);
MiaoshaMessage mm = RedisService.stringToBean(message, MiaoshaMessage.class);
MiaoshaUser user = mm.getUser();
long goodsId = mm.getGoodsId();
//判断库存 查数据库
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goods.getStockCount();
if(stock <= 0) {return;}
//判断是否已经秒杀到了 查Redis缓存
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(Long.valueOf(user.getNickname()), goodsId);
if(order != null) {return;}
//减库存 下订单 写入秒杀订单
miaoshaService.miaosha(user, goods);
}
声明式事务,开启一个事务,把减库存和下订单放在一个事务中。
@Transactional
public OrderInfo miaosha(MiaoshaUser user, GoodsVo goods) {
//下订单 减库存 写入秒杀订单
// 下面的逻辑有点问题
boolean success = goodsService.reduceStock(goods);
if(success){
return orderService.createOrder(user,goods) ;
}else {
//如果库存不存在则内存标记为true
setGoodsOver(goods.getId());
return null;
}
}
缓存一致性
数据库与缓存数据一致性问题
- 缓存必须要有过期时间
- 保证数据库跟缓存的最终一致性即可,不必追求强一致性
1、Cache Aside Pattern
(1)读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应
(2)更新的时候,先删除缓存,然后再更新数据库
为什么?
- 先修改数据库,再删除缓存,如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据出现不一致
- 比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。更新缓存的代价是很高的。这个缓存到底会不会被频繁访问到呢?
- 其实删除缓存,而不是更新缓存,就是一个lazy计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算
数据库与缓存更新与读取操作进行异步串行化
-
更新数据的时候,根据数据的唯一标识,将操作路由之后,发送到一个jvm内部的队列中
-
读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个jvm内部的队列中,一个队列对应一个工作线程
-
每个工作线程串行拿到对应的操作,然后一条一条的执行
-
这样的话,一个数据变更的操作,先执行,删除缓存,然后再去更新数据库,但是还没完成更新
-
此时如果一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,然后同步等待缓存更新完成
高并发
防止负库存(判断库存和原子性操作)
加库存用Lua,
秒杀主流程
public ResultGeekQ<Integer> miaosha(Model model, MiaoshaUser user, @PathVariable("path") String path,@RequestParam("goodsId") long goodsId) {
//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);
if (!check) {
result.withError(REQUEST_ILLEGAL.getCode(), REQUEST_ILLEGAL.getMessage());
return result;
}
//是否已经秒杀到 , 查缓存
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(Long.valueOf(user.getNickname()), goodsId);
if (order != null) {
result.withError(REPEATE_MIAOSHA.getCode(), REPEATE_MIAOSHA.getMessage());
return result;
}
//内存标记,减少redis访问,
boolean over = localOverMap.get(goodsId);
if (over) {
result.withError(MIAO_SHA_OVER.getCode(), MIAO_SHA_OVER.getMessage());
return result;
}
//预减库存
Long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, "" + goodsId);
if (stock < 0) {
localOverMap.put(goodsId, true);
result.withError(MIAO_SHA_OVER.getCode(), MIAO_SHA_OVER.getMessage());
return result;
}
// 往消息队列中发送消息
MiaoshaMessage mm = new MiaoshaMessage();
mm.setGoodsId(goodsId);
mm.setUser(user);
mqSender.sendMiaoshaMessage(mm);
return result;
}