佛系备战前端面试 | 掘金技术征文

616 阅读30分钟

陆续施工中......面试了两家,发现自己没准备好,每天学习一点,为拿到心仪的offer做好准备。同时,也想给在面试中的小伙伴一点帮助。

心得体会

根据投递岗位和工作年限准备

面试题是根据岗位要求和工作年限来提的。面试官的提问会和你的工作经验有关,或是比较基础的知识。按照自身实际情况进行准备就好,不用难为自己。

学习简历涉及到的深层次知识

面试准备要把简历上涉及的技术的深层次知识充分了解,避免一问三不知。要有意识的准备这一点。平常工作时,只是用技术,但学习不深。面试准备也是看别人总结的知识点。我应该根据自己的简历,构建自己的知识点,不然知识无穷无尽,永远也学不完。

面试题

测试

测试之前要问清楚功能要求,如是否允许原地修改。

  • 功能测试:正常输入
  • 极端测试:[], 只有一个元素的数组,已经有序的数组,特别大的数组
  • 非法输入:输入错误参数

算法

准备简单算法,动手敲代码之前先理清思路,考虑时间和空间复杂度,输入值和输出值,用到的变量。把这些和面试官沟通完毕,得到认可后敲代码。注意代码的命名、简练无重复、完整性和鲁棒性。以下是题目的简单分类。无标注的为leetcode的题目。分类标准是数据结构和解题方法,两个分类有交叉。

哈希

O(1)时间找到元素。用于判重,统计次数

物理结构-数组

数组适用于字符集小的情况,如只要小写英文字母,只有ASCII码,字符集大用对象。注意将字符转码charCodeAt后作为数组下标

  • 程序员面试金典01.01判定字符是否唯一
物理结构-对象
  • 查询元素的出现次数。剑指03. 数组中重复的数字。
    1. 两数之和
  • 判断是否重复 程序员面试金典02.01. 移除链表重复节点

字符串

  • 字符串操作经常从尾部开始,因为尾部有足够的空间,不用担心会覆盖原有的字符。 剑指试题05 替换空格

链表

注意在循环中的指针移动

删除节点
  • 程序员面试金典02.01.移除链表重复节点。每次判断pre.next是否需要删除,那么删除时,pre就是前置节点。
分割链表
  • 程序员面试金典02.04. 分割链表。注意思考的完整性,分割得到的before链表如果为空怎么办,after链表的尾节点的next置null
链表求和
  • 程序员面试金典02.05 链表求和,注意两个链表长度不同,还有维护进位
快慢指针
递归

递归占用空间:每次递归占用空间*递归次数

  • 程序员面试金典02.02. 返回倒数第 k 个节点的递归解法有助于理解递归

区分概念

  • 二叉查找树(二叉搜索树、二叉排序树)。左子树所有节点的值小于等于根节点,右子树所有节点的值大于根节点。二叉搜索树的每一个节点都在一个区间当中,可以通过递归传递区间端点值,检查每个节点是否在这个区间中来判断树是否是二叉搜索树。程序员面试金典04.05. 合法二叉搜索树
  • 平衡二叉树。所有节点的左子树和右子树的深度差不超过1。
  • 满二叉树
  • 完全二叉树:满二叉树最下层去掉几个叶子节点,并且剩余叶子都集中在该层最左边的位置。
  • 镜像…
  • 中序遍历,(二叉查找树),先序遍历
  • 单词查找树(trie):每个节点存储字符

位操作

  • 乘2相当于左移一位
  • 获取某一位:将1左移i位,得到00010000形式,与原数位与,其他位都得0,i位是0则结果为0,i位为1则结果不等于0.
  • 置位:将1左移i位,得到00010000形式,与原数位或,其他位不受影响,i位会变成1
  • 清0:与置位相反。将1左移i位,得到00010000形式,再取反,得到11101111,与原数位与,其他位不受影响,i位会变成0
  • 更新:先清0,再置1。写0就不用置1。 以上思路起源于0001000形式。

数学

  • 素数

动态规划

45分钟内要解决很难,所以面试考察较少。原理是用缓存避免重复计算。空间换时间。

双指针

双指针方法常用于数组,链表,注意题目的原地,排序关键字。

  • 链表中的快慢指针。876.找中点。141.判断是否有 环。142.找环起点。
  • 链表中有一个指针先走。19.删除链表的倒数第N个节点。
  • 数组中的快慢指针。常见于原地修改数组。26. 删除排序数组中的重复项,注意排序数组的重复元素紧挨的。27.移除元素。283.移动零。
  • 数组中的左右指针。167.两数之和-输入有序数组, 注意输入数组是递增的。二分查找。

排序

先问清楚时间和空间复杂度的要求,数据量等题意。

快速排序

每趟归位一个元素。平均logn趟,平均时间复杂度O(nlogn)。最坏情况下数组已经有序,每次都取第一个元素,导致划分极不均匀,时间复杂度为O(n平方)。空间复杂度取决于递归树高度,平均O(logn),最坏退化为冒泡排序,树是斜树,树高O(n)

归并排序

分治法的典型应用。先划分再归并。每次归并用时O(n),归并次数logn。总时间复杂度O(nlogn)

基数排序

利用整数位数有限的特点。先按个位对数字分组,再按十位分组。每次分组用时O(n),整数k位。总时间复杂度O(kn).

next greater number

要求下一个更大的数,就要知道后面元素的信息,所以对输入数组从后往前扫。比我小的都出栈,然后当前元素进栈,从而得到递减栈。对于当前元素,栈顶就是next greater number。当前元素进栈后等待下一轮的比较。

时间优化

字符串拼接时间、空间复杂度都是O(n平方)。 可以模拟java的stringBuffer,用数组不断push,存储结果,最后转为字符串。程序员面试金典01.06. 字符串压缩

网络

响应码

  • 301 永久重定向
  • 302 临时重定向
  • 304 未修改,使用浏览器缓存
  • 100 配置
  • 200 成功

即时通信技术

即时通信指服务器可以即时地将数据更新反映到客户端。 轮询。客户端每隔一定时间去请求服务器,服务器不管是否有数据更新都会响应。客户端收到响应,比对上次数据看是否更新。没有更新时,请求无效,浪费资源。而且数据更新可能存在延迟。

var xhr = new XMLHttpRequest();
    setInterval(function(){
        xhr.open('GET','/user');
        xhr.onreadystatechange = function(){
        };
        xhr.send();
    },1000)

长轮询。服务器收到请求后,保持住连接。在数据更新时再响应,断开连接。客户端收到响应后再次发出请求。

function ajax(){
        xhr.onreadystatechange = function(){
              ajax();
        };
    }

轮询和长轮询,由浏览器请求服务器,服务器被动响应,所以都会有高并发问题。

长连接 实现方式iframe流。在页面里嵌入一个隐蔵iframe,将这个隐蔵iframe的src属性设为对一个长连接的请求或是采用xhr请求,服务器端就能源源不断地往客户端输入数据。进度栏都会显示加载没有完成。

websocket。建立连接后,服务器和客户端地位平等,可以相互发送数据,不存在请求和响应的区别。全双工。

    // 向后端发送一个websocket连接请求
    let ws = new WebSocket('ws://127.0.0.1:5000/vote');
    ws.send(uid)
    ws.onmessage = function (event) {
        let data = JSON.parse(event.data);
    }

websocket 和 socket有什么不同?

websocket是应用层协议,socket编程接口。websocket在七层,tcp在四层。 websocket 多个客户端连接同一个服务器,两个客户端之间的消息需要通过服务器转发。 socket可以之间通过p2p进行两端的交互,不需要通过第三方转发。tcp本身就是全双工的

websocket握手过程:

  • 1、HTTP连接建立
  • 2、浏览器通过http向服务器发请求 协议变更为websocket
  • 3、服务器http响应 ,变更允许。之后按websocket协议通信
  • 4、数据通过tcp进行传输

websocket 和 http的区别?

相同点:建立在tcp之上 不同点:http单向,服务器只能被动等待客户端强求数据。无状态的,需要携带身份信息。 websocket全双工,websocket依赖http来握手。连接建立后,避免了http的无状态性。

TCP三次握手的意义

客户端和服务器相互告知起始序列号,保证报文的有序。 如果二次握手,连接就建立了,导致之前无效请求建立连接。而且,服务器消息的起始序列号没有得到确认,导致TCP不可靠。

TCP四次挥手的原因

TCP是全双工的。主动方发起断开连接的请求只表示我不会发送数据,但仍可以接收数据。被动方可能还有数据要发,所以先发ACK,数据发送完毕后,再发起断开连接的请求表示我也不会再发送数据了。

框架

MVVM

Model-View-ViewModel。ViewModel通过双向数据绑定将View和Model联系起来。 实现包括compile模板编译,observer数据劫持,watcher观察者和Dep依赖订阅器。

  • compile来编译模板,包括编译文本,编译指令,把更新函数添加到watch的update方法中。
  • observer改写get,set,收集依赖(watcher)加入Dep,监听数据变化。触发Dep的notify.
  • Dep是observer的一部分,存储watcher,数据更新时通知watch调用update
  • watcher接收observer的通知,执行complie中的更新方法。watcher是observer和complie的桥梁,一方面把自身加入Dep依赖订阅器,与observer建立联系,另一方面自身的update方法,会触发compile的更新方法。

vue响应式原理

响应式的意思是输入改变时,输出随着改变。 首先自己实现一个简单的响应式。推荐看视频

let price = 10;
let num = 2;
let total = total =  price * num; // 20
num = 3;
console.log(total); // 期待为30

要实现这样的效果,只要在输入改变时,再次执行得到输出值的函数就可以。

let storage = [];
let target = function(){
   total =  price * num;
}

function record(){
  storage.push(target);
}

function replay(){
  storage.forEach(run=>run());
}

record();
target();
console.log(total);//20

num = 3;
replay();
console.log(total);//30

用数组storge存储得到输出值的函数,第一次调用前纪录这个函数,输入改变时,再次调用storge中存放的函数。

class Dep{
  constructor(){
    this.subscribers = [];
  }
  depend(){
    if(target!==null && !this.subscribers.includes(target))
      this.subscribers.push(target);
  }
  notify(){
    this.subscribers.forEach(sub => sub());
  }
}

let dep = new Dep();
dep.depend();
target();
console.log(total);

num = 3;
dep.notify();
console.log(total);

类的写法,dep.depend()纪录函数,有includes检测,所以不会重复添加。dep.notify()再次调用。

let data = {
  price: 10,
  num: 2,
}
let target = null;

class Dep{
  constructor(){
    this.subscribers = [];
  }
  depend(){
    if(target!==null && !this.subscribers.includes(target))
      this.subscribers.push(target);
  }
  notify(){
    this.subscribers.forEach(sub => sub());
  }
}

Object.keys(data).forEach(key => {
  const dep = new Dep();
  let internalValue = data[key];
  Object.defineProperty(data, key, {
    get(){
      console.log(`get ${internalValue}`);
      dep.depend();
      return internalValue;
    },
    set(newVal){
      console.log(`set ${newVal}`);
      internalValue = newVal;
      dep.notify();
    }
  })
})

function watcher(func){
  target = func;
  target();
  target = null;
}
watcher(()=>{
  data.total =  data.price * data.num;
}); 

console.log(data.total);
data.num = 3;
console.log(data.total);

watcher将函数赋给target,并执行target,这样可以得到第一次的输出值。遍历data的属性,用Object.defineProperty为每个属性改写getter和setter,劫持数据的获取和改动,get时纪录函数,set时设置新值并重新执行。

现在理解vue中的响应式。这部分内容参考自 深入浅出Vue响应式原理 ,加入了一点我自己的理解。vue中的响应式要求源数据改变时,用到数据的地方自动更新。比如模板,计算属性。要实现这个功能需要

  • 监听数据变比
  • 知道哪些地方用到原数据
  • 数据变化时,通知那些地方更新

前两点可以用数据劫持,Object.defineProperty改写getter和setter。用到原数据的地方肯定会获取值、调用get,所以get可以知道有哪些地方用到源数据。set可以监听到数据的改变。

用到源数据的地方就是依赖源数据的地方,知道哪些地方用到原数据就是收集依赖。收集依赖后找个地方存储依赖。引入订阅者Dep类。

class Dep {
    constructor () {
        this.subs = [];
    }
    addSub (sub) {
    if(sub && !this.subs.includes(sub))//不会重复添加
        this.subs.push(sub);
    }
    notify () {
        this.subs.forEach((sub) => {
            sub.update();
        })
    }
}

依赖可能有很多,引入观察者Watcher来管理依赖。每个依赖都是一个watcher实例。 Dep中存储watcher实例。数据变化时,调用Dep的notify方法,由Dep通知所有的watcher更新数据,重新计算输出值,更新视图。所有脏活累活都是watcher干的,Dep只需要存储依赖,通知更新。使用的是观察者模型。

class Watcher {
  constructor(obj, key, cb) {
    // 将 Dep.target 指向自己
    Dep.target = this
    this.cb = cb
    this.obj = obj
    this.key = key
    this.value = obj[key]
    Dep.target = null
  }
  update() {
    // 获得新值
    this.value = this.obj[this.key]
   // 定义一个 cb 函数,这个函数用来模拟视图更新
    this.cb(this.value)
  }
}
function observe (obj) {
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
  function defineReactive (obj, key, value) {
    observe(value)  // 递归子属性
    let dp = new Dep(); //新增
    Object.defineProperty(obj, key, {
      get: function reactiveGetter () {
       // 将 Watcher 添加到订阅
       if (Dep.target === null) {
         new Watcher(obj, key, cb);
         dp.addSub(Dep.target) // 新增
       }
        return value;
      },
      set: function reactiveSetter (newVal) {
        observe(newVal) //如果赋值是一个对象,也要递归子属性
        if (newVal !== value) {
          value = newVal;
          // 执行 watcher 的 update 方法
          dp.notify() //新增
        }
      }
    })
  }
}

class Vue {
    constructor(options) {
        this._data = options.data;
        observer(this._data);
    }
}

每个data中的属性都有一个目标者dep,get时将依赖watcher加入订阅,set时设置新值,由dep通知watcher去update,获得新值(通过get),触发视图更新。

当传入一个js对象作为vue 的data选项时,vue通过observer遍历这个对象,对每个属性调用Object.defineProperty(),改写get和set方法,从而劫持数据获取和改动。访问数据会调用get,get中收集依赖加入Dep。数据变化时更新值,由dep通知watcher,watcher拿到新值,触发视图更新。

数据双向绑定

其实是响应式原理和MVVM的总结。响应式:Object.defineProperty()+观察者模式。MVVM:observer,compile和watcher。

vue生命周期

记住四个关键字:创建、挂载、更新、销毁。

  • beforeCreate:实例创建之前,data、methods不可用
  • created: 实例创建完毕,完成了data observer,事件登记。还未挂载,所以el不可用。
  • beforeMount:el值为生成的html,但数据未在dom上渲染
  • Mounted:数据在dom上渲染
  • beforeUpdate:数据更新,视图并未更新
  • updated:DOM重新渲染完毕,可以执行依赖于DOM的操作
  • beforeDestroy:实例销毁前,实例仍然可用
  • destroyed:实例已销毁。子实例被销毁,事件监听移除 详见vue2.0-vue实例的生命周期

vue非父子组件通信

eventBus

首先new一个vue实例并导出

import Vue from 'vue';  
export default new Vue(); 

传递消息的组件中emit,发布

Bus.$emit('getTarget', event.target);

接收消息的组件中on监听,订阅

Bus.$on('getTarget', target => {  
            console.log(target);  
        });  

eventBus 对于两个组件来说是全局的vue实例,然后分别调用这个实例的事件触发emit和事件监听on来实现通信。

vuex

当你搞不清楚组件之间的值是怎么传递的时候说明你该用vuex了。Vuex从入门到入门这篇文章讲的很好。简单总结如下:

state可以理解为前端的数据库,存储共享的数据。取数据用getter,存数据用mutation,存数据之前的数据处理用action, 将所有这些综合在一起就是store。

action在methods中用,getter在computed中用。mutation通过action来dispatch.

vue单页应用路由实现原理

参考vue:路由实现原理 路由的意思是路应该怎么走,怎么才能到达目的地,或者给出一条路,想知道这条路通向哪里。对于http请求来说就是由url得到网页。前端的单页应用可以在不发出http请求的情况下更新页面,这要靠前端路由。实现方式包括

  • window.location.hash
  • history 在H5中的新增方法 可以通过在vue-router中的mode参数传入不同值来控制不同的实现方式。

window.location.hash是url中#和#之后的部分,这部分是用来指导浏览器行为的,不会发送给服务器,所以可以利用这一点,实现不发请求就更改地址,然后更改页面的功能。通过改hash来更新页面就是前端路由的实现了。hash的每次改变会被记录到浏览器历史记录中去。

H5为history提供新方法pushState()和replaceState().当调用它们修改历史记录时,虽然url变了,但不会立即请求,只是触发popstate事件,所以可以监听popstate。

pushState可以修改url,只要与当前url同源即可,hash只能修改#部分。 pushState设置url可以与当前url相同,仍然可用加入历史记录栈中,hash设置的新值必须与当前不一样才能加入历史记录。

history模式也有不足。单页应用的理想场景是,仅在初次进入时加载index.html,之后的网络请求都通过ajax,不会通过url来请求页面。但碰到用户在地址栏键入回车的情况。hash不会修改url,所以对请求无影响。history模式修改了url,而这个url在后端无对应页面,会返回404。可以在后端对所以这种路由情况返回index.html,其余错误url返回404。

虚拟dom不一定快

  • 原生DOM操作与框架的封装操作 任何框架封装都不可能比原生DOM操作更快,因为框架封装DOM操作需要应对上层所有Api,具有普适性。任何框架的封装操作,都可以找到手动优化方法。但在实际开发中,不可能针对每一处做手动优化。框架提供的是在不用做手动优化的前提下,提供过的去的性能。这是可维护性与性能的平衡。 就算vue使用了虚拟dom,将一些改动先同步到虚拟对象上,然后去改动真实DOM。这其中,去改动真实DOM还是使用了原生的api去操作DOM,还是会不可避免的去reflow整个页面,如果不能把这些更新操作打包起来集中去更新真实DOM,那其实完全散失了虚拟DOM的作用性,反而变得更加冗余。

这时候框架的价值就体现出来了,vue中,如果在同一次事件循环中如果观察到有多个数据变化,vue会开启一个异步更新队列,并缓冲在同一事件循环中发生的所有数据改变。然后在下一个的事件循环‘tick’中,vue刷新队列并执行实际工作。这样就可以批量的去更新多次数据变化到虚拟dom对象中,diff差异,同步到页面中的真实dom里。

设计模式

发布订阅模式

用公众号来理解,公众号为发布者,订阅者是用户。用户订阅公众号后,公众号有新消息就会发布给用户。当然用户也可以取消订阅。下面是发布订阅模式的简单实现。 参考js实现发布订阅模式 PubSub需要实现三个功能,订阅,发布和取消订阅。

class PubSub{
  constructor(){
    this.handleBars = {}; // 保存监听事件
  }
  subscribe(eventName, handle){
    if(!this.handleBars.hasOwnProperty(eventName)){
      this.handleBars[eventName] = [];
    }
    if(typeof handle == 'function'){
      this.handleBars[eventName].push(handle);
    }else {
      throw new Error('need callback')
    }
  }

  publish(eventName, ...args){
    if(this.handleBars.hasOwnProperty(eventName)){
      this.handleBars[eventName].forEach(handle =>{
        handle.apply(null, args);
      })
    }
  }

  unsubscribe(eventName, handle){
    if(this.handleBars.hasOwnProperty(eventName)){
      this.handleBars[eventName].forEach((item, index) =>{
        if(item === handle){
          this.handleBars[eventName].splice(index, 1);
        }
      })
    }
  }
}

let sub = new PubSub();

function func1(type){
  console.log(`${type} func1`);
}
function func2(type){
  console.log(`${type} func2`);
}
function func3(type, data){
  console.log(`${type} func3 data ${data}`);  
}

sub.subscribe('ready', func1);
sub.subscribe('ready', func2);
sub.subscribe('complete', func3);

setTimeout(()=>{
  sub.unsubscribe('ready', func1);
  console.log(sub.handleBars)
  sub.publish('ready', 'ready');
  sub.publish('complete', 'complete', 123);
},1000)

PubSub类维护一个存储各种事件回调的handleBars对象。每次订阅,如果这个对象没有该属性,初始化该属性为数组。之后向这个数组添加回调。以下是handleBars存储的值。发布时,执行该事件对应的回调。取消订阅就是从handleBars中删除事件对应的回调。订阅、发布和取消订阅对应 存储、执行和删除。

{ready: Array[1], complete: Array[1]}
complete: Array[1]
0: ƒ func3()
ready: Array[1]
0: ƒ func2()

订阅时,传入要订阅的事件类型和回调函数。取消订阅类似;

sub.subscribe('ready', func1);
sub.unsubscribe('ready', func1);

发布事件,传参。第一个参数是事件类型,第一个参数是传递数据。

sub.publish('ready', 'ready'); 

观察者模式

class Observer{
  constructor(fn){
    this.update = fn;
  }
}

class Subject{
  constructor(){
    this.observers = [];
  }
  addObserver(observer){
    if(!this.observers.includes(Observer)){
      this.observers.push(observer);
    }
  }
  removeObserver(observer){
    this.observers.forEach((item, index) => {
      if(item === observer){
        this.observers.splice(index, 1);
      }
    })
  }
  notify(){
    this.observers.forEach(item => {
      item.update();
    })
  }
}

let subject = new Subject();
function func(){
  console.log('update')
}
let observer1 = new Observer(func);
subject.addObserver(observer1);
subject.notify();
subject.removeObserver(observer1);
subject.notify();
console.log(subject.observers); //[]

目标者存储观察者。观察者模式和发布/订阅模式的区别:

  • 目标者知道有哪些观察者,发布者不知道也不关心订阅者的存在。
  • 事件发生时由目标者通知观察者,观察者进行update()。发布者不会通知订阅者,而由调度中心(handleBars)通知订阅者。 *发布/订阅模式如vue响应式实现中的依赖收集和更新

行为面试

提问

项目组和美国同事是如何合作的?如何沟通? 有机会去美国培训? 组内几个人,如何分工? 现在做什么项目?

系统设计

答题过程:先问清除系统的功能,限制条件,先给出大框架,再给出细节。

可扩展性

性能问题:单个用户缓慢 扩展性问题:对于单个用户服务较快,但在高负载(多用户,大数据量)下缓慢 可扩展性:增加资源可以成比例的提高性能。或者为提高可靠性引入冗余,增加的资源不会影响性能。

垂直扩展

花钱买更好的服务器,但是是有限的。一钱不够,二现在的技术只能提供当前的设备。

水平扩展

与其买一台顶配设备,不如多买几台一般的设备。

延迟与吞吐量

  • 延迟:执行操作所用的时间
  • 吞吐量:单位时间内执行操作的数量
  • 我们的目标是在可接受的延迟内,达到最大的吞吐量

一致性和可用性

  • 一致性:读操作可以返回最新的写操作的结果
  • 可用性:在响应时间内返回结果
  • 分区容错性:网络故障导致网络发生分区时,仍能保证一致性或可用性

根据CAP理论,三者必须牺牲一个,才能保证另外两个。分布式系统肯定会有很多节点,而网络是不可靠的,网络节点发生故障必须考虑,所以必须保证分区容错性。 A向server1,server2写x后,A与server2之间发生网络故障。如要保证可用性,B,C读到的数据是不一致的。如要保证一致性,则B,C需要等待一段时间,在等待期间不能拿到结果。 需要注意的是,牺牲一致性牺牲的是实时一致性,但可以保证数据最终达到一致状态。

一致性模式

  • 弱一致性 可能读到新写入的数据,也可能不能。常见于网络电话,视频聊天,实时多人游戏。会有短暂的不同步或几秒延迟。如果在通话中丢失了几秒,重新连接后,不会听到这几秒的语音。
  • 最终一致性 在一段时间后可以读到新写入的数据。数据不是实时更新,是异步更新的,比如email,DNS。在追求可用性的系统中,经常使用最终一致性
  • 强一致性 可以立即读到新的数据,数据被同步复制。常见于文件系统,关系型数据库。

故障切换

为了可用性,为了避免单点故障,需要冗余。根据冗余的功能不同,故障切换时的操作也不同。

  • 工作到备用的切换(主从切换) 备用服务器不工作。工作服务器发心跳信号到备用服务器,心跳中断时,备用服务器切换成工作服务器的IP并提供服务。
  • 双工作切换(主主切换) 两台服务器都工作,在他们之间分散负载。切换快。
  • 故障切换的缺点 要实现故障切换要额外的硬件。在数据未写入到备用服务器时,工作服务器宕机,数据会丢失。

DNS

www.baidu.com的解析过程:

  • 查浏览器缓存,未命中进行下一步
  • 操作系统缓存,host文件,未命中进行下一步
  • 路由器缓存,未命中进行下一步
  • 本地域名服务器缓存,未命中进行下一步
  • 本地域名服务器向根域名服务器请求,根域名服务器返回com.顶级域名服务器的地址。
  • 本地域名服务器向顶级域名服务器请求,顶级域名服务器返回baidu.com域名服务器的地址
  • 本地域名服务器向baidu.com域名服务器请求,二级域名服务器查到www主机对应的IP地址,返回
  • 本地域名服务器缓存结果,返回给客户端
  • 客户端缓存,用IP发起请求

CDN

将视频,图片,html,css,js等静态资源缓存到更近的地方,使得用户可以共享资源,缩短响应时间。 网游加速器则是架设高带宽机房,用两端高速路代替一段土路。

  • CDN推送。服务器主动将内容推送到CDN,缓解目标服务器的压力,缺点是存在延迟。
  • CDN拉取。第一次访问时,从服务器拉取内容,返回,缓存。

反向代理

正向代理隐藏客户端,如某上网工具。 反向代理隐藏服务器,将请求转发给服务器。好处多多:

  • 缓存,提供CDN功能
  • 安全,用户只知道反向代理的IP
  • 可扩展性,用户只知道反向代理的IP,内部服务器可以增加,减少,修改配置
  • 内部相对安全,所以可以在这加密,解密。内部服务器不需要进行这样的耗时操作。

负载均衡器

分担单个繁重工作或多个并发请求。 负载均衡器可以隐藏内部服务器。DNS只知道负载均衡器的IP,内部服务器的私有IP外部并不知道。

如果每台服务器都存储相同内容,会存在数据同步问题。可以将资源分类存储。如图片服务器,视频服务器,HTML服务器,这样修改的总发生在同一台服务器,而且这种服务器,如图片服务器可以做相应的优化。另一种方式是内部服务器不存储与用户相关的信息,而把这些内容存储在一个公用的数据库中。

  • 四层交换。根据IP地址和端口号转发请求,TCP连接在客户端和服务器之间建立,需要修改报文的目标IP地址。
  • 七层交换。根据应用层报文内容选择服务器。所以必须代理,此时负载均衡器与客户端,服务器与负载均衡器之间都会建立TCP连接。

如何实现负载均衡

  • DNS返回IP地址,DNS可以用round robin轮询调度,以循环的方式返回IP地址。但可能有个服务器一直分到负载量大的用户。
  • load balancer也可以用round robin轮询调度。如果每台服务器的资源一样,这回break session,导致用户重新登录,购物车不一致,因为第二次访问的是另一台服务器。解决方案一:有一台专门的session server,其余所有server去session server取session。解决方案二,load balancer存储用户访问的服务器的ID,对应的cookie,用户下一次来知道去哪一台服务器。
  • 负载均衡是为了实现可扩展性和冗余
  • 需要在以下三个层次平衡负载,外部请求到web server,web server到平台层,平台层到数据库

平台层

在web server和数据库之间添加平台层,将业务功能从web server分离出,在修改API只需要修改平台层。

RAID

磁盘坏了怎么办。磁盘阵列。RAID1方案提供镜像磁盘做备份,工作磁盘坏了,镜像磁盘接替服务,再插入一个磁盘作为新的镜像磁盘,在过程中服务不会中断。

缓存

缓存便于读,不便于写。写的时候,如缓存一堆HTML文件,修改字体, 修改很多文件。

  • 对磁盘的缓存。RAM中缓存文件对应的磁盘地址。第一次访问未命中,访问磁盘慢,将地址记录在哈希表中,下次先查哈希表。时间长哈希表会越来越大,所以需要删除记录,比如用FIFO
  • 可以在多层次进行缓存。浏览器,服务器,数据库,CDN,内存缓存
  • 内存缓存,如redis,memcached.数据在ram中,比磁盘快。由于内存小,需要使用最近最少未使用算法淘汰数据。

缓存策略

  • 缓存模式。缓存不和存储器直接交互。应用执行以下操作: 从缓存中查数据,未命中则查询存储器,查到数据后存到缓存,将数据返回给客户端。存在缓存过期问题,可以设置TTL
  • 直写模式。应用把缓存作为主要的数据存储读写缓存。读很快。应用修改数据写到缓存,缓存同步的把数据写入数据库。返回结果。同步写导致操作很慢。
  • 回写。数据从cache异步地写入数据库

解决单点故障

工作-备份 双工作 异地备份,一个数据中心故障,通过DNS返回另一个数据中心的IP

数据库

关系型数据库

如sql.用表来存储数据项的。关系型数据库要求ACID,原子操作,强一致性。

  • 实现有主从复制:读写master数据库,读slave,可以负载均衡和备份。 主库复制将写入复制到多个从库中,树状的从库将数据复制到更多从库。主库离线,系统可以以只读方式运行。
  • 主主复制 两个主库都负责读写。主库存在一致性问题,或者存在同步写延迟
  • 两者共同的缺点:复制。主库在将新写入的数据复制到其他节点前挂掉,则数据丢失。读的从库越多,写入代价越高。
  • 联合。按功能对数据库进行划分,如产品数据库,用户数据库。可以并行写入,提高负载能力。减少复制延迟。
  • 分片 将数据分散到多个数据库。每个数据库只是数据的一个子集。优点与联合相同,减少读写压力,减少复制,提高缓存命中率。

NoSQL非关系型数据库

强调可用性高于一致性,所以是最终一致性。无法实现ACID。包括键值类型数据库,文档类型数据库,图数据库。

结构化数据(用户名,密码,地址),需要JOIN用sql.非结构化,数据量大,不用join用NoSql.如社交数据,埋点数据,日志,购物车(临时数据)。

服务器架构

load balancer连接所有服务器,为避免单点故障,实际应该还有一台。

任务队列

客户端发起耗时操作,不阻塞客户端,把任务放入任务队列,由一个worker异步地进行处理。期间,客户端可以返回一些结果看似任务已经完成。如发推文,推文立即出现在当前账户,但其他人需要等待一些时间才能看到推文。

高并发处理方法

  • html静态化
  • 各级缓存
  • CDN
  • 负载均衡
  • sql数据库集群,主从模式
  • 图片服务器。将图片与页面分离,可以避免服务器因为图片问题崩溃。而且也可以正对图片服务器做针对性优化。

高并发处理可以从客户端和服务端两个层面来处理,前端要做的是请求能不发给服务器就不发,后端策略是分而治之,提高单个请求的处理速度。

前端

  • 缓存:5M的localStrong,强缓存和协商缓存
  • 合并请求
  • 压缩资源,减少流量
  • 异步请求,分批获取数据
  • 节流,5秒只请求一次

后端

  • CDN
  • 根据业务拆分到不同的平台服务器
  • 负载均衡器,集群,分布式架构
  • 专门的图片、视频等静态服务器
  • html静态化

数据库

数据库集群,进行读写分离

redis

内存中的数据结构存储系统,所以可以用作缓存,数据库,消息队列。支持键到5种数据类型的映射。

前端异常监控

参考 Tencent CDC(cdc.tencent.com/2018/09/13/… 前端异常监控分四步:前端采集异常上传到服务器,服务器存储异常,机器分析或人工可视化分析,告警或预警。

错误原因

代码错、网络慢、系统错(内存不足)

异常采集

采什么?who did what get which exception in which environment.

  • 用户信息:用户状态,权限,在哪个终端登录。
  • 行为信息:操作,上传数据,后端返回数据,时间,页面
  • 异常信息:异常级别,用户操作的DOM节点
  • 环境信息:网络环境,设备型号,客户端版本,API接口版本

使用 requestId 唯一标识每个请求,请求会涉及到请求报文,数据库操作,涉及到的缓存操作,服务请求,响应报文。

异常捕获

全局捕获便于管理,单点捕获作为补充。 window.onerror全局捕获,try…catch单点捕获

异常录制

异常发生时,异常根源可能很远。所以要记录用户的每一个操作。

异常级别

紧急性,重要性区分

上报方案

前端存储,IndexedDB浏览器数据库,类似于noSql的键值对存储 上报方案:

  • 即时上报
  • 批量上报
  • 区块上报,异常相关操作,去除在批量上报中重复的。区块上报比即时上报稍慢
  • 用户主动上报,可以慢点上传

压缩方案:gzip

异常存储

后端有专门的日志服务器。接入层负责负责对信息进行甄别和处理后转入消息队列。

异常分析

用户纬度,时间纬度(异常前后),性能纬度,环境纬度,场景回溯。

异常修复

前端代码经过压缩后发布,上传的stack信息需要还原成源码以定位异常。将sourcemap上传到日志服务器,用sourcemap对stack信息进行解码。这就要求sourcemap必须与生产环境代码的版本对应