墨菲定理:任何事情都没有表面看起来那么简单;所有的事情都会比你预估的时间长;会出错的事情总会出错;你总是担心的事情,它总会发生的。

如何规范写日志

日志要有分隔符

大多数时候使用|作为分隔符。分析数据的时候直接用分隔符拆分对应的字段和属性。

# 正确例子
类名|方法名|输入参数|输出参数
# 错误例子1(不用分隔符)
类名方法名输入参数输出参数
# 错误例子2(用多种分隔符)
类名#方法名 输入参数|输出参数

通过UUID编号来保证日志的连贯性

每次请求都应该有一个唯一编号,每记录一次日志还应该有一个唯一编号。例如:

api.ERROR: 79a8ea37dceff105|0|responseObj is error:{"return_code":"SUCCESS","return_msg":"OK"}
api.ERROR: 79a8ea37dceff105|1|App\Request|{"subject":"201906179ae"}

79a8ea37dceff105是本次请求的全局uuid,0,1表示记录的顺序编号。这样能保证一次请求的所有日志都可追踪,可查看链路信息。下面是一个简单的代码:

class Logger {
    constructor() {
        this.uuid = Math.random().toString(16).slice(2);
        this.id = 0;
    }
    log(msg) {
        console.log(`${this.uuid}|${this.id++}|${msg}`);
    }
}

打印日志的时候会自动添加请求编号实现日志链路的追踪。

对于数组、对象类型统一用JSON格式

比较通用,方便解析。特别是添加字段的时候节省大量的维护成本。

重要日志要脱敏

用户绑定手机号或者邮箱时,会把手机号和邮箱作为参数传到服务端,我们在记录日志时应该把用户手机号和邮箱做脱敏处理,比如中间几位用*号代替。还有密码,身份证等敏感信息更要脱敏。日志是最容易泄露的数据,很难去保护,如果哪天大量用户的手机号等信息泄露可能就是日志未脱敏惹的祸,这个严重的大锅只能自己背。

301和302重定向的区别

301是永久重定向,会保存在用户的浏览器中,这样我们就无法跟踪用户行为了。

怎么限制文件上传大小?原理是什么

express中使用的是multer处理文件上传(multipart/form-data),用户上传大文件不仅会占用大量的内存资源,也会浪费带宽和磁盘。multer内部依赖的是busboy,这是一个解析stream为HTML的模块,代码比较简单:Busboy继承了可写流,并重写了_write方法:

function Busboy(opts) {
  if (!(this instanceof Busboy))
    return new Busboy(opts);
  if (opts.highWaterMark !== undefined)
    WritableStream.call(this, { highWaterMark: opts.highWaterMark });
  else
    WritableStream.call(this);

  this._done = false;
  this._parser = undefined;
  this._finished = false;

  this.opts = opts;
  if (opts.headers && typeof opts.headers['content-type'] === 'string')
    this.parseHeaders(opts.headers);
  else
    throw new Error('Missing Content-Type');
}
inherits(Busboy, WritableStream);

Busboy.prototype._write = function(chunk, encoding, cb) {
  if (!this._parser)
    return cb(new Error('Not ready to parse. Missing Content-Type?'));
  this._parser.write(chunk, cb);
};

在parseHeaders方法中根绝HTTP的header选择合适的parser:

Busboy.prototype.parseHeaders = function(headers) {
  this._parser = undefined;
  if (headers['content-type']) {
    var parsed = parseParams(headers['content-type']),
        matched, type;
    for (var i = 0; i < TYPES.length; ++i) {
      type = TYPES[i];
      if (typeof type.detect === 'function')
        matched = type.detect(parsed);
      else
        matched = type.detect.test(parsed[0]);
      if (matched)
        break;
    }
    if (matched) {
      var cfg = {
        limits: this.opts.limits,
        headers: headers,
        parsedConType: parsed,
        highWaterMark: undefined,
        fileHwm: undefined,
        defCharset: undefined,
        preservePath: false
      };
      if (this.opts.highWaterMark)
        cfg.highWaterMark = this.opts.highWaterMark;
      if (this.opts.fileHwm)
        cfg.fileHwm = this.opts.fileHwm;
      cfg.defCharset = this.opts.defCharset;
      cfg.preservePath = this.opts.preservePath;
      this._parser = type(this, cfg);
      return;
    }
  }
  throw new Error('Unsupported content type: ' + headers['content-type']);
};

一般上传文件的content-type是multipart,所以调用的是Multipart,先看下这个类的初始化,中间有一个计算文件限制大小的:

var fileSizeLimit = (limits && typeof limits.fileSize === 'number'
                       ? limits.fileSize
                       : Infinity)

上面计算的这个文件大小阈值会在onData回调函数中用到:

onData = function(data) {
    if ((nsize += data.length) > fileSizeLimit) {
        var extralen = (fileSizeLimit - (nsize - data.length));
        if (extralen > 0)
            file.push(data.slice(0, extralen));
        file.emit('limit');
        file.truncated = true;
        part.removeAllListeners('data');
    } else if (!file.push(data))
        self._pause = true;
};

可以看出当文件大小超过限制的时候移除了data的事件监听,从而后续不会触发data事件了,并且触发了limit,事件,这个事件在multer层接收,并返回错误:

fileStream.on('limit', function () {
    aborting = true
    abortWithCode('LIMIT_FILE_SIZE', fieldname)
})

将错误回调到express中返回给前端。原理就是流式读取,当发现读取的字节数超过限制的时候立即结束,关闭流,并且报错。

如何理解NIO中的Selector

selector是多路复用器,是NIO中的核心。

API网关有什么用

SpringCloud中的API网关可以实现认证登陆、授权、限流、日志、监控。常见的组件有:

  • 基于nginx的二次开发:KONG,API Umbrella
  • Zuul:netflix提供的,包括Zuul1,Zuul2
  • Spring Cloud Gateway
  • Linkerd

如何进行接口的鉴权

JWT

由于HTTP的无状态性,客户端需要携带凭证来调用接口。传统web依赖cookie和session。

image.png
image.png

而原生app不是基于浏览器的,常用的解决方案是JWT。它的原理是服务端认证完成后生成一个JSON对象,返回给用用户,后续客户端的所有请求都会带上这个JSON对象。

image.png
image.png

如上图所示,一个JWT分为3个部分:Header,Payload,Signature。左边的字符串以.分割后解码(Base64)可以得到右边的部分。其中最关键的是Signature:编码后的header和编码后的payload以及一个秘钥使用header中指定的签名算法进行签名:

signature = HMACSHA256(base64Encode(header) + '.' + base64Encode(payload),secert)

因为秘钥secert保存在服务器,即使别人拿到之后篡改了token中的数据,整个字符串就发生了变化,服务端就会鉴权失败,这样就可以保证安全性。一个基于Koa的实现如下:

const Koa = require('koa');
const Router = require('koa-router');
const jwt = require('jsonwebtoken');

const app = new Koa();
const router = new Router();

const privateKey = 'sdfihu83u84283&^$#43243#@@';

// 自定义ApiError,业务中直接throw,经过koa的错误处理中间件统一处理,不用再controller中手动构造JSON返回了
class ApiError extends Error {
  constructor({code, message, httpStatus}) {
    super(message);
    this.code = code;
    this.httpStatus = httpStatus;
  }
}

const ERRORS = {
  AUTH_FAILURE: {
    code: 101,
    message: '认证失败,非法token',
    httpStatus: 401
  }
};

// 检查用户权限的中间件
function userAuth(ctx, next) {
  const token = ctx.query.token;

  try {
    const decoded = jwt.verify(token, privateKey);
    ctx.state.uid = parseInt(decoded.uid);
    next();
  } catch (e) {
    throw new ApiError(ERRORS.AUTH_FAILURE);
  }
}

router.use(['/users', '/admin'], userAuth);

router.get('/login', (ctx, next) => {
  const {uid, password} = ctx.query;
  // 验证uid和密码,通过后下发token,客户端拿到token后可以放到自己的本地缓存中,以后请求的时候每次带上这个token即可
  const token = jwt.sign({uid}, privateKey, {expiresIn: '2h'});
  ctx.body = {
    code: 0,
    message: 'OK',
    data: {
      token
    }
  };
});

router.get('/users', (ctx, next) => {
  // 用户授权中间件通过后执行下面的逻辑
  const uid = ctx.state.uid;
  ctx.body = {
    uid,
    uname: '公孙龙',
    age: 32
  };
});

app
  .use(async (ctx, next) => {
    return next().catch(err => {
      let {code, message, httpStatus} = err;
      httpStatus = httpStatus || 500;
      ctx.body = {code, message};
      ctx.status = httpStatus;
      // 做一些定制化处理
      // if (err instanceof ApiError) {
      //
      // }
    });
  })
  .use(router.routes())
  .use(router.allowedMethods())
  .listen(9999);

进行统一错误处理的时候其实遇到了一个坑:原先采用的不是错误处理中间件而是使用监听koa的全局onerror事件,代码如下:

app.on('error',(err,ctx) => {
    console.error('这句话会执行,error occur',err);
    ctx.body = {message:'这两句话不会执行'};
    ctx.status = 200;
})

但是在路由中抛出错误的时候捕获到了错误,但是前端还是得到的字符串是Internal Server Error,状态码也是500,仔细查看源码发现koa对于每个请求中的错误调用的是其Context上的onerror

// application.js
/**
* Handle request in callback.
*
* @api private
*/

handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}

// context.js
/**
* Default error handling.
*
* @param {Error} err
* @api private
*/

onerror(err) {
    // don't do anything if there is no error.
    // this allows you to pass `this.onerror`
    // to node-style callbacks.
    if (null == err) return;

    if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));

    let headerSent = false;
    if (this.headerSent || !this.writable) {
        headerSent = err.headerSent = true;
    }

    // delegate
    this.app.emit('error', err, this);

    // nothing we can do here other
    // than delegate to the app-level
    // handler and log.
    if (headerSent) {
        return;
    }

    const { res } = this;

    // first unset all headers
    /* istanbul ignore else */
    if (typeof res.getHeaderNames === 'function') {
        res.getHeaderNames().forEach(name => res.removeHeader(name));
    } else {
        res._headers = {}; // Node < 7.7
    }

    // then set those specified
    this.set(err.headers);

    // force text/plain
    this.type = 'text';

    // ENOENT support
    if ('ENOENT' == err.code) err.status = 404;

    // default to 500
    if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;

    // respond
    const code = statuses[err.status];
    const msg = err.expose ? err.message : code;
    this.status = err.status;
    this.length = Buffer.byteLength(msg);
    res.end(msg);
},

ctx中的onerror事件通过this.app.emit('error', err, this);将错误委托给application,接下来移除了httpHeaders,设置http状态码,最后最关键的一句话是res.end(msg),这句话关闭http的输入流,所以即使我们后面自己设置ctx.body也不会响应任何信息给前端了,而msg默认就是Internal Server Error。所以这里使用application的的onerror事件处理错误其实是有坑的,而对于ctx的onerror方法这是个私有api官方并没有提供重写的方法。所以错误处理还是尽量选用中间件吧。

系统调优

  • 高并发服务器建议调小 TCP 协议的 time_wait 超时时间。说明:操作系统默认 240s 后才会关闭 time_wait 状态的连接。在高并发访问下,服务器端会因为处于 time_wait 的连接数过多,而无法建立新的连接,所以需要在服务器上调小此等待值。
  • 给 JVM 设置 -XX:+HeapDumpOnOutOfMemoryError 参数,让 JVM 碰到 OOM 场景时输出 dump 信息。说明:OOM 的发生是有概率的,甚至有规律地相隔数月才出现一例,出现时的现场信息对查错非常有价值。
  • 在线上生产环境,JVM 的 Xms 和 Xms 设置一样大小的内存容量,避免在 GC 后调整堆大小带来的压力。

密码学

非对称加密算法

永远不要自己实现加密算法,公开的加密算法往往是最安全的。因为公开的密码算法是经过全世界的专家经过很多年的研究的出来的,我们不可能比这些专家厉害!

对称加密算法(例如AES)中使用的加密和解密秘钥是一样的,因此配送秘钥的过程中存在一定的风险。如果采用公钥密码方案的话就不存在秘钥配送的问题,从某种程度上说公钥秘钥是密码学上迄今为止最伟大的发明

非对称加密的缺点是运算速度慢。

为什么我们常常说公钥加密私钥解密

其实公钥和私钥都能用来加密和解密。也就是说:同一对钥匙,公钥加密只能私钥解密,私钥加密只能公钥解密。那么我们平常为什么不说私钥加密呢?因为公钥是公开的,任何人人手一份公钥,这不就等于没有加密么?因此在实践中,基本上不用私钥进行加密。私钥的一般用途是签名。

中间人攻击

RSA算法中的中间人攻击
RSA算法中的中间人攻击

例如电信运营商的DNS劫持(或者HTTP劫持),篡改网页加入自己的广告,最好的解决方案是采用HTTPS协议(多了一个步骤SSL证书)。

域名劫持:通过劫持掉域名的DNS解析结果,将HTTP请求劫持到特定IP上,使得客户端和攻击者的服务器建立TCP连接,而非和目标服务器直接连接,这样攻击者就可以对内容进行窃取或篡改。在极端的情况下甚至攻击者可能伪造目标网站页面进行钓鱼攻击。一般而言,用户上网的DNS服务器都是运营商分配的,所以,在这个节点上,运营商可以为所欲为。例如,访问http://jiankang.qq.com/index.html,正常DNS应该返回腾讯的ip,而DNS劫持后,会返回一个运营商的中间服务器ip。访问该服务器会一致性的返回302,让用户浏览器跳转到预处理好的带广告的网页,在该网页中再通过iframe打开用户原来访问的地址。

HTTP劫持/直接流量修改:在数据通路上对页面进行固定的内容插入,比如广告弹窗等。在这种情况下,虽然客户端和服务器是直接建立的连接,但是数据内容依然可能遭到野蛮破坏。例如在运营商的路由器节点上,设置协议检测,一旦发现是HTTP请求,而且是html类型请求,则拦截处理。后续做法往往分为2种,1种是类似DNS劫持返回302让用户浏览器跳转到另外的地址,还有1种是在服务器返回的HTML数据中插入js或dom节点(广告)。

HTTPS的原理

HTTP协议存在以下的几个问题:

  1. 泄密。数据明文传输
  2. 篡改。例如植入广告
  3. 假冒。访问的站点是假网站,例如域名劫持、钓鱼网站

解决第一个问题可以采用加密。但是可能存在中间人攻击。对于对称加密算法,截获了秘钥就可以为所欲为;对于非对称加密,中间人在服务器发送公钥给客户端的时候将其替换为自己的公钥发送给客户端,客户端对公钥的真实性是毫无感知的,它下次发送数据的时候采用中间人的公钥进行加密,中间人拿到数据后用自己的私钥进行解密,然后可以修改客户端的数据,用自己的公钥加密后发送给服务器(同样服务器也不知道中间人是假的客户端)。对于服务器来说,中间人是真正的客户端;而对于客户端来说,中间人是真正的服务器

为了解决中间人攻击

客户端在第一次和服务器进行通信的时候,服务器需要展示自己的数字证书,证明自己是真正的服务器。证书是由权威机构(CA)颁发的、无法伪造的。客户端拿到证书后就需要验证证书编号是否能在对应的CA机构查到,并且核对该证书的基本信息:例如证书上的域名是否和当前访问的域名一致等,还可以拿到证书中服务器的公钥信息用于协商对称秘钥!

证书颁发了,那怎么防止证书在传输过程中不被篡改呢?万一中间人截获到数字证书,把公钥改成自己的,那岂不是仍然无法保证安全了?这个时候就需要数字签名了。

服务器在向CA申请证书的时候,CA在颁发证书的同时会连同证书和证书的摘要(经过CA机构自己的私钥进行加密的)一同发送给服务器.

get_certificate_from_ca.png
get_certificate_from_ca.png

通过下图可以看到CA给服务器颁发证书的时候是有自己的专属“公章”的。

image.png
image.png

下图所示是知乎的证书,采用了2048位的RSA算法:

知乎的证书
知乎的证书

服务器在与客户端通信的时候,就会将数字证书和数字签名出示给客户端了。客户端拿到数字证书和数字签名后,先通过操作系统或者浏览器内置信任的CA机构找到对应CA机构的公钥对数字签名进行解密,然后采用同样的摘要算法计算数字证书的摘要,如果自己计算的摘要与服务器发来的摘要一致,则证书是没有被篡改过的!这样就防止了篡改!第三方拿不到CA机构的私钥,也就无法对摘要进行加密,如果是第三方伪造的签名自然也在客户端也就无法解密,这就防止了伪造!所以数字签名就是通过这种机制来保证数字证书被篡改和被伪造。

digital_signature.png
digital_signature.png

这里需要注意一点,一个是CA机构的公钥,内置在客户端,用来解密数字签名!另一个是目标服务器的公钥,在数字证书内容里,用来协商对称密钥!

其实HTTPS的出现就是结合了上面的手段:客户端请求服务器的证书(公钥),服务器返回证书,客户端拿到这个证书后校验(通过内置的CA),校验成功后生成一个随机数,然后使用公钥对随机数进行加密后发送,服务器收到后使用自己的私钥解密。接下来双方的通信都是基于该随机数的对称加密。

https_arch.png
https_arch.png
  1. 客户端发起HTTPS请求.
  2. 服务端的配置:采用HTTPS协议的服务器必须要有一套数字证书,可以自己制作,也可以向组织申请。区别就是自己颁发的证书需要客户端验证通过,才可以继续访问,而使用受信任的公司申请的证书则不会弹出提示页面。这套证书其实就是一对公钥和私钥。
  3. 传送证书.这个证书其实就是公钥,只是包含了很多信息,如证书的颁发机构,过期时间等等。
  4. 客户端解析证书。这部分工作是有客户端的TLS来完成的,首先会验证证书是否有效,比如颁发机构,过期时间等等(客户端会收到服务器发送的证书以及证书的摘要(CA使用自己的私钥进行加密,也叫做数字签名),使用浏览器内置信任的CA的公钥对数字签名进行解密,并用同样的算法对计算证书的摘要。比较解密出来的摘要和自己算出来的摘要是否相同)。如果发现异常,则会弹出一个警告框,提示证书存在问题。如果证书没有问题,那么就生成一个随即值。然后用证书(公钥)对该随机值进行加密。
  5. 传送加密信息。这部分传送的是用证书加密后的随机值,目的就是让服务端得到这个随机值,以后客户端和服务端的通信就可以通过这个随机值来进行加密解密了。
  6. 服务端解密信息。服务端用私钥解密后,得到了客户端传过来的随机值。这个随机值就是对称加密的秘钥。
  7. 传输加密后的信息。服务器发送对称加密的内容
  8. 客户端解密信息。客户端用之前发送给服务器的秘钥进行解密

为什么Charles能够抓取HTTPS

charles-https.png
charles-https.png

HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥,前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。

为什么DNS服务器要采用分层结构

互联网的规模太大,域名数量更是不胜枚举。一台DNS Server存储是不现实的。因此DNS在设计的时候就是分层架构,每一部分存储下一级的相关信息。

举个例子,我们想知道 www.example.com 的 IP 地址是什么,将这个请求发送给了我们的 DNS Server。

DNS Server 需要先问根域名服务器,谁负责管理 .com?然后再问 .com 域名服务器,谁负责管理 example.com?最后,再问 example.com 域名服务器,www.example.com 的 IP 地址是什么,从而获得答案返回给我们。

以上就是域名的分级架构,域名查询需要从根开始,一级一级向下,直到找到答案。当然,因为域名查询是一个高频词的动作,无时无刻都在发生,如果每次都是这样一层一层获取,效率将十分低下,因此,DNS 系统中大量使用缓存,每一个中间环节都会缓存相关结果来节省时间提高效率。在不考虑缓存的情况下,我们每次都需要询问根域名服务器,那么DNS服务器是如何知道根域名服务器的地址呢?它其实采用了一个非常简单的做法——使用配置文件写死。全世界一共13组根域名服务器:[a-m].root-servers.net。IINA提供的配置文件。下载下来之后配置相关的DNS软件即可。

谈谈TCP中的状态机

image.png
image.png
状态 描述
LISTEN 等待来自远程TCP应用程序的请求
SYN_SENT 发送连接请求后等待来自远程端点的确认。TCP第一次握手后客户端所处的状态
SYN-RECEIVED 该端点已经接收到连接请求并发送确认。该端点正在等待最终确认。TCP第二次握手后服务端所处的状态
ESTABLISHED 代表连接已经建立起来了。这是连接数据传输阶段的正常状态
FIN_WAIT_1 等待来自远程TCP的终止连接请求或终止请求的确认
FIN_WAIT_2 在此端点发送终止连接请求后,等待来自远程TCP的连接终止请求
CLOSE_WAIT 该端点已经收到来自远程端点的关闭请求,此TCP正在等待本地应用程序的连接终止请求
CLOSING 等待来自远程TCP的连接终止请求确认
LAST_ACK 等待先前发送到远程TCP的连接终止请求的确认
TIME_WAIT 等待足够的时间来确保远程TCP接收到其连接终止请求的确认

TCP三次握手

  • 第一次握手:客户端发送SYN包(SYN = J),并进入SYN_SENT状态,等待服务器确认。
  • 第二次握手:服务端收到SYN包,确认客户端的SYN(ACK = J + 1),同时发送自己的SYN包(SYN = k),即服务器发送SYN + ACK包,此时服务器进入SYN_RCVD状态。
  • 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包(ACK = K + 1).此包发送完毕后客户端和服务器进入ESTABLISHED状态,握手完成。

下图显示了TCP三次握手的过程,以及客户端和服务端状态的变化。

image.png
image.png

半连接队列是什么

服务器第一次收到客户端的SYN之后就会进入到SYN_RCVD状态,此时双方还没有完全建立连接,服务器会把这种状态下的请求放入到一个队列中,这种队列就是半连接队列。与此对应,全连接队列指的就是已经完成3次握手,建立起连接的请求。如果队列满了(backlog)就可能出现丢包

这里在补充一点关于SYN-ACK 重传次数的问题: 服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传,如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s, 2s, 4s, 8s, ….

正因为TCP设计的有这些缺陷,有一种针对半连接的攻击叫做SYN攻击,大量构造三次握手中的第一次握手包SYN包浪费服务器CPU和内存资源,造成半连接队列溢出从而正常客户端发送的请求直接被服务器丢了,这样对于正常客户端服务就不可用了,检测SYN攻击也非常简单:

netstat -n | grep SYN_RECV

当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。

为什么需要3次握手

为什么要三次握手,我握两次不行吗?我觉得我说发,你说好,不就完了吗,非要矫情一下,握第三次手的意义是什么?

首先我们先来理解一下为什么需要握手?

客户端和服务器端通信前需要连接,而”握手“作用就是为了证明,客户端的发送能力和服务器端的接受能力都是正常的,这是”握手“来达到的目的。

第一次握手:客户端发送网络包,服务器端收到了,这样服务器端就能证明:客户端的发送能力、以及服务器端的接收能力都是正常的。

第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。 从客户端的视角来看,我接到了服务端发送过来的响应数据包,说明服务端接收到了我在第一次握手时发送的网络包,并且成功发送了响应数据包,这就说明,服务端的接收、发送能力正常。而另一方面,我收到了服务端的响应数据包,说明我第一次发送的网络包成功到达服务端,这样,我自己的发送和接收能力也是正常的。

第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力,服务端的发送、接收能力是正常的。 第一、二次握手后,服务端并不知道客户端的接收能力以及自己的发送能力是否正常。而在第三次握手时,服务端收到了客户端对第二次握手作的回应。从服务端的角度,我在第二次握手时的响应数据发送出去了,客户端接收到了。所以,我的发送能力是正常的。而客户端的接收能力也是正常的。而从上面的过程可以看到,最少是需要三次握手过程的。两次达不到让双方都得出自己、对方的接收、发送能力都正常的结论。

TCP四次挥手

image.png
image.png
  • 第一次挥手:客户端主动关闭方发送一个FIN(此报文中指定一个序列号),此时客户端处于FIN_WAIT1状态。用来关闭客户端到服务器端的数据传送,也就是客户端告诉服务器端:我已经不会再给你发数据了, (当然,在FIN包之前发送出去的数据,如果没有收到对应的ACK确认报文,客户端依然会重发这些数据),但是,此时客户端还可以接受数据
  • 第二次挥手:服务端收到FIN包后,发送一个ACK给客户端,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号).此时服务器处于CLOSE_WAIT状态。
  • 第三次挥手:服务器端发送一个FIN,用来关闭服务器端到客户端的数据传送,也就是告诉客户端,我的数据也发送完了,不会再给你发送数据了!!!这一次其实就和客户端第一次挥手一样发送FIN报文携带序列号,发送完毕后服务器处于LAST_ACK状态。
  • 第四次挥手:客户端收到 FIN 后,发送一个 ACK 给服务端,确认序号为收到序号 + 1,至此,完成四次挥手。此时客户端处于TIME_WAIT阶段。需要过一阵子确保服务器收到自己的ACK报文之后才会进入到CLOSED状态.
  • 服务器收到ACK之后就关闭连接了,处于CLOSED状态。

这里特别需要主要的就是TIME_WAIT这个状态了,这个是面试的高频考点,就是要理解,为什么客户端发送 ACK 之后不直接关闭,而是要等一阵子才关闭。这其中的原因就是,要确保服务器是否已经收到了我们的 ACK 报文,如果没有收到的话,服务器会重新发 FIN 报文给客户端,客户端再次收到 ACK 报文之后,就知道之前的 ACK 报文丢失了,然后再次发送 ACK 报文。
至于 TIME_WAIT 持续的时间至少是一个报文的来回时间。一般会设置一个计时,如果过了这个计时没有再次收到 FIN 报文,则代表对方成功就是 ACK 报文,此时处于 CLOSED 状态。

注意:上述描述中的客户端和服务器的角色是对等的,以主动断开连接的一方作为客户端。

关于3次握手和4次挥手的网络抓包如下:

image.png
image.png

以上的客户端和服务器在同一台机器上。但是我们仅在客户端抓包可能会出现如下的情况:

image.png
image.png

为什么建立连接时3次握手,而关闭连接时4次挥手

这是因为服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。而关闭连接时,当收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,己方是否现在关闭发送数据通道,需要上层应用来决定。因此,一般ACK和FIN一般都会分开发送。

为什么TIME_WAIT状态还需要等2MSL后才能返回到CLOSED状态

两个存在的理由:

  1. 无法保证最后发送的ACK报文会一定被对方收到,所以需要重发可能丢失的ACK报文。
  2. 当关闭当前的TCP连接时,最后发送出去的数据报可能被路由器的转发队列缓存,如果立马切换到CLOSED状态,可能使用相同窗口的新的TCP连接收到的数据报还是前一个TCP连接缓存在路由器中的数据。2MSL足以让分组最多存活MSL秒被丢弃。

如何设计一个高并发系统

image.png
image.png

系统拆分

将一个系统拆分为多个子系统,用 dubbo 来搞。然后每个系统连一个数据库,这样本来就一个库,现在多个数据库,不也可以扛高并发么。

缓存

大部分的高并发场景,都是读多写少,那你完全可以在数据库和缓存里都写一份,然后读的时候大量走缓存不就得了。毕竟人家redis轻轻松松单机几万的并发。所以你可以考虑考虑你的项目里,那些承载主要请求的读场景,怎么用缓存来抗高并发。

MQ

可能你还是会出现高并发写的场景,比如说一个业务操作里要频繁搞数据库几十次,增删改增删改,疯了。那高并发绝对搞挂你的系统,你要是用 redis 来承载写那肯定不行,人家是缓存,数据随时就被 LRU 了,数据格式还无比简单,没有事务支持。所以该用 mysql 还得用 mysql 啊。那你咋办?用 MQ 吧,大量的写请求灌入 MQ 里,排队慢慢玩儿,后边系统消费后慢慢写,控制在 mysql 承载范围之内。所以你得考虑考虑你的项目里,那些承载复杂写业务逻辑的场景里,如何用 MQ 来异步写,提升并发性。MQ 单机抗几万并发也是 ok 的。

分库分表

分库分表,可能到了最后数据库层面还是免不了抗高并发的要求,好吧,那么就将一个数据库拆分为多个库,多个库来扛更高的并发;然后将一个表拆分为多个表,每个表的数据量保持少一点,提高 sql 跑的性能。

读写分离

读写分离,这个就是说大部分时候数据库可能也是读多写少,没必要所有请求都集中在一个库上吧,可以搞个主从架构,主库写入,从库读取,搞一个读写分离。读流量太多的时候,还可以加更多的从库

ElasticSearch

es 是分布式的,可以随便扩容,分布式天然就可以支撑高并发,因为动不动就可以扩容加机器来扛更高的并发。那么一些比较简单的查询、统计类的操作,可以考虑用 es 来承载,还有一些全文搜索类的操作,也可以考虑用 es 来承载。

其实大部分公司,真正看重的,不是说你掌握高并发相关的一些基本的架构知识,架构中的一些技术,RocketMQ、Kafka、Redis、Elasticsearch,高并发这一块,你了解了,也只能是次一等的人才。对一个有几十万行代码的复杂的分布式系统,一步一步架构、设计以及实践过高并发架构的人,这个经验才是难能可贵的。

分布式

如果是代码量多达几十万行的大中型项目,团队里几十个人如果不今年新年更系统拆分,开发测试效率都非常低下,非常难以维护。分布式拆分之后可以大幅度提升开发效率,各个模块可以单独部署。但是分布式系统也带来了一些复杂的技术挑战:

一个服务的代码不要太多,1 万行左右,两三万撑死了吧。

大部分的系统,是要进行多轮拆分的,第一次拆分,可能就是将以前的多个模块该拆分开来了,比如说将电商系统拆分成订单系统、商品系统、采购系统、仓储系统、用户系统,等等吧。

但是后面可能每个系统又变得越来越复杂了,比如说采购系统里面又分成了供应商管理系统、采购单管理系统,订单系统又拆分成了购物车系统、价格系统、订单管理系统。

扯深了实在很深,所以这里先给大家举个例子,你自己感受一下,核心意思就是根据情况,先拆分一轮,后面如果系统更复杂了,可以继续分拆。你根据自己负责系统的例子,来考虑一下就好了。

为什么Protocol Buffer的效率是最高的

  • 使用proto编译器,自动进行序列化和反序列化,速度非常快,比XML和JSON快20-100倍
  • 数据压缩效果非常好,序列化后的数据量非常小,传输起来占用更少的带宽

如何设计一个RPC框架

  • 服务要去注册中心注册,所以要有个服务注册中心,保留各个服务的信息,可以用ZK。
  • 消费者需要去注册中心上拿到对应的服务信息
  • 向机器发起请求可以采用负载均衡算法,比如最简单的轮询
  • 服务器接收到序列化请求后调用对应的代码返回数据并序列化数据发给客户端
  • 客户端反序列化得到RPC的结果

ZK的使用场景有哪些

统一命名服务(Name Service)、配置管理(Configuration Management)、集群管理(Group Membership)、共享锁(Locks)、队列管理。

  • Zookeeper是一个类似linux、hdfs的树形文件结构,zookeeper可以用来保证数据在(Zookeeper)集群之间的数据的事务性一致性,zookeeper也是我们常说的CAP理论中的CP(强一致性)。
  • Zookeeper有一个概念叫watch(也称之为事件),是一次性触发的,当watch监视的数据发生变化时,通知设置了该watch的client端,即watcher实例对象(用于改变节点的变化而做出相应的行为)
  • Zookeeper有三个角色:Leader,Follower,Observer
  • Leader:数据总控节点,用于接收客户端连接请求,分发给所有的Follower节点后,各个Follower节点进行更新数据操作并返回给Leader节点,如果满足半数以上(所以Zookeeper集群一般是奇数个节点)更新成功则此次操作成功;
  • Follower:相当于跟随者的角色,Zookeeper的Leader宕机(挂掉)时,所有的Follower角色内部会产生选举机制,选举出新的Leader用于总控;
  • Observer:顾名思义,就是我们的客户端,用于观察Zookeeper集群的数据发送变化,如果产生变化则zookeeper会主动推送watch事件给Observer(客户端),用于对数据变化的后续处理;当然Observer(客户端)也可以发送数据变更请求;

分布式协调

简单来说,就好比,你 A 系统发送个请求到 mq,然后 B 系统消息消费之后处理了。那 A 系统如何知道 B 系统的处理结果?用 zookeeper 就可以实现分布式系统之间的协调工作。A 系统发送请求之后可以在 zookeeper 上对某个节点的值注册个监听器,一旦 B 系统处理完了就修改 zookeeper 那个节点的值,A 系统立马就可以收到通知,完美解决。

image.png
image.png

分布式锁

举个栗子。对某一个数据连续发出两个修改操作,两台机器同时收到了请求,但是只能一台机器先执行完另外一个机器再执行。那么此时就可以使用 zookeeper 分布式锁,一个机器接收到了请求之后先获取 zookeeper 上的一把分布式锁,就是可以去创建一个 znode,接着执行操作;然后另外一个机器也尝试去创建那个 znode,结果发现自己创建不了,因为被别人创建了,那只能等着,等第一个机器执行完了自己再执行。

image.png
image.png

某个节点尝试创建临时 znode,此时创建成功了就获取了这个锁;这个时候别的客户端来创建锁会失败,只能注册个监听器监听这个锁。释放锁就是删除这个 znode,一旦释放掉就会通知客户端,然后有一个等待着的客户端就可以再次重新加锁。

Twitter的分布式锁的实现

与redis分布式锁的对比:

  • redis 分布式锁,其实需要自己不断去尝试获取锁,比较消耗性能。
  • zk分布式锁,获取不到锁,注册个监听器即可,不需要主动尝试获取锁,性能开销小。

另外一点就是,如果是 redis 获取锁的那个客户端 出现 bug 挂了,那么只能等待超时时间之后才能释放锁;而 zk 的话,因为创建的是临时 znode,只要客户端挂了,znode 就没了,此时就自动释放锁。redis分布式锁好麻烦:遍历上锁,计算时间等等……zk 的分布式锁语义清晰实现简单。综上所述zk的分布式锁比redis的分布式锁牢靠,而且模型简单易用。

元数据/配置信息管理

zookeeper可以用作很多系统的配置信息的管理,比如 kafka、storm 等等很多分布式系统都会选用 zookeeper 来做一些元数据、配置信息的管理,包括 dubbo 注册中心不也支持zookeeper么?

image.png
image.png

HA高可用

比如 hadoop、hdfs、yarn 等很多大数据系统,都选择基于 zookeeper 来开发 HA 高可用机制,就是一个重要进程一般会做主备两个,主进程挂了立马通过 zookeeper 感知到切换到备用进程。

image.png
image.png

分布式事务

常用的有以下5种实现方案:

XA

所谓的 XA 方案,即:两阶段提交,有一个事务管理器的概念,负责协调多个数据库(资源管理器)的事务,事务管理器先问问各个数据库你准备好了吗?如果每个数据库都回复 ok,那么就正式提交事务,在各个数据库上执行操作;如果任何其中一个数据库回答不 ok,那么就回滚事务。

这种分布式事务方案,比较适合单块应用里,跨多个库的分布式事务,而且因为严重依赖于数据库层面来搞定复杂的事务,效率很低,绝对不适合高并发的场景。如果要玩儿,那么基于 Spring + JTA 就可以搞定,自己随便搜个 demo 看看就知道了。

这个方案,我们很少用,一般来说某个系统内部如果出现跨多个库的这么一个操作,是不合规的。我可以给大家介绍一下, 现在微服务,一个大的系统分成几十个甚至几百个服务。一般来说,我们的规定和规范,是要求每个服务只能操作自己对应的一个数据库

如果你要操作别的服务对应的库,不允许直连别的服务的库,违反微服务架构的规范,你随便交叉胡乱访问,几百个服务的话,全体乱套,这样的一套服务是没法管理的,没法治理的,可能会出现数据被别人改错,自己的库被别人写挂等情况。

如果你要操作别人的服务的库,你必须是通过调用别的服务的接口来实现,绝对不允许交叉访问别人的数据库。

image.png
image.png

TCC

全称:Try,Confirm,Cancle。

  • Try 阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留
  • Confirm 阶段:这个阶段说的是在各个服务中执行实际的操作
  • Cancel 阶段:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功的业务逻辑的回滚操作。(把那些执行成功的回滚)

这种方案说实话几乎很少人使用,我们用的也比较少,但是也有使用的场景。因为这个事务回滚实际上是严重依赖于你自己写代码来回滚和补偿了,会造成补偿代码巨大,非常之恶心。

比如说我们,一般来说跟相关的,跟钱打交道的,支付、交易相关的场景,我们会用 TCC,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,保证在资金上不会出现问题。而且最好是你的各个业务执行的时间都比较短。

但是说实话,一般尽量别这么搞,自己手写回滚逻辑,或者是补偿逻辑,实在太恶心了,那个业务代码是很难维护的。

image.png
image.png

可靠消息最终一致性

直接基于 MQ 来实现事务。比如阿里的 RocketMQ 就支持消息事务。大概意思如下:

  1. A 系统先发送一个 prepared 消息到 mq,如果这个 prepared 消息发送失败那么就直接取消操作别执行了;
  2. 如果这个消息发送成功过了,那么接着执行本地事务,如果成功就告诉 mq 发送确认消息,如果失败就告诉 mq 回滚消息;
  3. 如果发送了确认消息,那么此时 B 系统会接收到确认消息,然后执行本地的事务;
  4. mq 会自动定时轮询所有 prepared 消息回调你的接口,问你,这个消息是不是本地事务处理失败了,所有没发送确认的消息,是继续重试还是回滚?一般来说这里你就可以查下数据库看之前本地事务是否执行,如果回滚了,那么这里也回滚吧。这个就是避免可能本地事务执行成功了,而确认消息却发送失败了。
  5. 这个方案里,要是系统 B 的事务失败了咋办?重试咯,自动不断重试直到成功,如果实在是不行,要么就是针对重要的资金类业务进行回滚,比如 B 系统本地回滚后,想办法通知系统 A 也回滚;或者是发送报警由人工来手工回滚和补偿。

这个还是比较合适的,目前国内互联网公司大都是这么玩儿的,要不你举用 RocketMQ 支持的,要不你就自己基于类似 ActiveMQ?RabbitMQ?自己封装一套类似的逻辑出来,总之思路就是这样子的。

image.png
image.png

最大努力通知

这个方案的大致意思就是:

  1. 系统 A 本地事务执行完之后,发送个消息到 MQ;
  2. 这里会有个专门消费 MQ 的最大努力通知服务,这个服务会消费 MQ 然后写入数据库中记录下来,或者是放入个内存队列也可以,接着调用系统 B 的接口;
  3. 要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃。

最佳实践:特别严格的场景,用的是 TCC 来保证强一致性;然后其他的一些场景基于阿里的 RocketMQ 来实现分布式事务。

分布式session

常用的解决方案有2种:

  • 基于redis的session共享
  • 使用JWT存储用户身份,再从DB或者cache中获取其他信息。这样无论请求分配到哪个服务器都无所谓。

高可用架构

电商网站详情页系统架构

小型电商网站的页面展示采用页面全量静态化的思想。数据库中存放了所有的商品信息,页面静态化系统,将数据填充进静态模板中,形成静态化页面,推入 Nginx 服务器。用户浏览网站页面时,取用一个已经静态化好的 html 页面,直接返回回去,不涉及任何的业务逻辑处理。

image.png
image.png

这样做,好处在于,用户每次浏览一个页面,不需要进行任何的跟数据库的交互逻辑,也不需要执行任何的代码,直接返回一个 html 页面就可以了,速度和性能非常高。对于小网站,页面很少,很实用,非常简单,Java 中可以使用 velocity、freemarker、thymeleaf 等等,然后做个 cms 页面内容管理系统,模板变更的时候,点击按钮或者系统自动化重新进行全量渲染。坏处在于,仅仅适用于一些小型的网站,比如页面的规模在几十到几万不等。对于一些大型的电商网站,亿级数量的页面,你说你每次页面模板修改了,都需要将这么多页面全量静态化,靠谱吗?每次渲染花个好几天时间,那你整个网站就废掉了。

大型电商网站商品详情页的系统设计中,当商品数据发生变更时,会将变更消息压入 MQ 消息队列中。缓存服务从消息队列中消费这条消息时,感知到有数据发生变更,便通过调用数据服务接口,获取变更后的数据,然后将整合好的数据推送至 redis 中。Nginx 本地缓存的数据是有一定的时间期限的,比如说 10 分钟,当数据过期之后,它就会从 redis 获取到最新的缓存数据,并且缓存到自己本地。

用户浏览网页时,动态将 Nginx 本地数据渲染到本地 html 模板并返回给用户。

image.png
image.png

虽然没有直接返回 html 页面那么快,但是因为数据在本地缓存,所以也很快,其实耗费的也就是动态渲染一个 html 页面的性能。如果 html 模板发生了变更,不需要将所有的页面重新静态化,也不需要发送请求,没有网络请求的开销,直接将数据渲染进最新的 html 页面模板后响应即可。

在这种架构下,我们需要保证系统的高可用性。如果系统访问量很高,Nginx 本地缓存过期失效了,redis 中的缓存也被 LRU 算法给清理掉了,那么会有较高的访问量,从缓存服务调用商品服务。但如果此时商品服务的接口发生故障,调用出现了延时,缓存服务全部的线程都被这个调用商品服务接口给耗尽了,每个线程去调用商品服务接口的时候,都会卡住很长时间,后面大量的请求过来都会卡在那儿,此时缓存服务没有足够的线程去调用其它一些服务的接口,从而导致整个大量的商品详情页无法正常显示。这其实就是一个商品接口服务故障导致缓存服务资源耗尽的现象。

数据结构和算法

设计实现一个LRU

该结构支持2个操作:访问和删除。访问操作会将不存在于数据结构中的元素插入(或者数据结构中存在该元素,则该元素放在头部),删除操作会返回最近最少访问的元素。

思考:取得最近访问元素就像是FIFO,可以想到栈,但是删除最老的数据就需要遍历整个栈了。我们需要一种快速访问容器头部并快速移除尾部元素的结构,自然而然可以想到队列,但是LRU还有一个特性:如果容器中已经有元素,需要将元素移动到头部,队列对于这样的操作需要从头遍历到指定元素,删除元素,再在 对头插入。显然是低效的。联想到双端链表删除元素只需要O(1),(单链表删除元素为O(N),因为要遍历链表)。因此LRU的底层可以采用双向链表存储。快速根据key找到node节点就需要使用hash表了。

class Node {
  constructor(value, prev, next) {
    this.value = value;
    this.prev = prev;
    this.next = next;
  }
}

class DoubleLinkedList {

  constructor() {
    this.head = null;
    this.tail = null;
  }

  insertFirst(value) {
    if (!this.head) {
      this.head = this.tail = new Node(value);
    } else {
      const newNode = new Node(value, null, this.head);
      this.head.prev = newNode;
      this.head = newNode;
    }
    return this.head;
  }

  deleteLast() {
    if (!this.tail) return null;

    const ret = this.tail;

    const newTail = this.tail.prev;

    if (!newTail) {
      this.head = null;
    } else {
      newTail.next = null;
    }

    return ret.value;
  }

  // 把节点移动到头部
  moveToFront(node) {
    if (!node || node === this.head) return; // 已经在头部了
    const {prev, next} = node;
    // 这个判断其实没有必要,因为前面已经判断过不是头结点了,前驱节点一定存在,写这个的目的是
    if (prev) {
      prev.next = next;
    }
    if (next) {
      next.prev = prev;
    }
    node.prev = null;
    node.next = this.head;

    this.head.prev = node;
    this.head = node;
  }

}

class LRU {

  constructor() {
    this.list = new DoubleLinkedList();
    this.map = new Map(); // key => 元素 ;value,双向链表中的节点
  }

  /**
   * 将不存在数据结构中的元素插入
   * @param value
   */
  access(value) {
    let node = this.map.get(value);
    if (!node) {
      node = this.list.insertFirst(value);
      this.map.set(value, node);
    } else {
      this.list.moveToFront(node);
    }
  }

  /**
   * 删除并返回最近最少访问的元素
   */
  delete() {
    const value = this.list.deleteLast();
    this.map.delete(value);
    return value;
  }

  toString() {
    let cur = this.list.head;
    const values = [];
    while (cur) {
      values.push(cur.value);
      cur = cur.next;
    }
    return values.join(' -> ');
  }
}

参考:LRU原理和Redis实现——一个今日头条的面试题

如何回退代码到指定版本

方式一:可以新建一个分支,然后选择你想回退到到那个版本,切换到新分支之后,代码就是那个版本了,可以对那个版本进行操作,修改等,如果想回到之前最新版本,直接切换分支到原来到分支即可,这杨相互不影响,Reset master to this commit 只有是hard的时候,项目里代码内容才是你想切到的版本内容,不过这样会把你新改的代码丢失了.

image.png
image.png

方式二:git reset

image.png
image.png

注意如果需要保留后面commit的更改就选mixed,不然选hard。

注意:从这个reset 之后,再push服务器,会提示落后xx个版本,这个时候需要强制push。git push --force

一个TCP连接能发几个HTTP请求

如果是 HTTP 1.0 版本协议,一般情况下,不支持长连接,因此在每次请求发送完毕之后,TCP 连接即会断开,因此一个 TCP 发送一个 HTTP 请求,但是有一种情况可以将一条 TCP 连接保持在活跃状态,那就是通过 Connection 和 Keep-Alive 首部,在请求头带上 Connection: Keep-Alive,并且可以通过 Keep-Alive 通用首部中指定的,用逗号分隔的选项调节 keep-alive 的行为,如果客户端和服务端都支持,那么其实也可以发送多条,不过此方式也有限制,可以关注《HTTP 权威指南》4.5.5 节对于 Keep-Alive 连接的限制和规则。
而如果是 HTTP 1.1 版本协议,支持了长连接,因此只要 TCP 连接不断开,便可以一直发送 HTTP 请求,持续不断,没有上限;
同样,如果是 HTTP 2.0 版本协议,支持多用复用,一个 TCP 连接是可以并发多个 HTTP 请求的,同样也是支持长连接,因此只要不断开 TCP 的连接,HTTP 请求数也是可以没有上限地持续发送

WebSocket和HTTP之间有什么关系

WebSocket 是一个独立的基于 TCP 的协议,它与 HTTP 之间的唯一关系就是它的握手请求可以作为一个升级请求(Upgrade request)经由 HTTP 服务器解释(也就是可以使用 Nginx 反向代理一个WebSocket)。

负载均衡

HTTP重定向负载均衡

来自用户的HTTP请求到达负载均衡服务器以后,负载均衡服务器根据某种负载均衡算法计算得到一个应用服务器的地址,通过HTTP状态码302重定向响应,将新的IP地址发送给用户浏览器,用户浏览器收到重定向响应以后,重新发送请求到真正的应用服务器,以此来实现负载均衡。

实现起来非常简单,但是缺点也非常明显:

  1. 客户端完成一次访问需要2次请求(请求负载均衡服务器+应用服务器),处理性能会受到影响
  2. 因为客户端需要连接应用服务器,所以需要将真实IP暴露给客户端,可能会带来安全问题

这种方案在实际开发中几乎不会使用。

DNS负载均衡

当用户从浏览器发起HTTP请求的时候,首先要到DNS域名服务器进行域名解析,解析得到IP地址以后,用户才能够根据IP地址建立 HTTP连接,访问真正的数据中心的应用服务器,这时候就可以在DNS解析的时候进行负载均衡,也就是说,不同的用户进行域名解析的时候,返回不同的IP地址,从而实现负载均衡。

DNS负载均衡和HTTP重定向负载均衡非常像,但是不会有性能问题:因为DNS解析之后IP地址就被客户端缓存了。但是会不会有安全问题呢?大型互联网应用通常并不直接通过DNS解析得到应用服务器IP地址,而是解析得到负载均衡服务器的IP地址。也就是说,大型网互联网应用需要两次负载均衡,一次通过DNS负载均衡,用户请求访问数据中心负载均衡服务器集群的某台机器,然后这台负载均衡服务器再进行一次负载均衡,将用户请求分发到应用服务器集群的某台服务器上。通过这种方式,应用服务器不需要用公网IP将自己暴露给外部访问者,避免了安全性问题。

DNS域名解析是域名服务商提供的一项基本服务,几乎所有的域名服务商都支持域名解析负载均衡,只需要在域名服务商的服务控制台进行一下配置,不需要开发代码进行部署,就可以拥有DNS负载均衡服务了。目前大型的互联网应用,淘宝、百度、Google 等全部使用DNS负载均衡。比如用不同的电脑ping www.baidu.com就可以看到,不同电脑得到的IP地址是不同的。

反向代理负载均衡

常用的web服务器NGINX就有这个功能。反向代理服务器是工作在HTTP协议层之上的,因此这一层的代理负载均衡也叫做应用层负载均衡。所以它代理的也是HTTP的请求和响应。作为互联网应用层的一个协议,HTTP协议相对说来比较重,效率比较低,所以反向代理负载均衡通常用在小规模的互联网系统上,只有几台或者十几台服务器的规模。

IP负载均衡

工作在TCP/IP的IP层,所以有时候也叫做网络层负载均衡。工作原理:当用户的请求到达负载均衡服务器以后,负载均衡服务器会对网络层的数据包的IP地址进行转换,将其修改为应用服务器的IP地址,然后把数据包重新发送出去,请求数据就会到达应用服
务器。

在操作系统内核直接修改IP数据包的地址,效率比应用层的反向代理负载均衡高得多。但是它依然有一个缺陷,不管是请求还是响应的数据包,都要通过负载均衡服务器进行IP地址转换,才能够正确地把请求数据分发到应用服务器,或者正确地将响应数据包发送到用户端程序。请求的数据通常比较小,一个URL或者是一个简单的表单,但是响应的数据不管是HTML还是图片,或者是 JS、CSS
这样的资源文件通常都会比较大,因此负载均衡服务器会成为响应数据的流量瓶颈

数据链路层负载均衡

可以解决响应数据量大而导致的负载均衡服务器输出带宽不足的问题,像淘宝这种规模的应用,通常使用Linux内核支持的链路层负载均衡。

image.png
image.png

这种负载均衡模式也叫直接路由模式,在负载均衡服务器的Linux操作系统内核拿到数据包后,直接修改数据帧中的mac地址,将其修改为应用服务器集群中某个服务器的mac地址,然后将数据重新发送回服务器集群所在的局域网,这个数据帧就会被某个真实的应用服务器接收到。

负载均衡服务器和集群内的应用服务器配置相同的虚拟IP地址,也就是说,在网络通信的IP层面,负载均衡服务器变更mac地址的操作是透明的,不影响TCP/IP的通信连接。所以真实的应用服务器处理完应用请求,发送应答响应的时候,就会直接发送回请求的 App手机,不会再经过负载均衡服务器。

Linux上实现IP负载均衡和链路层负载均衡的技术是LVS,目前LVS功能已经集成到Linux中了,可以直接配置实现这两种负载均衡。

如何改善数据的存储能力

在整个互联网系统架构中,承受着最大处理压力,最难以被伸缩的,就是数据存储部分。原因主要有两方面:

  1. 数据存储需要使用硬盘,而硬盘的处理速度要比其他几种计算资源,比如 CPU、内存、网卡都要慢一些;
  2. 另一方面,数据是公司最重要的资产,公司需要保证数据的高可用以及一致性,非功能性约束更多一些。

因此数据存储通常都是互联网应用的瓶颈。在高并发的情况下,最容易出现性能问题的就是数据存储。目前用来改善数据存储能力的主要手段包括:数据库主从复制、数据库分片和NoSQL数据库。

实现高可用常用的手段有哪些

冗余备份

提供同一服务的服务器要存在冗余,即任何服务都不能只有一台服务器,服务器之间要互相进行备份,任何一台服务器出现故障的时候,请求可以发送到备份的服务器去处理。这样,即使某台服务器失效,在用户看来,系统依然是可用的。

失败隔离

将失败限制在一个较小的范围之内,使故障影响范围不扩大。具体实现失败隔离的主要架构技术是消息队列。

一方面,消息的生产者和消费者通过消息队列进行隔离。如果消费者出现故障的时候,生产者可以继续向消息队列发送消息,而不会感知到消费者的故障,等消费者恢复正常以后再去从消息队列中消费消息,所以从用户处理的视角看,系统一直是可用的。

发送邮件消费者出现故障,不会影响生产者应用的运行,也不会影响发送短信等其他消费者正常的运行。

image.png
image.png

另一方面,由于分布式消息队列具有削峰填谷的作用,所以在高并发的时候,消息的生产者可以将消息缓冲在分布式消息队列中,消费者可以慢慢地从消息队列中去处理,而不会将瞬时的高并发负载压力直接施加到整个系统上,导致系统崩溃。也就是将压力隔离开来,使消息生产者的访问压力不会直接传递到消息的消费者,这样可以提高数据库等对压力比较敏感的服务的可用性。

同时,消息队列还使得程序解耦,将程序的调用和依赖隔离开来,我们知道,低耦合的程序更加易于维护,也可以减少程序出现 Bug的几率。

限流降级

保护系统高可用的一种手段。比如说在电商系统中有确认收货这个功能,即便我们不去确认收货,系统也会超时自动确认收货。但际上确认收货这个操作是一个非常重的操作,因为它会对数据库产生很大的压力:它要进行更改订单状态,完成支付确认,并进行评价等一系列操作。如果在系统高并发的时候去完成这些操作,那么会对系统雪上加霜,使系统的处理能力更加恶化。

解决办法就是在系统高并发的时候,比如说像淘宝双 11 的时候,当天可能整天系统都处于一种极限的高并发访问压力之下,这时候就可以将确认收货、评价这些非核心的功能关闭,将宝贵的系统资源留下来,给正在购物的人,让他们去完成交易。

异地多活

主要是解决整个数据中心不可用问题的。将数据中心分布在多个不同地点的机房里,这些机房都可以对外提供服务,用户可以连接任何一个机房进行访问,这样每个机房都可以提供完整的系统服务,即使某一个机房不可使用,系统也不会宕机,依然保持可用。

异地多活的架构考虑的重点就是,用户请求如何分发到不同的机房去。这个主要可以在域名解析的时候完成,也就是用户进行域名解析的时候,会根据就近原则或者其他一些策略,完成用户请求的分发。另一个至关重要的技术点是,因为是多个机房都可以独立对外提供服务,所以也就意味着每个机房都要有完整的数据记录。用户在任何一个机房完成的数据操作,都必须同步传输给其他的机房,进行数据实时同步。

数据库实时同步最需要关注的就是数据冲突问题。同一条数据,同时在两个数据中心被修改了,该如何解决?为了解决这种数据冲突的问题,某些容易引起数据冲突的服务采用类似MySQL 的主主模式,也就是说多个机房在某个时刻是有一个主机房的,某些请求只能到达主机房才能被处理,其他的机房不处理这一类请求,以此来避免关键数据的冲突。

除了上述的高可用架构方案,还有一些高可用的运维方案:通过自动化测试减少系统的bug,通过自动化监控尽早发现系统的故障,通过预发布验证发现测试环境无法发现的bug,灰度发布降低软件错误带来的影响以及评估软件版本升级带来的业务影响等等。

说一说你的缺点

只需要说一说自己现在正在学的东西即可,不要傻乎乎说自己这个不行,那个不行。

你有什么想要问的

这个岗位具体会做哪些事情,会与哪些人合作?
进一步对工作岗位进行深入了解,以及对将来的合作伙伴有更多提前认知,同时给面试官一些尽情表达的机会,显得你是非常关心这个工作机会。

咱们团队目前面对的最大挑战/困难是什么?
抓住机会深入了解团队目前的困难、痛点,利用入职前的空档期,提前做好预习与准备,以便顺利入职之后快速产出。

总之,问太肤浅的问题,会让你显得格局小。太细节、太琐碎的,比如作息制度、报销制度,是否管午饭,是否经常加班,团队有几个女生,有没有健身房……等等类似太具体的问题,还是留在该公司将offer发给你之后、入职已经胜券在握的时候再去问HR吧。