架构设计方法(一)高并发

1,605 阅读11分钟

以下内容是在学习过程中的一些笔记,难免会有错误和纰漏的地方。如果造成任何困扰,很抱歉。

前言

高并发读写架构与业务实现

一、Feeds流的设计与实现

设想一个场景:微博首页中,用户关注了n个人,每个人都在不断地发微博,系统需要把这n个人的微博按时间排序成一个列表(Feeds流),并展示给用户,同时用户也要能查看自己发布的微博列表。

假如存在一个用户关注的关系表,表结构大致是

id关注人被关注人
iduser_idfollowings

然后一个微博发布信息表

id发布人发布的微博id
iduser_idweibo_id

在普通情况下,大部分开发者想到的方法是做关联查询并展示

select followings from xxx1_table where user_id = 123
select weibo_id from xxx2_table where user_id in (
    xxx,xxx,xxx
) limit index,size

在高并发场景下显然不能满足,所以引进了收发件箱的设计思路,每位用户都拥有一个发件箱与收件箱,用户发了一条微博后,将消息放入自己的发件箱,并异步广播推送到粉丝的收件箱中,每次用户查询时,查询自己的收件箱即可。

但是我们发现,收件箱与发件箱是一个无限长的列表,从收件箱拿到的id,再去对应的数据结构中查找id对应的detail,我们如何去寻找或设计这种数据结构?

  • 方案一:通过中间件RocksDB存放,RocksDB可以对key实现范围及点查询,理论上磁盘空间足够的情况下,列表可以无限长。
  • 方案二:从产品层面对总条数进行限制,例如只允许用户翻阅50000万历史,传说推特的做法就是如此。
  • 方案三:分库分表+MySQL二级索引表。

Feeds流的分类有很多种,但最常见的分类有两种:

  • Timeline:按发布的时间顺序排序,先发布的先看到,后发布的排列在最顶端,类似于微信朋友圈,微博等。这也是一种最常见的形式。产品如果选择Timeline类型,那么就是认为Feed流中的Feed不多,但是每个Feed都很重要,都需要用户看到。
  • Rank:按某个非时间的因子排序,一般是按照用户的喜好度排序,用户最喜欢的排在最前面,次喜欢的排在后面。这种一般假定用户可能看到的Feed非常多,而用户花费在这里的时间有限,那么就为用户选择出用户最想看的 Top N 结果,场景的应用场景有图片分享、新闻推荐类、商品推荐等。

若是一个用户的粉丝数量十分多,例如微博大V,显然上面的这种推模式的计算量和延迟都会很高,不太适用当前场景,我们通过拉模式的思维来思考这个问题:

  1. 用户A发布微博,插入记录到微博表(放入自己的发件箱)
  2. 用户B获取关注的用户的微博记录数据(从对方发件箱获取),按时间倒序排列,分页及返回

上述的拉模式如果不加以处理,效率是非常低的,通常以推+拉的形式去做,并且如果用户取消了关注,我们也需要做到刷新(用户粉丝大于x时,选择拉模式,小于则推模式),然后总结一下如下的业务场景

  • 用户关注与拉黑的行为
  • 用户点赞评论行为
  • 个性化和定向广告
  • 微博消息广场

我们从用户的点赞评论看数据结构,常规的结果我们可能是设计为<微博id,评论id,评论详情>,如果按照该设计,每条微博下都需要去执行大量查询,效率较低,我们转换思路,方法之一是可以按照KV的设计结构,设计为<微博id,[ (评论id,评论详情) , (评论id,评论详情) , (评论id,评论详情) ]>,这个列表按照自增长的形式,那么查询速度会相较于之前会提高,后续具体情况再具体落地编码分析。

二、高并发读设计思路

对于高并发读写,无论如何设计,本质都是读写分离,把系统按照读和写两个视角去设计,对于读,除了对业务动手脚做设计,其它的方式不外乎

  • 宽表
  • 中间件:例如ES搜索引擎
  • 本地缓存或集中式缓存
  • 自己实现的倒排索引

何为宽表?将要关联的表的数据算好后保存在一张宽表中。依据情况,可以定时算,也可以在原始表发生变化后触发计算。

ES搜索引擎的实现?将多张表的join结果做成文档,放在搜索引擎中,即可灵活地实现排序和分页查询。

后续将通过案例落地想法。

三、高并发写设计思路

下面看看书中应对的四种策略

  • 数据分片
    1. 分库分表
    2. JDK的ConcurrentHashMap - 原子类,槽与槽之间的并发读写;
    3. Kafka的Partition - 逻辑消息队列Topic具体到物理空间被分为多块Partition;
    4. ES搜索引擎的分布式索引;
  • 任务分片 - 归并排序,工序拆分最后组合结果
  • 异步化与Pipeline
    1. LST树,KV存储,通过写内存+预写日志形式,内存中维护sorted HashMap,预写日志保证即使内存数据丢失也可快速恢复;
    2. 电商订单系统之异步拆单;
    3. Kafka的Pipeline,主节点直接返回成功给客户端,从节点异步从主节点批量拉取消息;
  • 批处理写入
  • 串行化+多线程+Reactor模式+异步I/O

四、秒杀系统设计

面试中的非常高频率的问题,秒杀模块的设计也是考察了程序员对于高并发的处理能力,在电商项目中也是非常热门的存在,一般需要考虑的因素有以下几点:

  1. 库存;
  2. 时间限制;
  3. 安全设计 - 拦截恶意请求;

通过以上几点完成整体的思路设计与分析。

在上述的简图中,我们已经进行了一个初步的业务流程窥探,接下来是将每块逻辑通过相应的技术栈去进行代码实现。

我将代码层面拆分成大致四个阶段:

  1. 购买前检查
    • 账户是否登录:一般通过用户携带的token进行判断;
    • 是否已到了秒杀时间;
    • 商品目前的库存情况:存放缓存提高效率,注意数据一致性问题;
    • 是否限购;
  2. 开始秒杀
    • 秒杀点击:使用AQS队列存放用户id保证效率及原子性的实现;
    • 库存扣减:利用Redis原子性进行库存扣除;
    • 订单号生成:可以使用推特的雪花算法实现分布式环境id唯一;
  3. 付款
    • 如未在规定时间内购买:库存恢复,队列释放,秒杀继续;
    • 已购买:通知用户购买成功;
  4. 购买成功

根据上述的思路,先做一个简易版的进行猜想验证

后台接口

import com.ljm.entity.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.PostConstruct;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

@RestController
public class DemoController {

    /** 日志处理 */
    private static Logger log = LoggerFactory.getLogger(DemoController.class);

    /** 假设商品的数量 */
    private static AtomicInteger productNumber = new AtomicInteger(1000);

    /** 成功购买的用户 */
    private static CopyOnWriteArrayList<User> users = new CopyOnWriteArrayList<>();

    /**
     * 秒杀接口
     * @param user
     * @return
     */
    @PostMapping("kill")
    public Boolean kill(User user) {
        if (productNumber.get() <= 0) {
            log.info("商品已售罄");
            return false;
        }
        productNumber.decrementAndGet();
        users.add(user);
        log.info("商品剩余 = " + productNumber.get());
        return true;
    }

    /**
     * 监控
     */
    @PostConstruct
    private void listener() {
        CompletableFuture.runAsync(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    // 持续监控 ....
                    try {
                        Thread.sleep(6000);
                        log.info("-------- 商品库存 = " + productNumber.get());
                        log.info("-------- 成功购买的用户数量 = " + users.size());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        });
    }
}

这个用户类写的很简单

/**
 * 模拟购买商品的用户
 * @author 李家民
 */
@Data
public class User {

    /** 用户id */
    private Long id;
}

**模拟大量并发 **

import com.ljm.entity.User;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.methods.PostMethod;

import java.io.IOException;
import java.util.Random;
import java.util.concurrent.CompletableFuture;

public class Main {
    private static Random rd = new Random();

    public static void main(String[] args) throws IOException {

        CompletableFuture.runAsync(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 3000; i++) {
                    CompletableFuture.runAsync(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                long userId = rd.nextInt(900000000) + 100000000;
                                User user = new User(userId);
                                // ----
                                final String IP_PORT = "http://127.0.0.1:20001/kill";
                                PostMethod postMethod = new PostMethod(IP_PORT);
                                postMethod.addParameter("user", user.toString());

                                HttpClient httpClient = new HttpClient();
                                httpClient.executeMethod(postMethod);
                            } catch (IOException e) {
                                throw new RuntimeException(e);
                            }
                        }
                    });
                }
            }
        });

        CompletableFuture.runAsync(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2900);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 3000; i++) {
                    CompletableFuture.runAsync(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                long userId = rd.nextInt(900000000) + 100000000;
                                User user = new User(userId);
                                // ----
                                final String IP_PORT = "http://127.0.0.1:20001/kill";
                                PostMethod postMethod = new PostMethod(IP_PORT);
                                postMethod.addParameter("user", user.toString());

                                HttpClient httpClient = new HttpClient();
                                httpClient.executeMethod(postMethod);
                            } catch (IOException e) {
                                throw new RuntimeException(e);
                            }
                        }
                    });
                }
            }
        });

        CompletableFuture.runAsync(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2800);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 3000; i++) {
                    CompletableFuture.runAsync(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                long userId = rd.nextInt(900000000) + 100000000;
                                User user = new User(userId);
                                // ----
                                final String IP_PORT = "http://127.0.0.1:20001/kill";
                                PostMethod postMethod = new PostMethod(IP_PORT);
                                postMethod.addParameter("user", user.toString());

                                HttpClient httpClient = new HttpClient();
                                httpClient.executeMethod(postMethod);
                            } catch (IOException e) {
                                throw new RuntimeException(e);
                            }
                        }
                    });
                }
            }
        });

        // ....
        try {
            Thread.sleep(10000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

第一次验证猜想还是比较成功的。

目前这个Demo我的思路是:

  1. 在商品库存上,使用原子类及静态关键字去防止因为多线程导致的问题;
  2. 假设当前商品种类只有一个,那么库存及购买成功的用户应该是成正比的;

目前这个测试代码先初步模拟了秒杀的步骤,接下来进行往外扩展。

PS:实践中发现的问题

  1. 秒杀接口的数据一致性问题漏洞 - 并非能用原子类这么简单的解决;
  2. 分布式唯一id问题 - 毫秒级别的压测下如果不加以盐值计算,id重复并非不可能;
  3. 从缓存获取库存数目的效率问题 - 每个线程都通过redis去获取库存数目,那效率实在太低了;

在上述的问题中也提到,如果使用原子类作为库存数目,一旦库存减少的那段代码延时过大,立刻就会导致库存超卖的数据一致性问题,此时加入AQS的阻塞队列能够解决该问题,后期的分布式环境我们还是会使用Redis,还是先写一个demo作为思路学习。

import com.ljm.entity.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.PostConstruct;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;

/**
 * 秒杀接口
 * @author 李家民
 */
@RestController
public class DemoController {

    /** 日志处理 */
    private static Logger log = LoggerFactory.getLogger(DemoController.class);

    /** 商品a库存 */
    private static final Integer repertoryNumber = 1000;

    /** 成功抢到商品的用户 */
    private static ArrayBlockingQueue<User> blockingQueue = new ArrayBlockingQueue<>(repertoryNumber);

    /**
     * 秒杀接口
     * @param user
     * @return
     */
    @PostMapping("kill")
    public Boolean kill(User user) {
        // 阻塞队列 成功抢购到商品的用户
        try {
            blockingQueue.add(user);
            // 延时阻塞等待插入的代码 - blockingQueue.offer(user,3, TimeUnit.SECONDS);
        } catch (IllegalStateException illegalStateException) {
            // 如果出现这个异常 代表队列已满
            log.info("商品已被抢购一空");
            return false;
        }
        return true;
    }

    /**
     * 监控
     */
    @PostConstruct
    private void listener() {
        CompletableFuture.runAsync(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    // 持续监控 ....
                    try {
                        Thread.sleep(6000);
//                        log.info("-------- 商品库存 = " + "null");
                        log.info("-------- 成功购买的用户数量 = " + blockingQueue.size());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        });
    }
}

后续将引入各个中间件进行分布式的联动,既然是引入其它中间件来辅佐秒杀,那么流程这块的技术栈需要我们重新梳理一下。

文章目录
电商网站中,50W-100W高并发,秒杀功能是怎么实现的? - 知乎 (zhihu.com)

完整业务流程如下

over

五、库存系统设计

以后跳槽到电商公司再补充....

六、冷热分离

首先聊聊MySQL的分区,何为分区?分区就是将一张表中的数据分散存储在一台或者多台计算机上的多个文件中,目的是为了

  • 储存更多的数据
  • 优化查询
  • 并行处理
  • 快速删除数据
  • 更大的数据吞吐量

分区类型大致如下

  • range分区:根据范围分区
  • list分区:根据值列表,将数据储存在不同的分区
  • hash分区:hash算法分区
  • key分区:hash算法分区(mysql自带)
  • 字段分区
  • 子分区:对分区在进行分区

但是,数据库的分区并不是生成新的数据表,而是将表的数据均衡分配到不同的硬盘、系统或不同的服务器存储介质中,实际上还是一张表。所以分区可以成为高并发读写方案之一,但并不能为最终的效果去买单。

什么是冷热分离?就是数据分成冷库和热库2个库,冷库只存放那那些走到终态的数据,热库存放还需要去修改字段的数据。对于冷热分离,我们需要思考

  1. 如何判断一个数据是冷数据还是热数据?

  2. 如何触发冷热数据分离?

  3. 如何实现冷热数据分离?

  4. 如何使用冷热数据?

  5. 历史数据如何迁移?

除了将冷数据放在MySQL,我们也可以放在HBase等KV中间件,数据量太大MySQL吃不住。

我们如何使用冷热数据?需要注意的是,在判断数据是冷数据还是热数据时,必须确保用户不允许同时有读冷热数据的需求。我们可以通过业务代码区分:例如工单业务中,未完结的工单是热数据,已完结的工单是冷数据等思路,整个业务中最复杂的也是如何分离冷热数据+数据同步,迁移时,可以对冷热数据做标记,在搬运数据的过程中要考虑到加锁及原子性。

七、SQL分组优化

有段时间一直在编写趋势图的业务接口,按照以往的方法,都是通过某个时间段进行全量查询后聚合,在数据量小的情况下,速度实际上没什么太大问题,但是在有一定的数据量的情况下,这种查询方法就会变得很慢

select * from xxx_table WHERE 1=1 AND create_time BETWEEN '' and ''

但是提前通过在SQL中使用Group By聚合数据,效率优化就会十分明显

        SELECT
            DATE_FORMAT(create_time,#{type}) as name,count(*) as value
        FROM
           xxx_table
        WHERE
            1 = 1
          AND create_time BETWEEN #{startTime}
            AND #{endTime}
        GROUP BY DATE_FORMAT(create_time, #{type} )

这件事的同时也让我反思了自己,写业务的同时也不能为了完成而去完成,不能着急,加班写慢一些也没关系。那么,为什么Group By会这么快?

我们发现查询的同时使用了条件、索引、临时表,所以查询的效率会比较高。

where + having 区别

  • having子句用于分组后筛选,where子句用于行条件筛选;
  • having一般都是配合group by和聚合函数一起出现,如:count()、sum()、avg()、max()、min();
  • where条件子句中不能使用聚集函数,而having子句就可以;
  • having只能用在group by之后,where执行在group by之前;

分组使用不当,容易导致慢SQL问题。因为既用了临时表,也在排序,所以,在分组操作中,我们可以通过以下两点进行优化,以提升性能:

  • 在分组操作中,可以通过索引来提高效率。
  • 分组操作中,索引的使用也满足最左前缀法则。
  • order by null 禁止排序。
  • 如果统计的数量不大,直接使用临时内存表,如果数据量太大就需要用到磁盘临时表了,MySQL会帮助我们区分,我们一般情况下也有两种做法:
    • 调大tmp_table_size
    • 直接使用SELECT SQL_BIG_RESULT 字段

但是在我方的业务场景中,产品设计到处充满了趋势图,也就是存在时间列,根据前端的要求之一,我们使用到了Map的排序动作:使用LinkedHashMap存储完整时间,将一个有序的列表通过流转换为Map,并放入LinkedHashMap,最后遍历该LinkedHashMap,通过containsKey方法查询是否包含key,包含则get并插入,否则填入0。

八、写缓存的批量落库

为了提高写操作的效率,我们也有通过写入缓存后再进行批量入库的动作,方案缺点就是及时性不如直接落库,通过定时器及缓存数量的双重校验,将缓存中的数据一次性取出后进行批处理入库

但是,如果缓存出现故障,数据就有丢失的风险,落实该方案之前必须要有一套应急措施

同时这也是一个数据库缓存的双更新动作;分库分表留在mycat学习。

结束