Giter Site home page Giter Site logo

blog's Introduction

技术讨论与总结

知其然,知其所以然

https://github.com/jinhailang/blog/issues


技术栈

Software Engineer Keywords

  • Golang
  • ngx_lua
  • Linux
  • Network
  • DevOps
  • Distributed

Server Developer Keywords

  • 网关
  • Web安全 (CC/WAF/反爬)
  • 规则引擎
  • 缓存系统
  • CDN
  • Web Server
  • 爬虫

经历

杭州 挖财(2014.6-2017.3)

...

杭州 又拍云(2017.3-2018.5)

...

杭州 网易(2018.5-至今)

...

联系

[email protected]

blog's People

Contributors

jinhailang avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

PostgreSQL 使用

后面准备开发内部的数据API平台,因为目前公司用 redis 比较多,为了能够平滑的迁移,所以,对 JOSN 的处理很重要,调研试用了MongoDB,MySQL 和 PostgreSQL,大概比较了一下,比较倾向于 PostgreSQL,主要原因如下:

  • 对 JSON/JSONB 操作方便,还可以对JSON 检索引。因为对 JSON 支持的比 MySQL 早很多,所以相对更完善
  • 可以混合存储关系数据和非关系数据,虽然目前先考虑迁移 redis 数据,但是后期可能会有一些关系数据。
  • 对 SQL支持的非常好,基本包括了 MySQL
  • 性能更高(没测过)
  • 跟 nginx 配合开发比较方便,我们公司用的 openresty 比较多。

golang 开发辅助工具使用(持续更新)

go tool pprof

实时查看程序运行堆栈信息,收集性能数据,用于程序调优和问题跟踪。非常丰富方便。
1)导入包:_ "net/http/pprof"
2)在 main 函数添加以下代码

func main() {
	go func() {
		log.Println(http.ListenAndServe(":6060", nil))
	}()
	//todo
}

浏览器输入 http://localhost:6060/debug/pprof,可以查看相关运行信息。
更多功能

go tool trace

用于程序诊断的工具,更直观的展现程序运行过程,定位延迟,并行化和竞争异常代码。
简单实例:

1)

func main() {
	trace.Start(os.Stdout)
	// todo 
	trace.Stop()
}

2)数据收集: go run main.go > trace.out
3)查看: go tool trace trace.out

更多使用

gotrace 并发 3D 可视化

//todo

go tool vet

用来检查在编译阶段没有发现的错误,比如函数调用错误,错误的操作锁或者原子变量,对于写代码比较马虎的人来说,超级好用,可以避免很多细小的问题。当然,这只是一种辅助工具,发现的问题有限。

检查所有项:go tool vet -all ./*.go
输出结果:

jinhailang@jinhailang:~/work/purge$ go tool vet -all ./*.go
./purge_test.go:35: possible formatting directive in Log call
./purge_test.go:40: possible formatting directive in Log call
./purge_test.go:117: Println call ends with newline
./purge_test.go:179: possible formatting directive in Log call
./purge_test.go:180: possible formatting directive in Log call
./purge_test.go:181: possible formatting directive in Log call
./purge_test.go:182: possible formatting directive in Log call
./purge_test.go:185: possible formatting directive in Log call
./purge_test.go:193: possible formatting directive in Log call

更多使用

race的使用

数据竞争是并发系统中最常见和最难调试类型的错误之一。特别是在Golang中,由于goroutine的使用,这样的问题更容易出现,好在Golang提供了race这个功能。

go test -race mytest.go   
go run -race mytest.go 
go build -race mytest.go 
go install -race mytest.go 

这个参数会引发CPU和内存的使用增加,所以基本是在测试环境使用,不是在正式环境开启。
更多使用

lua vs luaJIT

lua:

  • 解释器是 C 编写的;

luaJIT:

  • 解释器,用汇编实现
  • JIT 编译器,可直接将字节码(部分)转成机器码执行

所以需要尽量通过 LuaJIT 的 FFI 来调用 C

lua code -> luaJIT 字节码 -> (热代码)对应的机器码

参考

Nginx reload 端口占用问题分析

Nginx reload 端口占用问题分析

起因

用户反馈说线上程序貌似没有更新,承诺的新特性都没有。去线上确认,发现程序内存版本号确实没有更新。由于项目是使用 shell 脚本命令(docker kill --signal "HUP" xxx)重启的,当时就怀疑是不是重启命令没有被正确执行?但是查询日志,发现更新时,有 init_by_lua 阶段的日志输出,就是说 重启指令执行了,且 init_by_lua 阶段正常被执行了,但进程内的代码数据还都是旧的,而且使用 nginx stop 重启更新是正常的,这就很诡异了!

init_by_lua 阶段输出的日志:
image

问题定位

因为之前版本没有发现过,只能猜测是新版代码有问题,但是,新版代码都是些业务代码之类的更新,怎么会出现这种问题呢?很让人抓狂,只得老老实实在测试环境,模拟一下新旧版本的切换场景,没有想到,出现了下面的错误:

reload_error

会不会线上也是因为端口占用,导致的 reload 失败呢?去线上再次查日志,发现更新的时间点,果然也有端口占用的错误日志(/var/log/syslog):

address

时间上刚好与 init_by_lua 阶段输出的日志时间一致。
这里脚本执行也有个问题,由于 reload 后没有正确判断执行结果,导致只能去 /var/log/syslog 下查日志才发现。

原因分析

基本可以确认是因为执行 Nginx reload 时,出现了端口被占用的错误,导致更新没有完成。而 init_by_lua 被执行的是因为该阶段在端口监听之前被执行(从上面日志可以直接看到)。

所以,到底是什么操作触发了这个错误呢?既然是端口监听的问题,自然跟 nginx 的 listen 配置有关,联想到之前,为了安全起见,**将配置 listen 8081 改成了 listen 127.0.0.1:80。**于是,在本地尝试复现,发现每次必现:

load_cfg

至此,问题原因基本查清楚了,就是地址端口修改导致的“血案”,而且只有在 listen 8081listen x.x.x.x:8081 格式之间切换时,才会出现这种问题。

解决方案

既然是 listen 配置问题,那么直接使用固定 listen IP:PORT 就可以避免了,而且很重要的是,listen PORT 这种用法也是很不安全(可能被外网访问),且不专业的。

源码分析

为了找到百分百的实锤,也是为了彻底搞清楚 reload 的详细过程,查看了下这块 Nginx 代码。与热(Nginx 二进制)更新(kill -USR2) 相比,reload 实现代码相对简单些。
Nginx master 进程收到 HUP 信号后将 ngx_reconfigure = 1,会执行函数 ngx_master_process_cycle 内的以下代码块:

if (ngx_reconfigure) {
    ngx_reconfigure = 0;
 
    if (ngx_new_binary) {
        ngx_start_worker_processes(cycle, ccf->worker_processes,
                                   NGX_PROCESS_RESPAWN);
        ngx_start_cache_manager_processes(cycle, 0);
        ngx_noaccepting = 0;
 
        continue;
    }
 
    ngx_log_error(NGX_LOG_NOTICE, cycle->log, 0, "reconfiguring");
 
    cycle = ngx_init_cycle(cycle);
    if (cycle == NULL) {
        cycle = (ngx_cycle_t *) ngx_cycle;
        continue;
    }
 
    ngx_cycle = cycle;
    ccf = (ngx_core_conf_t *) ngx_get_conf(cycle->conf_ctx,
                                           ngx_core_module);
    ngx_start_worker_processes(cycle, ccf->worker_processes,
                               NGX_PROCESS_JUST_RESPAWN);
    ngx_start_cache_manager_processes(cycle, 1);
 
    /* allow new processes to start */
    ngx_msleep(100);
 
    live = 1;
    ngx_signal_worker_processes(cycle,
                                ngx_signal_value(NGX_SHUTDOWN_SIGNAL));
}

这里关键函数是 ngx_init_cycle,这个函数负责解析初始化配置,并创建新的 ngx_cycle_t 结构体以及清理旧的 ngx_cycle_t 。

进入 函数 ngx_init_cycle(ngx_cycle_t *old_cycle) ,与监听相关的片段有两处(按前后顺序):

  • 创建新结构体的监听句柄

image

  • 清理旧的未被引用的监听句柄

image

以上代码说明,reload 后首先会遍历旧结构体内原来的监听的句柄,如果监听的地址和端口都跟新的配置结构体相同,则直接将句柄添加到新的结构体。然后才会关闭旧结构体内没有被新结构体引用的监听句柄。

ngx_cmp_sockaddr 函数负责判断新旧配置监听的地址端口是否相同,主要实现如下:

if (cmp_port && sin1->sin_port != sin2->sin_port) {
	return NGX_DECLINED;
}
if (sin1->sin_addr.s_addr != sin2->sin_addr.s_addr) {
	return NGX_DECLINED;
}

小结

经过源码分析,就很清晰自然了,由于 listen 8081 监听的实际上是 0.0.0.0:8081,改成 127.0.0.1:8081 后,reload 时会先重新创建监听 127.0.0.1:8081 的句柄,自然会与原来的 0.0.0.0:8081 监听冲突了。Nginx 这么设计是很合理的,是配置使用的问题,线上不应该使用 listen 8081 这种指令,显然很不安全。

最后,梳理下 reload 基本过程:

  • 执行 reload 指令,主进程接收到 HUP 信号
  • 主进程首先会检测新配置的语法有效性
  • 尝试应用新的配置
    • 调用 ngx_init_cycle 函数,创建新的结构体 ngx_cycle_t,解析加载配置。监听配置的地址端口(直接引用或新创建)

此时,有两种情况:

  • 如果 ngx_init_cycle 执行失败,则还是会使用原有的配置(回滚)。

  • 如果 ngx_init_cycle 执行成功,则使用新的配置(ngx_cycle_t 结构体),新建所有 worker 进程。新建成功后发送退出信号给旧的 worker 进程。

  • 旧 worker 进程接收到到退出信号后会继续服务,当所有请求的客户端被服务后,worker 进程退出。

end:)

OpenResty 相对于 Go 的优势

我并不是 Go 方面的专家,只是大略列表一下 OpenResty 相对于 Go 的优势: 

1. Lua 是全动态语言,支持动态加载和卸载代码,可以在请求级别完成。Go 世界的一些哥们尝试过把动态语言 VM 嵌入进 Go 或用 Go 
来实现上层语言的解释器,性能都极差。因为 Go 是很封闭的运行时环境,并不能方便而高效地进行嵌入或扩展。 

2. 享受 NGINX 完整的基础设施和生态圈。像二进制热升级和 0 下线时间的整体配置 reload 这样的功能都是由 NGINX 直接提供。 

3. 单线程编程方式,方便用 C 扩展,同时通常不用考虑线程安全的问题,开发成本大大下降。而 Go 是多线程模型,对于 C 
扩展而言,要么得确保线程安全,要么就要加一把大锁。当然 cgo 还有更多的限制了。 

4. 根据我们之前线上观察的规则,Go 运行时的线程调度器的 CPU 损耗非常大,而且很难优化。 

5. Go 在底层实现上继承了 Plan9 系统的很多古老的设计,我之前看连 ABI 都是 plan9 
的。这使得很多现有的调试和动态追踪工具链和 Go 系统存在较大的兼容性问题。我记得我在上一家东家分析那些运行缓慢的 Go 
进程时,都只能在系统调用及其以下层面进行分析,用户态是一团乱麻,非常郁闷。 

总而言之,Go 在我看来是一个很尴尬的抽象层面,一方面它不及 C/C++ 那么底层那么有控制力,另一方面它又不及 Lua 
这样的动态语言那么灵活,那么动态。更要命的是它的设计是相当封闭的(不封闭也没有做它的那些抽象了),不像 nginx 或 luajit 
那样方便深度定制和扩展。 

当然了,Go 用作简单的服务还是比较方便的,但如果一旦业务复杂度上去了,就会越写越纠结,至少这是我看到的前东家的一些 Go 粉工程师用 Go 
做公司项目之后的痛的领悟。相比之下,Rust 看起来还更有前途一些,可以用作 C++ 的替代物,当然和 OpenResty 
也不在一个抽象层面上就是了。而 Erlang 的性能我也不敢恭维了,嘿嘿。 

当然了,作为 OpenResty 的作者,我的观点肯定是具有偏向性的,仅供参考。 

Regards, 
Yichun 

引用

gcc 安装

最近遇到在Linux下使用C++标准正则库报错,google发现是与gcc版本的问题,于是只好重新安装编译新的gcc,但是安装过程没有想象的那么简单,以下是遇到的各种坑:
注意:网上很多垃圾教程,很是误导人,就严格按照下面一步步来,不要试来试去,陷入各种奇怪错误陷阱,坑自己。

1)下载gcc源码,不多讲。

2)root权限切换到/opt目录下

cd /opt

解压gcc源码包到/opt目录下

tar xzvf gcc-4.8.2.tar.gz  
cd gcc-4.8.2

3)下载依赖包 gmp, mpfr, mpc

分别下载安装,很麻烦,很作死,不多讲。
执行下面的脚本自动下载关联依赖库

./contrib/download_prerequisites

4)新建一个文件,用来编译

一定要不要在那个gcc文件下直接编译,否则会报错,作死妥妥的

cd ..  
mkdir objdir  
mkdir /usr/local/gcc-4.9.1  
mkdir /usr/local/gcc  

5)编译gcc

cd objdir  
../gcc-4.9.1/configure --prefix=/usr/local/gcc-4.9.1 --exec-prefix=/usr/local/gcc --enable-languages=c,c++  
make  #很慢,大概要编译一个多小时

6)安装gcc

make install  

以上gcc就算是安装完了,但是要正常使用,还需要一些配置,一下配置方法引用自 http://ilovers.sinaapp.com/article/centos%E4%B8%8B%E5%AE%89%E8%A3%85gcc-481:

make install 之后,会发现 /user/local/gcc 下放置的是 bin + lib 文件,/usr/local/gcc-4.8.1 下放置的是 include 文件。上面完事之后,就是删除原有的 gcc,替换成现在的最新版本;不过为了保险起见,还是将原有的 gcc 换成其他名字的好,比如 gcc-4.4.7/g++-4.4.7。关于后续的工作其实还有一些,主要是环境变量的设置,以及为 c++ 做的一些设置。

# 将 gcc/g++ 改名
$ mv /usr/bin/gcc /usr/bin/gcc-4.4.7
$ mv /usr/bin/g++ /usr/bin/g++-4.4.7

# 环境变量的设置
$ export PATH=/usr/local/gcc/bin:$PATH # 可以让 us 使用最新的 gcc/g++;
$ export LD_LIBRARY_PATH=/usr/local/lib # 这个可能不是必须的,对于 me 来说是必须的,设置的是 lib  的搜索 path;
$ ln -s /usr/local/gcc-4.8.1/include/c++/4.8.1 /usr/include/c++/4.8.1 # 在 include/c++ 文件夹下添加最新的 c++ 4.8.1 版本(这是个符号链接);
$ export C_INCLUDE_PATH=/usr/include # 这个是多余的,实际上不用设置;
$ export CPLUS_INCLUDE_PATH=/usr/include/c++/4.8.1:/usr/include/c++/4.8.1/x86_64-unknown-linux-gnu # c++ include 搜索目录,这里有两个,使用的 : 隔开;

有个问题是,在 shell 中通过 export 设置的环境变量不是持久有效的,在用户退出登录之后就不再有效,可以将 export 的环境变量在用户主目录下的 .bash_profile 中设置,对用户来说,是持久有效的;如果想对对所有的用户有效,需要 root 在 /etc/profile 中设置;

安装可能出现的问题以及方案

configure 步骤提示找不到 gmp、mpfr 等 lib 或是 header;缺少的要安装,可以使用自带的包管理器,比如 yum install gmp,也可以从官网下载安装,下载地址:
GMP:http://gmplib.org
MPFR:http://www.mpfr.org
MPC:http://www.multiprecison.org
ISL+CLooG:ftp://gcc.gnu.org/pub/gcc/infrastructure
ISL 明明已经安装了,然而 configure 检测 no !设置环境变量 $ export LD_LIBRARY_PATH=/usr/local/lib (这是 isl lib 所在的目录,当然 u 的可能不一样);
stubs-32.h 找不到,安装 32 位的 glibc-devel;
编译 c++ 发现找不到 c++config.h;本来 c++ include 目录是 /usr/include/c++/4.8.1,c++config.h 位于其下的 x86_64-unknown-linux-gnu (这个文件夹跟平台有关)下,所以可以在 CPLUS_INCLUDE_PATH 中设置;
环境变量设置只在 shell 中有效,退出之后就不再有效;修改 ~/.bash_profile 文件,在其中添加环境变量(需要退出登陆有效);
测试

编译一下 hello.cpp,使用了 c++11 的一些特性,比如初始化方式,类型推断以及新的 for 用法,$ g++ -std=c++11 hello.cpp :

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
 
int main()
{
        vector<int> v = {2, 4, 8, 3, 5, 6, 1, 7, 10, 9};
 
        sort(v.begin(), v.end());
 
        for(auto i: v)
                cout << i << " ";
        cout << endl;
 
        return 0;
}

以上。

读《微服务架构与实践》

《微服务架构与实践》算是入门级的书,除了代码,就没多少页了,但对于新手还是很合适的,里面的一些概念,框架,以及架构等都很有启发作用。所谓的微服务架构,其实一直就是在应用到,这本系统的梳理了这些技术,使得理解更深入了。

我认为微服务最终要的是模块化和自动化,模块化就是系统分解,自动化包括自动生成代码,自动测试,自动部署,自动报警等。

关于微服务

微服务是一种架构风格,一个大型复杂软件应用由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。每个微服务仅关注于完成一件任务并很好地完成该任务。在所有情况下,每个任务代表着一个小的业务能力。

尽管“微服务”这种架构风格没有精确的定义,但其具有一些共同的特性,如围绕业务能力组织服务、自动化部署、智能端点、对语言及数据的“去集中化”控制等等。

微服务架构的思考是从与整体应用对比而产生的。

优缺点

微服务是互联网发展的必然结果,软件规模越来越大了,维护起来也越来越费力,互联网讲究的是速度,快速开发快速上线验证。而传统的开发模式,更多是阶段流程式:需求分析,概要设计,详细设计,编码,测试,交付,验收,维护等。显然太慢了,也不够灵活,互联网更多的情况是尝试,需要先出一个简单版本,在快速迭代推进。

随着软件行业的发展,有很多成熟,开源的组件备应用,模块化的系统可以很好的将这些好的组件应用在自己的系统当中,更新,维护也很方便。

优点:

  • 每个服务都很简单,只关注于一个业务功能。
  • 每个微服务可以由不同的团队独立开发。
  • 微服务是松散耦合的。
  • 微服务可以通过不同的编程语言与工具进行开发。

缺点:

  • 运维成本过高
  • DevOps是必须的
  • 接口不匹配
  • 代码重复
  • 分布式系统的复杂性
  • 异步
  • 测试

关于测试

自己之前对测试不太重视,总以为代码写好了就行,也不知到怎么写测试。换新工作之后体会到了测试的重要性,尤其是自动化测试的概念,感觉以前自己就像猴子。

测试是微服务架构很重要的一个部分,因为涉及到很多模块,复杂的异步逻辑等,调试和 bug 修复难度都更大,所以越早测试越容易解决问题,单元和接口测试是必须的。

<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCTERxMXc2ZW1kSW8/preview" width="400" height="300"></iframe>

自动化测试是持续集成开的保障,不然增加新功能又会导致前面的功能出现问题,测试成本是巨大的,我是经历过的,之前的项目就是没有单元,只有最终的功能性测试,总是重复测试,重复出现问题,举步维艰,不但效率低下,还很打击士气。

我们现在的项目使用 python 写单元测试,GitLab CI 自动化测试,方便多了。

总结

虽然之前也一直认为在程序开发过程中,编码应该只能占很小的部分,大部分时间应该花在设计,测试,以及上线之后的问题发现与解决当中,但是实际却很难做到,缺少方向和方法。

人跟动物最大区别就是人可以创造和使用工具,我想新手和老司机的区别也在此吧。学会使用工具,可以极大的提升工作效率,解放自己,最近在使用swagger 设计 API,可以方便的设计出符合 RESTful 规范的API。后面需要多关注,尝试新的框架和技术。

Linux OOM Killer 机制

参考

理解LINUX的MEMORY OVERCOMMIT

使用技巧

某个程序因为系统内存不被杀掉了,此时,由于没有输出详细的堆栈信息,很难定位问题。
可以设置 overcommit_memory 参数,禁止系统启用 overcommit 机制(原理参考上面链接的文章)。

echo 2 > /proc/sys/vm/overcommit_memory

这样当系统内存不足时,程序就会主动 panic 从而输出 stack trace(以 Go 为例)。

在线代码测试

在线代码测试

1)

package main

import (
	"fmt"
	"time"
)

const (
	total = 100
)

//Printer1

func Printer1(a, b chan int) {
	for i := range a {
		if i > 100 {
			return
		}

		fmt.Printf("Printer1--%d\r\n", i)
		b <- i + 1
	}
}

func Printer2(a, b chan int) {
	for i := range a {
		if i > 100 {
			return
		}

		fmt.Printf("Printer2--%d\r\n", i)
		b <- i + 1
	}
}

func main() {
	a := make(chan int)
	b := make(chan int)

	go Printer1(a, b)
	go Printer2(b, a)

	a <- 0

	time.Sleep(time.Millisecond * 10) //阻塞

	close(a)
	close(b)

	fmt.Printf("exit\n")
}

2)

package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
)

const (
	LOG = "/Users/jinhailang/Desktop/test.txt" //"/home/admin/logs/data.log"
)

func cat(path, key string) ([]string, error) {
	var ss []string

	fi, err := os.Open(LOG)
	if err != nil {
		fmt.Printf("Open error: %v\n", err)
		return nil, err
	}

	defer fi.Close()

	br := bufio.NewReader(fi)
	for {
		a, _, c := br.ReadLine()
		if c == io.EOF {
			break
		}

		s := string(a)
		if strings.Contains(s, key) {
			ss = append(ss, s)
		}
	}

	fmt.Println("ss: ", ss)

	return ss, nil
}

func uniq(ss []string) map[string]int {
	mp := make(map[string]int)

	for _, s := range ss {
		count, ok := mp[s]
		if ok {
			count++
			mp[s] = count
		} else {
			mp[s] = 1
		}
	}

	return mp
}

type kv struct {
	K string
	V int
}

type kvs []kv

func (p kvs) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
func (p kvs) Len() int           { return len(p) }
func (p kvs) Less(i, j int) bool { return p[i].V < p[j].V }

func sort_nr(mp map[string]int) []kv {
	p := make(kvs, len(mp))
	i := 0
	for k, v := range mp {
		p[i] = kv{k, v}
		i = i + 1
	}

	sort.Sort(p)

	return p
}

func main() {
	ss, _ := cat(LOG, "alibaba")

	sort.Strings(ss)
	fmt.Println("sort after ss: ", ss)

	mp := uniq(ss)
	fmt.Println("uniq after mp: ", mp)

	kk := sort_nr(mp)
	fmt.Println("sort_nr after kk: ", kk)

	fmt.Println("exit.")
}

Nginx 获取客户端 IP 的几种方式

Nginx 获取客户端 IP 的几种方式

Nginx 服务端很多时候需要知道请求客户端的真实 IP,但是实际请求客户端可能经过了很多层代理才到的服务端,而误将代理 IP 当作请求客户端的 IP 可能导致很严重的问题,比如在 IP 维度做请求限速或者请求统计时。

这种情况下,要怎样获取请求端真实 IP 呢?

获取 IP 的三种方式

  • 设置 X-Forwarded-For 请求头
  • 设置 X-Real-IP 请求头

这两种方式的原理其实是一样的,都是跟上下游代理之间约定某个特定字段,将请求 IP 放进去,然后层层传递下去。
X-Forwarded-For 一般包含了整个请求经过的所有对端IP,以逗号+空格分割,最前(左)端的 IP 就是第一个请求客户端的 IP,因为这种方式被广泛的使用,可以作为业界共识。
但是,很明显的是,这种约定是脆弱的,也不安全,客户端甚至可以在请求的时候自己指定 X-Forwarded-For 头,这样就相当于可以随意篡改自己的 IP 地址了。当然,一般我们真正关心的是请求进入公司内网时的 IP,所以,可以在内网最前面的代理上做过滤。

proxy_bind 允许代理指定发起请求的 IP,因此我们可以如下设置(Nginx 1.11.0 开始支持的):

proxy_bind $remote_addr  transparent;

注意,需要开启 root 权限 user root;。这样设置后,Nginx 后面的服务器看到的 IP($remote_addr) 不再是 Nginx 反向代理服务器主机的 IP,而是真正发起请求的主机 IP。这就是所谓的“透明代理”。实现原理是在Linux 2.6.24以后,socket 增加了一个选项IP_TRANSPARENT可以接受目的地址没有配置的数据包,也可以发送原地址不是本地地址的数据包。可以通过该特性实现4层以上的透明代理。,详细分析可以看这里

相对上面两种,这种方式相对更简便,也更安全(初看以为也能篡改 IP,其实是不行的,必须是发起请求的 IP 才行,原因自行思考一分钟吧)。

小结

因此,最好都设置透明代理,这样后端就不需要特别去关注怎么获取真实的请求 IP 地址,直接取 ngx.var.remote_addr 值就是了。关键这也是最安全的方式,没有被篡改的风险。

想起以前做爬虫的时候,最大的难题就是爬取次数太多,IP 被限制了,只能花钱去买代理,然后代理又被封了,让人很头大。现在看来,也许可以在请求的时候加上 X-Forwarded-For 头?有时间可以试一试,理论上应该有些效果。

参加 OpenResty Con 2018

第一次参加技术性的大会,总体质量挺高的,有些技术实践,技巧建议或者技术前瞻很有意义,收益颇多,也很受启发,这种技术性大会,可以的话,打算每年参加一场(立个 flag :)),原因有二:

  • 了解同行的技术实现方案。很好的学习机会,对提升自身技术能力很有必要
  • 放开眼界,提升自信。闭门造车,比较容易导致盲目自卑或自信,之前认识比较局限,
    比如容易以公司或职位论技术,不太客观。

记下一些技术点,后续有时间需要慢慢研究。

技术点

  • DNS 动态解析
    lua-resty-dns
  • Hyperscan 高性能的正则表达式匹配库
    有个腾讯讲师分享,说项目内使用 Hyperscan 做正则匹配,比传统的 PCRE 库快 30% 左右。

Hyperscan 已经开源,是一款来自于Intel的高性能的正则表达式匹配库。它是基于X86平台以PCRE为原型而开发的,并以BSD许可开源在 https://01.org/hyperscan。在支持PCRE的大部分语法的前提下,Hyperscan增加了特定的语法和工作模式来保证其在真实网络场景下的实用性。

  • OpenResty 1.15.6.1(新版)特性
    • 基于 cosocket 连接池的后端并发调控
    • 对 Nginx resolver 命令扩展,支持 local 指定 DNS 文件
      # read nameserver address from /etc/resolv.conf
       resolver local=on ipv6=off;
       resolver local=/path/to/resolv.conf
      
    • 取消对 Lua 5.1 解释器的兼容支持,要求使用 LuaJIT 2,推荐 OpenResty LuaJIT 2.1 分支
    • OpenResty LuaJIT 2.1 新特性
      • table.clone table 浅拷贝
      • table.isarray 判断 table 是否为数组
      • table.isempty 判断空 table。isempty({}) -- true
      • table.nkeys 获取 table key 数量
      • thread.exdata
      • GC 优化
    • ngx_stream_lua 模块新特性
    • 100% 非阻塞的 ngx.pipe API 在 Linux 上基于 epoll + signalfd
      用于执行 linux cmd 命令,标准 Lua API io.popen("cmd") 是阻塞的
    • OpenResty CI(付费?)
    • lua_shared_dict R/W lock
  • Kong 很多讲师谈到 Kong,主要用来做 API 网关
  • Nginx 指令配置(优化)
    • keepalive_timeout, keepalive_requests
    • sendfile on
    • tcp_nopush, tcp_nodelay
    • listen backlog reuserport ?
  • Linux sysctl 设置(优化)
    • Memory
    • Size of processor queue
    • Maximum TCP buffer size
    • Disable TCP timestamps

-------------------------- 第二天------------------------

  • lua-resty-mlcache 三级缓存系统,非常有用,已经在 cloudflare 使用
  • ngx time 的使用
    默认 ngx.time 会被缓存,只有开始处理事件时才会被更新,使用 ngx.update_time()
  • string 地址是不可变的(与 Go 类似?),昂贵的 lj_str_new
    • 使用 table.concat 和 string.format
    • 传递 table,而不是 string
    • 减少字符串的创建,比如用 string.byte 代替 string.sub
  • 使用 table 作为类似 ctx 共享变量,效率更高。lua-tablepool
  • 尽量使用 ngx.null 而不是 nil
  • Dump the JIT tracing process 将JIT Tracing 过程 dump 到文件
     local dump = require "jit.dump"
     dump.on(nil, "/tmp/jit_dump.log")
    
  • 方法建议
    • 保障正确:linter + test + code review,关注 metrics
    • 追求高效 :持续 benchmark,使用火焰图、jit dump 等工具寻找热点,并优化
  • OpenResty trace 动态追踪工具,春哥用的,非入侵式,可以收集进程(与语言无关的)运行时信息,然后还可以动态展示进程运行状态,像是下一代的技术,非常惊艳,但是暂时没有开源的计划 :)

在 C# 中执行 js

在C#中执行js一般的方法如下:

  • 使用execScript :没有返回值,不能传参数
mshtml.IHTMLDocument2 doc = webBrowser.Document.DomDocument as mshtml.IHTMLDocument2; 
mshtml.IHTMLWindow2 win = doc.parentWindow as mshtml.IHTMLWindow2; 
win.execScript(@”alert(‘hello webbrowser’)”, “javascript”);
  • 使用InvokeScript,可以返回值,且可传参数
webBrowser.Document.InvokeScript(“test”); 

但是,某些情况下- 使用InvokeScript,可能会异常,例如:

string s=webBrowser.Document.InvokeScript(“myobject.test()”).ToString();

可以使用下面的方法解决:

webBrowser.Document.InvokeScript(“eval”,new object[]{“myobject.test()”}).ToString()

如果需要外部传参数的话,只有用下面的方法了,比较麻烦,但是效果不错

HtmlElement head = webBrowser.Document.GetElementsByTagName(“head”)[0]; 
HtmlElement scriptEl = webBrowser.Document.CreateElement(“script”); 
IHTMLScriptElement element = (IHTMLScriptElement)scriptEl.DomElement; 
element.text = “function pwdSetSk(sessionKey) { pgeditor.pwdSetSk(sessionKey);return ‘secess’;} function pwdResult() { result=pgeditor.pwdResult();return result;} function machineNetwork() { result=pgeditor.machineNetwork();return result;}”; 
head.AppendChild(scriptEl); 
string test = webBrowser.Document.InvokeScript(“pwdSetSk”, new object[] { sessionKey }).ToString();

关于Cookie一些思考

我们的系统使用截图方式获取验证码一直不靠谱,偶尔会报错。为了彻底解决这个问题,想着可以使用发送请求方式直接获取验证码。但是,又出现了新问题,对方服务器一直返回“验证码失效的错误”,弄了很久,最后发现,问题出在cookie的处理上。
获取验证码请求会设置 cookie。正常登陆过程,比如登陆页面的 webBrowser 初始 cookie=ck_1,当发送验证码后,webBrowser页面cookie=ck_2。因为我们登录使用webBrowser模拟的方式,而获取验证码使用的 http 直接请求的方式,所以,发送验证码请求获取验证码图片,我们还需要将当前 http 返回的 cookie 写到 webBrowser 的对应cookie 里。可以使用JS的方式:

document.cookie= c.Name=escape(c.value)

但是,这样就可以了吗?webBrowser 是根据 cookie 的路径取相应的 cookie 再发送到服务器进行认证的,但是,上面并没有设置 cookie 路径,而默认路径是在当前目录下,并不一定是根目录。所以,还需要设置 cookie 路径:

document.cookie= c.Name=escape(c.value);path=c.path

只有 cookie 的 name, path, domain 这三个属性都相同,才会被覆盖。取 cookie 的时候会先尾匹配 domian,然后前匹配 path。所以,domin 或 path 详细,当名称相同的时候会被优先使用。例如:

cookie_1:"document.cookie= name=sb1;path=/domain=csdn.net" 
cookie_2:"document.cookie= name=sb1;path=/tt;domain=csdn.net"

同时存在的时候,会使用 cookie_2 的值。
可以通过设置 cookie 有效期,Expire time/Max-age 属性,来删除对应的 cookie。
使用 fiddler 分析 http 请求的时候,有的 cookie 后面有 HTTP-Only 标志,这是为了防止窃取 cookie 的安全机制,设置 HTTP-Only 后,使用 JS 不能访问该 cookie 。
一旦 cookies 通过 Javascript 设置后遍不能提取它的选项,所以你将不会知道 domain, path, expiration 日期或 secure 标记。因此分析 request 的 cookie 的时候,我们只能看到 cookie 的 name 和 value。

参考:

http://www.dannysite.com/blog/77/
http://blog.csdn.net/fangaoxin/article/details/6952954

Nginx 与 lua_nginx_module

启动 Nginx

系统执行流程(概述)

20131009165600781

Nginx master

Nginx 启动后,首先进入 main() 函数,加载初始化配置信息(nginx.conf),调用所有模块的 init_module 方法,然后再调用 ngx_master_process_cycle

26142403-71f4cccd438240f086019a43e11ad879

Nginx worker

如上图,函数 ngx_start_worker_processes 会循环 fork() 出配置项 worker_processes 指定的 worker 进程。关于系统函数 fork,需要知道的是:

Linux下一个进程在内存里有三部分的数据,就是"代码段"、"堆栈段"和"数据段"。其实学过汇编语言的人一定知道,一般的CPU都有上述三种段寄存器,以方便操作系统的运行。这三个部分也是构成一个完整的执行序列的必要的部分。

函数fork( )用来创建一个新的进程,该进程几乎是当前进程的一个完全拷贝:

* 子进程和父进程使用相同的代码段;
* 子进程复制父进程的堆栈段和数据段;

如果一个大程序在运行中,它的数据段和堆栈都很大,一次fork就要复制一次,那么fork的系统开销不是很大吗?
一般CPU都是以"页"为单位来分配内存空间的,每一个页都是实际物理内存的一个映像,象INTEL的CPU,其一页在通常情况下是 4086字节大小,而无论是数据段还是堆栈段都是由许多"页"构成的,fork函数复制这两个段,只是"逻辑"上的,并非"物理"上的。
也就是说,实际执行fork时,物理空间上两个进程的数据段和堆栈段都还是共享着的,当有一个进程写了某个数据时,这时两个进程之间的数据才有了区别,系统就将有区别的" 页"从物理上也分开。
系统在空间上的开销就可以达到最小。这种技术又叫 [Copy-on-write (COW)](https://en.wikipedia.org/wiki/Copy-on-write
) 

因此,worker 进程的 lua vm,是 fork 时,从 master 进程拷贝而来,因为是拷贝,在 worker 进程内的操作,自然就不会影响 master 堆栈(lua vm)变化。

PS:nginx 进程间通信,主要使用三种方式:

  • 系统信号量,例如 hup 等;
  • socketpair 匿名套接字,fork 子进程也会即继承父进程的 socketpair,从而实现父子进程间通信;
  • 共享内存,调用系统函数 shmget 等操作;

openresty 的 ngx.shared.DICT 就是基于系统共享内存实现的。

lua_nginx_module

lua_nginx_module 模块的函数 ngx_http_lua_init 也会在 main 调用所有模块的 init_module 方法时被调用,该函数调用 ngx_http_lua_init_vm 创建 lua 虚拟机实例,大致流程如下:

main -> ngx_http_lua_init -> ngx_http_lua_init_vm -> ngx_http_lua_new_state (创建虚拟机实例)

ngx_http_lua_new_state 主要功能

1)生成新 vm,lua_State
2)设置默认的package路径,路径由编译脚本生成
3)ngx_http_lua_init_registry() 初始化 lua registry table。registry 中保存了多个 lua 运行期需要保持的变量,例如:cache 的 lua 代码,协程的引用地址等,这些变量如果放在 lua 堆栈中会被 GC 机制自动回收,所以需要另外保存。
4)ngx_http_lua_init_globals() 初始化 global 全局变量。ngx 的各种 api 和内置变量就是在这里由 ngx_http_lua_inject_ngx_api() 进行注入,提供给 lua 脚本调用。

虚拟机创建完成后,继续调用 init_handle 函数,该函数将加载执行配置项 init_by_lua* 对应的 lua 代码文件。即 init_by_lua* lua 代码是加载在 master 虚拟机的。

nginx 分为 11 个执行阶段:


typedef enum {
    NGX_HTTP_POST_READ_PHASE = 0,   // 接收到完整的HTTP头部后处理的阶段
 
    NGX_HTTP_SERVER_REWRITE_PHASE,  // URI与location匹配前,修改URI的阶段,用于重定向
 
    NGX_HTTP_FIND_CONFIG_PHASE,     // 根据URI寻找匹配的location块配置项
    NGX_HTTP_REWRITE_PHASE,         // 上一阶段找到location块后再修改URI
    NGX_HTTP_POST_REWRITE_PHASE,    // 防止重写URL后导致的死循环
 
    NGX_HTTP_PREACCESS_PHASE,       // 下一阶段之前的准备
 
    NGX_HTTP_ACCESS_PHASE,          // 让HTTP模块判断是否允许这个请求进入Nginx服务器
    NGX_HTTP_POST_ACCESS_PHASE,     // 向用户发送拒绝服务的错误码,用来响应上一阶段的拒绝
 
    NGX_HTTP_TRY_FILES_PHASE,       // 为访问静态文件资源而设置
    NGX_HTTP_CONTENT_PHASE,         // 处理HTTP请求内容的阶段,大部分HTTP模块介入这个阶段
 
    NGX_HTTP_LOG_PHASE              // 处理完请求后的日志记录阶段
} ngx_http_phases;

lua_nginx_module 模块在其中的 rewrite, access, content,log 阶段注册了 handler 函数。这几个阶段执行流程如下:

image

上图,除了 init_by_lua* 都是在 worker 进程内处理的,模块初始化阶段(ngx_http_lua_init),将这些阶段处理函数挂载到对应的阶段处理函数数组,以 content_by_lua 为例,大致流程如下:

ngx_http_lua_content_by_lua(设置 handle file 路径) -> ngx_http_lua_content_handle -> ngx_http_lua_content_handle_file(加载 lua 代码) -> ngx_http_lua_content_by_chunk -> ngx_http_lua_by_thread(在协程里面执行)

所有协程共享 woker 进程的 lua vm,每个外部请求都由一个 lua 协程处理,协程之间数据隔离,即不同请求间的数据隔离。

ngx_lua 协程

详见另一篇博客(ngx_lua 中协程的使用 )

小心 ngx_lua 协程

ngx_lua 中创建协程的方式主要有三种:

这些函数创建的协程本质都是 lua coroutine,但由不同的角色控制(yield resume),使用场景也不同,使用场景范围最大的是 ngx.timer.at 几乎每个阶段都可以使用,其他两个方法调用限制较多,具体看上面的链接。

前面已经说过,协程是不会主动出让 CPU 时间片的,除非被挂起,阻塞或者代码执行出错。因此,用户创建的协程,如果不主动退出,比如代码进入了死循环,就无法切换执行其他的协程,而且,因为父协程执行的优先级比子协程低,所有请求处理都在同一个主协程内,如果子协程执行时间太长,就会导致 worker 进程请求处理缓慢,甚至卡死,影响整个 OpenResty 处理效率。

另一个问题,当在请求子协程(rewrite, access, content 阶段)内调用 ngx.thread.spawncoroutine.create 创建协程,可能会阻塞当前请求,因为请求子协程是用户创建协程的父协程,必须要所有子协程结束,才能退出,执行下个阶段。ngx.timer.at 创建的协程是与请求协程无关的,可以认为它的父线程是 worker 主协程,所以,当它阻塞时,并不会影响请求处理。

因为协程之间数据是异步的,所以,在协程内使用外部数据要格外小心,最好直接使用参数调用,避免出现非预期的数据变化。

ngx.timer.at 是延迟执行,可以递归实现定时执行的效果,delay 延迟参数不能设置的太小,否则会拖慢当前 worker 的请求处理速度。当需要定时执行的时候,可以直接使用 ngx.timer.every,更简洁,而且 delay 不能为 0

总之,最关键的是与其他语言(例如 Golang)不同, lua 虚拟机没有实现协程管理功能,完全由用户自己控制,使用不当会严重影响程序性能!

虚拟机分区扩容(数据无损)

创建虚拟机设置磁盘大小时,很难预测未来的使用,导致分配的磁盘大小后续常常不够用。

可以使用命令 df -h 查看挂载的目录空间使用情况。

因为我的根目录(boot 目录,挂载到 /dev/sda1 分区)空间不够,导致软件安装失败,出现空间不足的错误。扩容之后效果下:

image

设置步骤

1)对虚拟机备份: 直接将虚拟机所在的文件夹复制一份就行;
2)关闭虚拟机,扩展虚拟机磁盘容量:虚拟机 -> 设置 -> 扩展 -> 设置最大磁盘大小

image

注意,此时虽然虚拟机磁盘更大了,但是并没有挂载进系统,所以,系统还不能识别应该到。可以使用命令 fdisk -l 看到,总的磁盘大小变大了,但是分区大小没有变,增加空间“不见了”。

3)安装磁盘分区工具:GParted,调整分区大小。

有两种安装方式

  • 使用命令 apt-get install gparted 直接安装;
    调整非系统分区,可以使用这种方式,更简单,快捷。

  • 下载 iso 镜像
    调整系统分区大小,必须使用这种方式安装。

4)加载 gparted 的 .iso 文件,选择光驱启动虚拟机。

  • 虚拟机 -> 设置 -> CD/DVD(IDE),勾选启动时连接,选择使用 ISO 映象文件,找到 gparted .iso 文件存放的位置,确定即可;

image

  • 进入 BIOS 设置界面:右击虚拟机 -> 电源 -> 打开电源时进入固件;

image

  • 设置从 CD-ROM 启动:Boot -> 调整(+/-) CD-ROM Drive 顺序到最前面;

image

  • F10 保存设置,进入 GParted ;

image

  • 按 Enter 键,选择默认就行,直到进入如下的设置界面

image

5)调整分区大小

  • 选择需要调整的分区,右键 -> Resize/Move

image

这里需要特别解释一下,磁盘和分区的关系,就像堵车时,路跟车子关系类似,前面的车子必须预留空间给后面车子,后面的车子才能往前移动。

Free space preceding 就是空出给后面分区的空间大小

因此,如上图,我要调整“最后面的” /dev/sda1 大小,就必须先按顺序调整“前面的” sda8,sda7 等等分区的大小。

注意, New Size 是当前分区的大小,这个值可以不变或调大,但不要调小,否则可能导致数据丢失。
image

6)设置完成后,记得点击 Apply 应用,分区需要花点时间,耐心等待~

7)重启虚拟机, /dev/sda1 分区调整成功

image

end.

Nginx 动态加载模块

nginx-1.9.11 开始,支持动态加载源码模块或第三方模块,需要先在编译 Nginx (./configure)时指定:

  • 源码模块:./configure --with-mail=dynamic ...
  • 第三方模块 :./configure --add-dynamic-module=...

模块对应的 .so 文件会被存放在 /path/nginx/modules/ 下面,当需要使用模块时,在 nginx.conf 最顶端(main)配置 load_module,指定模块路径。

但是,这时,添加模块仍然需要在编译阶段声明即需要重新编译 Nginx 程序,很多时候,在生产环境是不能随便去更新替换二进制程序的。

因此,在 nginx-1.11.5 编译命令增加了 --with-compat可以单独编译需要新增的模块直接动态加载到原有的 nginx 二进制程序而不用重新编译 nginx
官方阐述

Dynamic modules – NGINX 1.11.5 was a milestone moment in the development of NGINX. It introduced
 the new --with-compat option which allows any module to be compiled and dynamically loaded into a 
running NGINX instance of the same version (and an NGINX Plus release based on that version). 
There are over 120 modules for NGINX contributed by the open source community, and now you can load 
them into our NGINX builds, or those of an OS vendor, without having to compile NGINX from source. 
For more information on compiling dynamic modules, see Compiling Dynamic Modules for NGINX Plus.

下面,以动态加载模块 lua-nginx-module 为例,展示具体用法。

  • 下载 Nginx 安装包
wget http://nginx.org/download/nginx-1.11.5.tar.gz
tar -xzvf nginx-1.11.5.tar.gz
        export LUAJIT_INC=/usr/local/include/luajit-2.0
        export LUAJIT_LIB=/usr/local/lib
  • 编译 Nginx ,记得带上 --with-compat 开启兼容模式
./configure --prefix=../nginx-dy/nginx --with-ld-opt="-lpcre -Wl,-rpath,/usr/local/lib" \
         --with-pcre=../pcre-8.42 \
		 --with-zlib=../zlib-1.2.11 \
		 --with-stream \
		 --with-http_ssl_module \
		 --with-mail=dynamic \
		 --with-compat 
  • 安装 Nginx
make -j4
make install
nginx/sbin/ngin -t   -- 测试安装成功
cd ./nginx-1.11.5/

./configure --with-compat --with-cc-opt='-O0 -I /usr/local/include/luajit-2.0'  \
    --with-ld-opt='-Wl,-rpath,/usr/local/lib -lluajit-5.1' \
    --add-dynamic-module=../lua-nginx-module-0.10.13 --add-dynamic-module=../ngx_devel_kit-0.3.0

make modules

cp objs/ndk_http_module.so objs/ngx_http_lua_module.so ../nginx-dy/nginx/modules/
  • 配置使用,在 nginx.conf 最顶端,添加:
load_module modules/ndk_http_module.so;
load_module modules/ngx_http_lua_module.so;
  • 完成

遇到的坑

  • 编译 Nginx,要带上 with-compat ,否则运行 Nginx 时,会报 ngx_lua 模块不兼容的错误。
  • 编译 Nginx,要使用 --with-ld-opt 指定 PCRE 等依赖组件 lib 安装路径,否则会报动态库链接符号错误。

小结

Nginx 动态加载模块,使增加功能模块变的更加方便与灵活,由于,Nginx 奇数版本为开发版,偶数为稳定版,目前,虽然稳定版已经到了 nginx-1.14.0,但是,大部分生产环境应该还是 nginx-1.10.xx,所以,要使用这个功能呢个,生产环境需要升级到 nginx-1.11.5 或以上版本才行。 :)

url 特殊字符编码问题

url 特殊字符编码

RFC 3986 指定了保留字符,分为两类,如下:

gen-delims    = ":" / "/" / "?" / "#" / "[" / "]" / "@"
sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
                 / "*" / "+" / "," / ";" / "="

可能只会选择对其中的部分保留字符编码,而不同的编程语言和函数选择编码的字符不一样,这就导致不同系统之间的解析出来的 http url 不一致。

以 Go 为例,运行下面两段代码。

代码 1:

    murl := "http://testwhc5.b0.upaiyun.com/anything/<Wake Up To Dream> What's Media Lab 2016.mp4"
	mu, _ := url.Parse(murl)
	fmt.Printf("url: %s\r\n", murl)
	fmt.Printf("url.string: %s\r\n", mu.String())

输出:

url: http://testwhc5.b0.upaiyun.com/anything/<Wake Up To Dream> What's Media Lab 2016.mp4
url.string: http://testwhc5.b0.upaiyun.com/anything/%3CWake%20Up%20To%20Dream%3E%20What%27s%20Media%20Lab%202016.mp4

代码 2:

    murl = "http://testwhc5.b0.upaiyun.com/anything/%3CWake%20Up%20To%20Dream%3E%20What's%20Media%20Lab%202016.mp4"
	mu, _ = url.Parse(murl)
	fmt.Printf("url: %s\r\n", murl)
	fmt.Printf("url.string: %s\r\n", mu.String())

输出:

url: http://testwhc5.b0.upaiyun.com/anything/%3CWake%20Up%20To%20Dream%3E%20What's%20Media%20Lab%202016.mp4
url.string: http://testwhc5.b0.upaiyun.com/anything/%3CWake%20Up%20To%20Dream%3E%20What's%20Media%20Lab%202016.mp4

运行以上两段代码,会发现输出的 url string 有微小的差异,上面的将 ' 转码成了 %27,而下面没有转码。

按理来说,调用同样的函数,编码后的 url 应该一样才对,问题出在哪儿呢?

查看函数源码发现,函数 url.String 输出的是编码后的 url 字符串。但是,如果输入的原始 url 已经是编码的,那么就不会做处理直接使用原始 url。判断函数 validEncodedPath 代码片段:

       case '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=', ':', '@':
 			// ok
 		case '[', ']':
 			// ok - not specified in RFC 3986 but left alone by modern browsers
 		case '%':
 			// ok - percent encoded, will decode
 		default:
 			if shouldEscape(s[i], encodePath) {
 				return false
 			}

那么,上面两段代码输出结果不一致的原因基本找到了,那就是因为 validEncodedPath 判断认为代码 2 输入的 url 是已经编码过(已被 ngx_lua 编码)的,就将原始字符串输出了。而代码 1 输入的 url 被 Go 函数 EscapedPath 编码之后再输出。

再进一步查看 Go 的编码函数 escape(s string, mode encoding),判断是否需要编码函数 shouldEscape(c byte, mode encoding)判断,代码片段如下:

        case '$', '&', '+', ',', '/', ':', ';', '=', '?', '@': // §2.2 Reserved characters (reserved)
  		// Different sections of the URL allow a few of
  		// the reserved characters to appear unescaped.
  		switch mode {
  		case encodePath: // §3.3
  			// The RFC allows : @ & = + $ but saves / ; , for assigning
  			// meaning to individual path segments. This package
  			// only manipulates the path as a whole, so we allow those
  			// last three as well. That leaves only ? to escape.
  			return c == '?'
  			
  			...

可以看到 Go 会对 url path 中 ', ? 等特殊字符编码,而不会对 ;, =等字符编码。当然更多不同语言的编码差异, 需要具体分析了。对于使用多种编程语言的复杂系统,需要特别注意这点,最好是最后处理 url 的程序自己保持编码一致,即将输入的 url,处理成符合自己的编码规范。因为,一般解码函数(如 Go)没有这种差异,所以可以先对输入的 url 解码,再编码,这样能保证编码的 url 字符串,符合本程序语言规范的,从而保证本程序最终的 url 编码字符串一致

总结

所以,最好是不要在 url 中使用这些保留字符。不同的语言会有微小的差异,很容易导致 bug,查找和处理都很麻烦。

规则引擎的实现

规则引擎的实现

概述

什么是规则引擎?简单说就是解释,执行规则的一类程序,本质上就是规则的编译器。

我们知道,编程语言都是由一系列的规范组成的,编译器根据语言规范,生成最终的机器码,因此,规则引擎跟程序员经常打交道的语言编译器并没有本质区别。但是,规则引擎定义的规则,都比较简单,没有编程语言那么复杂,规则引擎主要是分析,执行两步,一般没有复杂的代码生成(中间码或机器码)与优化模块。

前面说过,编程语言其实就是规则的定义,不同的是规则引擎的规则定义非常简单,主要包括两部分:条件和行为,可以很形象的用伪代码表示:

when
    condition
then
    action

规则条件的判断就是模式匹配过程,模式匹配是将数据源(也称 fact)与条件匹配,这里的数据源不仅仅是指输入规则引擎的消息数据,还包括规则引擎内定义,实现的供规则调用的函数或数据变量。所以,规则引擎主要包括三类对象:

  • 规则或规则集
  • 输入的数据消息
  • 自定义的方法与变量

目前比较常用的规则引擎开源框架是使用 Java 开发的 drools

实现

规则引擎的实现方案有以下几种:

  1. 硬编码

就是直接将用户的规则写死到系统代码内,实现简单,但是难以维护,且很浪费程序员精力。

  1. 模板配置

使用规则模板(一般是 Json 格式),后端按模板解析,执行。关键前期是模板的定义,实现相对简单,可读性强,符合普通用户的认知方式,但是,扩展性差,不够灵活,对于交复杂的场景,模板会变的很复杂且冗余,后端的模板解析代码同样会变的复杂且难以维护。

  1. 表达式

在编程语言中表达式,表达式包括操作数(常量,变量,函数等)和操作符(运算符等)。
通常情况下,用户的配置的业务规则其实就可以看作是一个表达式,表达式能够满足大多数业务场景,操作数据的库的 SQL 语句就是属于表达式的实现方式。

表达式足够灵活,相对比较安全,对用户学习成本较低,是项目成熟后比较常用的实现方案,后面也将重点讨论基于表达式的具体实现。

  1. 编程语言

对于某些非常复杂的场景,需要支持用户直接定义函数,声明变量等,规则就相当于是代码了。前面提到的开源框架 drools 就支持 Java 代码的,广义上来说,类似 OpenResty 或游戏引擎这种支持运行脚本语言的系统,都可以看作是这种方案的实践。

自然的,这种方案几乎是没有边界的了,可以支持任何规则。对于通用框架来说,是有必要的,但是,对应特定的业务场景下,这种方式会带来多种弊端,需要用户具有一定的编程能力,学习成本较高,也提高了系统复杂度,不易维护,也不安全。

这里的编程语言应该就是主流的语言,而不是自己造轮子,发明一门新语言,给自己挖坑。

表达式实现

表达式是组成编程语言的数据结构之一,对于规则引擎来说用户输入的规则就是普通的字符串,规则引擎首先要对表达式字符串进行解析,解析的过程包括两个步骤,也即编译原理中的词法分析语法分析,最后生成抽象语法树,这里就是表达式的语法数,最后递归遍历执行。

词法分析(英语:lexical analysis)是计算机科学中将字符序列转换为标记(token)序列的过程。进行词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称lexer),也叫扫描器(scanner)。

简单来说,就是对代码字符串的字符进行分类,比如空格,关键词等。

语法分析(英语:syntactic analysis,也叫 parsing)是根据某种给定的形式文法对由单词序列(如英语单词序列)构成的输入文本进行分析并确定其语法结构的一种过程。

抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

Go 的 parser 接受的输入是源文件(字符串),内嵌了一个 scanner,scanner 生成的 token 经过语法分析,生成 AST 返回。

Go AST 主要有三类节点组成:表达式,语句,声明。Go 语言规范 详细的定义了这三类数据结构,以及所子类。需要说明的是,这里的表达式,包括了 Types(类型)和 primary expressions(基本表达式)。

type Node interface {
        Pos() token.Pos // position of first character belonging to the node
        End() token.Pos // position of first character immediately after the node
}

// All expression nodes implement the Expr interface.
type Expr interface {
        Node
        exprNode()
}

// All statement nodes implement the Stmt interface.
type Stmt interface {
        Node
        stmtNode()
}

// All declaration nodes implement the Decl interface.
type Decl interface {
        Node
        declNode()
}

基于 Go 基本编译方法,递归遍历执行 Go ParseExpr 函数返回的表达式 AST,实现基于表达式的规则引擎。在我的开源项目 https://github.com/jinhailang/gre 有详细的文档说明跟代码实现,这里不再复述了。

使用场景

规则引擎应用非常广泛,在用户操作比较复杂的场景,经常被用到。例如:风控系统,Waf 系统等安全类系统,游戏引擎,数据库等通用技术框架。

规则引擎应用于较为复杂的业务场景,增强了系统的扩展性,降低系统耦合,很大程度的提高了开发人员的工作效率,满足用户多种需求,使开发者和用户都得到解放。

小结

规则引擎的核心是规则解析,基础是数据源。规则的解析依赖 Json 结构(模板配置)或 AST,AST 是由语法分析器生成,对于很多编程语言,都直接暴露了 API 调用(比如 Go 就开放了很丰富的编译器实现 API),绝大多数情况,都没有必要自己造轮子。

数据源包括外部输入的消息数据,以及规则引擎内实现的方法或定义的变量。规则的执行是基于数据源的,因此,规则引擎开发之前,首先要明确数据源,也即明确外部需求。

Go unsafe 包探究

Go unsafe 包探究

unsafe 包的作用有两个:

  • 实现任意不同类型指针之间的转换;
  • 实现指针运算(偏移)操作;

包接口比较简单,包括 3 个函数:

  • func Alignof(x ArbitraryType) uintptr 变量对齐;
  • func Offsetof(x ArbitraryType) uintptr 计算结构体(struct)内属性值的偏移字节大小,即相对结构体起始地址的大小;
  • func Sizeof(x ArbitraryType) uintptr 类型变量自身占用字节大小,注意,不包括变量引用的地址;

unsafe 函数都是在编译时计算返回结果的,所以,可以直接用于常量赋值,也要注意,尽量不要将运行时变量类型(例如 slice)作为这些函数的参数出入,可能会导致非预期的结果。

包括 2 种类型:

  • type ArbitraryType 占位符,实际上表示任意的变量类型;
  • type Pointer 指向任意类型的指针类型。类似 C 中 void * 类型。

unsafe.Pointer 是本包的精华,也是被使用最多的功能点。Pointer 允许程序(开发者)跳脱 Go 的类型系统,(通过指针转换与运算)读写任意内存,所以要小心使用。
Pointer 总结下来就两个特性,也是实现前面说的目标(作用)的基础:

  • Pointer 能与任意类型的指针值互相转换;
  • Pointer 能与 uintpter 相互转换;

uintptr 是无符号整型,被用来存放指针值(地址)。unsafe.Pointer + uintptr 就能实现指针偏移计算了。因为 uintptr 变量存放的是某个变量的地址,因此,uintpter 变量值对应的内存地址块(对应的变量)可能会被 GC 回收掉。unsafe.Pointer 本质上就是指针,该类型变量指向的内存块则不会被回收,因此,应该使用 unsafe.Pointer 类型变量来保持变量地址不被回收。

// 不安全的使用

z := uintptr(unsafe.Pointer(&xx))
//todo ...
fmt.Println(z)

//正确使用

sp:=safe.Pointer(&xx)
z = uintptr(sp)
//todo ...
fmt.Println(z)

安全的使用场景

前面说过,使用 Pointer 必须要非常小心才行,官方定义了 6 种安全有效的使用场景。使用 go vet 工具可以检测出不符合这些场景的调用。

  • 将指针 *T1 转化成 *T2

如果 T2 大于 T1 变量类型的内存占用,并且两者共享等效的内存布局,则该转换允许将一种类型数据解释成为另一种类型。例如:int64 与 float64。

  • 将 Pointer 转成 uintptr,但是,不能转回到 Pointer

与 Pointer 不同,uintptr 保存的地址指向的变量是可以被 GC 回收的。

可以使用 runtime.KeepAlive 函数避免变量被 GC。

  • 将 Pointer 转成 uintptr,用于偏移运算,将计算结果转回成 Pointer

通常用于访问结构体或者数组等:

// equivalent to f := unsafe.Pointer(&s.f)
f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f))

// equivalent to e := unsafe.Pointer(&x[i])
e := unsafe.Pointer(uintptr(unsafe.Pointer(&x[0])) + i*unsafe.Sizeof(x[0]))

特别注意:因为上面说的原因,uintptr 不能放在临时变量内,所以下面这样分开使用也是无效的

u := uintptr(p)
p = unsafe.Pointer(u + offset)

另外,做地址偏移的时候,要注意越界的问题,比如:

a = []int{0,1,2,3}
p := unsafe.Pointer(uintptr(unsafe.Pointer(&a)) + len(a) * unsafe.Sizeif(a[0]))

此时,变量 p 指向的地址是未知的,可能会出现不声明,直接偷偷的读写未知内存地址的情况,对系统运行稳定性影响很大。

  • 使用函数 syscall.Syscall 时,将 Pointer 值转成 uintptr

syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))

  • 将函数 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 返回值,从 uintptr 转成 Pointer,最终转成具体的类型值
p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))
  • 将 reflect.SliceHeader 或 reflect.StringHeader 的数据字段(Data)转成 Pointer,或者从 Pointer 转成 Data 字段

Data 字段返回的也是 uintptr:

var s string
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) // case 1
hdr.Data = uintptr(unsafe.Pointer(p))              // case 6 (this case)
hdr.Len = n

reflect.SliceHeader 的使用:

package main

import "fmt"
import "unsafe"
import "reflect"
import "runtime"

func main() {
        bs := []byte("Golang")
        var pa *[2]byte // an array pointer
        hdr := (*reflect.SliceHeader)(unsafe.Pointer(&bs))
        pa = (*[2]byte)(unsafe.Pointer(hdr.Data))
        runtime.KeepAlive(&bs)
        fmt.Printf("%s\n", pa) // &Go
        pa[1] = 'a'
        fmt.Printf("%s\n", bs) // Galang
}

如果最后一行的 Printf 不存在的话,runtime.KeepAlive 的调用是必须的。
另外,最好不要像下面这样,直接从 StringHeader 或 StringHeader 直接创建对象:

// Assume p points to a sequence of byte and
// n is the number of bytes in the sequence.
var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(new([5]byte)))
// Now the just allocated byte array has lose all
// references and it can be garbage collected now.
hdr.Len = 5
s := *(*string)(unsafe.Pointer(&hdr))

小结

在有些场景下,使用 unsafe.Poniter 可以帮助我们写出高效的代码,例如在 sync/atomic 包内的使用。而且一些底层或 C 调用,必须要用到 Poniter。

unsafe 包用于有经验的开发者绕过 Go 类型系统的安全性限制,一定要深入理解上面的六种使用场景,谨慎使用,否则很容易引起严重的内存问题,有经验的开发者都知道,这类问题通常是很难定位的,对系统的稳定性影响很大。

参考

Go 101 : https://go101.org/article/unsafe.html

http 协议的结束符

http 协议的结束符

突然想起很久之前一次面试,面试官问我,当请求头没有 content-length 时,怎么知道请求体结束了?

http 的 headerbody 之间空行分割的,又因为每个头部项是以 \r\n 作为结束符,所以,数据流中是以 \r\n\r\n 来分割解析请求头(响应头)与请求体(响应体)的。如下图所示:

image

那么怎么知道(请求体)响应体结束了呢? http 协议规定,响应头的字段 content-length 用来表示响应体长度大小,但是,有可能发送请求头时,并不能知道完整的响应体长度(比如当响应数据太大,服务端流式处理的情况),这时需要设置请求头Transfer-Encoding: chunked,使用数据块的方式传输,数据块格式如下图所示:

image

每个数据块分为两个部分:数据长度和数据内容,以 \r\n 分割,最后长度为 0 的数据块,内容为空行(\r\n),表示没有数据再传输了,响应结束。需要注意的是,此时, content-length 不应该被设置,就算设置了,也会被忽略掉。

回到最开始的那个问题,我当时对 http 协议不太清楚,回答不上来,那位面试官就告诉我,可以使用 \r\n\r\n 来判断,现在看来,他说的并不严谨。首先,http 协议并没有规定请求体(响应体)要以 \r\n\r\n 作为结束符,其次,很重要的一点是,响应体(请求体)的内容是多种多样的,你没法做限制,当数据内容包含\r\n\r\n 时,显然解析出来的响应体就是不全的

当然,如果是自己实现 http 服务端的话,怎么兼容这种情况呢?

如果是短连接的话,比较简单,连接关闭就表示数据传输完成了。如果是长连接的话,一种不太优雅的方式就是使用超时机制,当读取超过一定时间,就认为数据已经传输完成。

总之,判断数据(块)结束最严谨的方式是计算长度,而不是使用结束符,但是,一般可控的场景下(双方约定),还是可以选择使用结束符来判断的,这样实现起来会更简洁。此时,为了防止内容中包含约定的结束符,导致数据内容被提前截断,客户端可以在发送数据时先对内容中的约定结束符进行编码。

扩展

因为服务端在解析请求头和请求体时,都需要依据以上协议,来读取完整数据。Slow HeadersSlow POST 两类 DDoS 慢速攻击正是利用了这个原理。

正常的 HTTP 报文中请求头部的后面会有结束符 0x0d0a(\r\n 的十六进制表示方式),
而攻击报文中不包含结束符,并且攻击者会持续发送不包含结束符的 HTTP 头部报文,
维持连接状态,消耗目标服务器的资源。

Nginx keepalive_requests 踩坑总结

Nginx keepalive_requests 踩坑总结

问题起因

Waf(基于 Nginx 实现的七层防护系统)上线上后,QA 同事对系统进行大流量压测(十万级 QPS),发现部署机器处于 TIME_WAIT 状态的 TCP 连接数异常,高峰期达到几千。按理 waf 这边使用的是长连接,连接数不应该这么多的。

问题定位

首先确认下 TCP 连接处于 TIME_WAIT 原因:

TCP 连接主动关闭方会处于较长的 TIME_WAIT 状态, 以确保数据传输完毕, 时间可长达 2 * MSL, 即就是一个数据包在网络中往返一次的最长时间。
其目的是避免连接串用导致无法区分新旧连接。

这就说明确实是服务端这边主动关闭了连接。我重新 review 了一下 Nginx 配置,确实使用了长连接,并且,我上线前在本地使用 ab 工具压测过, 没有观察到连接数量异常情况

只能祭出 google 大法了,发现可能是 keepalive_requests 设置太小引起的。

keepalive_requests 参数限制了一个 HTTP 长连接最多可以处理完成的最大请求数, 默认是 100。当连接处理完成的请求数达到最大请求数后,将关闭连接。

而我并没有配置 keepalive_requests,所以,就是使用的默认数 100,即一个长连接只能处理一百个请求,然后 Nginx 就就会主动关闭连接,使大量连接处于 TIME_WAIT 状态。

我猜测很大可能就是这个原因了,为了在本地验证,我将 keepalive_requests 分别设为 10, 50, 100, 1000,分四组分别进行压测,对比结果。

压测命令:

ab -n100000 -c10 -k http://127.0.0.1:8086/test

然后使用 netstat -nat |awk '{print $6}'|sort|uniq -c 查看连接数,结果出乎意料的清晰,TIME_WAIT 数量基本等于 总请求数/keepalive_requests,这就基本证实了猜测。

问题解决

其实大部分情况下,使用默认值是没有问题的,但是对于 QPS 较大的情况,默认数值就不够了,综合考虑,在 Nginx conf 增加配置项:

keepalive_requests      1024;

可能很多人更习惯不做限制,可以设置一个最大的数来实现这个效果 -- 2^32 - 1 = 4294967295(这是在运行32位和64位系统的计算机中是无符号长整形数所能表示的最大值,亦是运行32位系统的计算机中所能表示的最大自然数),

疑问

虽然问题解决了,但是,为什么 Nginx 要对单个长连接的处理请求数进行最大限制呢?一般我们理解的,长连接是在连接池里维护的,只要连接本身没有超时等异常问题,是可以一直复用的。

关于这个疑问,讨论的似乎不多,只在 Nginx 开发者论坛上看[nginx] Upstream keepalive: keepalive_requests directive.有所讨论,开发者有模糊的表述:

Much like keepalive_requests for client connections, this is mostly
a safeguard to make sure connections are closed periodically and the
memory allocated from the connection pool is freed.

但是,为什么需要定时关闭连接,释放内存?难道这只是一种保护策略,怕极端情况下,用户配置不当,导致连接一直不断开,引起内存问题?很奇怪,从 Nginx 开发日志也没有找到特别说明。

另外,上面的讨论贴其实是讨论 Upstream keepalive_requests 设置的,最新版 Nginx (version 1.15.3.) 在 Upstream 内增加了 keepalive_requests 配置项,与 keepalive_requests client connections 类似,默认值也是 100。也就是说,之前的版本,连接上游的连接是没有次数限制的,完全由上游的服务端的 keepalive_requests 设置,但是,如果上游服务端不使用 Nginx 的话,别的系统一般也是没有这个限制的。

在大流量的场景下,Nginx 代理升级到这个版本,就可能会引起连接异常情况。关于默认值和兼容性,作者的回复也比较主观,可能这块确实很难面面俱到吧,只能满足大多数场景了。

综上,在 QPS 较高的场景下,服务端要注意将 keepalive_requests 设置的大一些,如果 Nginx 升级到最新版,还需要注意设置 upstream 的keepalive_requests,这两个数量可以不一致,一般服务端要设置的大些,因为一般来说,一个服务端可能对应多个代理。

OpenResty 火焰图收集

火焰图可以用来分析程序代码函数层的 CPU 或内存的使用情况,为程序的优化提供很直观的参考。
因此,对一个成熟的项目来说,能够随时收集运行时的火焰图必不可少。

得益于春哥对动态追踪技术的研究(动态追踪技术漫谈建议先看一下),OpenResty 提供了很多调试工具,其中就包括火焰图收集脚本。

火焰图收集流程

  1. 下载 OpenResty 项目源码,编译(configure)带上 --with-debug,开启调试模式编译;

  2. 安装 systemtap,以及内核调试信息,注意要跟内核版本保持一致

     uname -r #查看内核版本
     apt-get install systemtap linux-image-`uname -r`-dbg linux-headers-`uname -r` #自动安装
    
  3. 下载调试工具 stapxx

     git clone https://github.com/openresty/stapxx.git
     cd ./stapxx
     export PATH=$PWD:$PATH
    
    • 收集 CPU

        ./samples/lj-lua-stacks.sxx --skip-badvars -x PID > a.bt #PID 即 nginx worker 进程 id
        ./fix-lua-bt a.bt > a.bt #美化数据,去除杂音 https://github.com/openresty/openresty-systemtap-toolkit#fix-lua-bt
      
    • 收集内存

        ./samples/sample-bt-leaks.sxx -x PID -v > a.bt #PID 即 nginx worker 进程 id
      
  4. 下载 FlameGraph 将堆栈信息生成火焰图

     tar -zxf FlameGraph.tar.gz
     cd ./FlameGraph
     ./stackcollapse-stap.pl ../stapxx/a.bt > a.cbt
     ./flamegraph.pl --encoding="ISO-8859-1" --title="Lua-land on-CPU flamegraph" a.cbt > a.svg
    
  5. 使用浏览器打开 a.svg 即可得到火焰图

  • CPU 火焰图实例:
    image

  • 内存火焰图实例:
    image

踩过的坑

  • 抓不到 LuaJit 采样数据的情况:WARNING: Found 0 JITted samples.
    可能是因为请求数太少了,见春哥回答
这两个工具都被设计成分析非常繁忙的 nginx worker  进程(CPU 使用率接近 
100%,每秒处理几百、几千、乃至几万个请求)时仍然开销极小(吞吐量极限的损失低于 5%),所以你只应当你的目标 worker 进程的 
CPU 使用率足够高时(至少超过 10% 吧),才能得到比较有意义的结果。它们是通过 OS 内核 tick 或者 cpu clock 探针按 
CPU 时间进行分时采样的,而并不是追踪每一个具体的请求(此种做法开销很大)。 
  • 程序中引用了 lua cjson 包,使用 stapxx 工具抓取时可能会出现链接或函数找不到之类的错误。此时,需要重新编译 cjson

    git clone https://github.com/openresty/lua-cjson.git
    cd  lua-cjson/
    
    • 修改 Makefile,开启调试模式:
    Build defaults
      LUA_VERSION =       5.1
      TARGET =            cjson.so
      PREFIX =            /usr/local/openresty/luajit
      CFLAGS =            -g -Wall -pedantic -fno-inline
      CFLAGS =            -O3 -Wall -pedantic -DNDEBUG
      CJSON_CFLAGS =      -fpic
      CJSON_LDFLAGS =     -shared
      LUA_INCLUDE_DIR ?=   $(PREFIX)/include/luajit-2.1
      LUA_CMODULE_DIR ?=   /usr/local/openresty/lualib
      LUA_MODULE_DIR ?=    $(PREFIX)/share/lua/$(LUA_VERSION)
      LUA_BIN_DIR ?=       $(PREFIX)/bin
    
    • make install
  • 注意看错误信息,出现连接符或未知函数变量之类的错误一般都是对应的组件没有调试信息,重新编译对应的组件,编译时开启调试模式即可。

NSQ 学习总结

概述

NSQ是一个基于Go语言的分布式实时消息平台,其当前最新版本是1.0.0版。可用于大规模系统中的实时消息服务,并且每天能够处理数亿级别的消息,其设计目标是为在分布式环境下运
行的去中心化服务提供一个强大的基础架构。NSQ具有分布式、去中心化的拓扑结构,该结构具有无单点故障、故障容错、高可用性以及能够保证消息的可靠传递的特征。
NSQ非常容易配置和部署,且具有最大的灵活性,支持众多消息协议。另外,官方还提供了拆箱即用Go,Python等多种语言库。如果读者兴趣构建自己的客户端的话,还可以参考官方提供的协议规范。

四个重要组件

  • nsqd:一个负责接收、排队、转发消息到客户端的守护进程

     -broadcast-address string
     注册到 lookupd 的地址,默认是 OS hostname,需要注意的是消费者客户端使用 
     ConnectToNSQLookupd 时,应该设置服务器真实ip,例如:-broadcast-address=192.168.3.101
     否则会连接不到nsqd实例。
     
     -men-queue-size int
     内存队列大小,超过该大小后,会将一部分消息存储到磁盘,为了保证消息的不丢失,可将 -men-queue-size=0 
     那么,节点重启后消息依然存在。
     
     -lookupd-tcp-address value
     nsqlookupd 实例运行地址,可以多次设置不同nsqlookupd 实例运行地址。
    

以上是比较重要的配置项,应该在启动 nsqd 时指定。

  • nsqlookupd:管理拓扑信息并提供最终一致性的发现服务的守护进程

    管理集群下的 nsqd 节点,保证 topic 消息的一致性,一个集群至少布置三个nsqlookupd 实例,保证集群冗余可用性。

     -tcp-address string
     <addr>:<port> to listen on for TCP clients (default "0.0.0.0:4160")
     -http-address string
     <addr>:<port> to listen on for HTTP clients (default "0.0.0.0:4161")
    
  • nsqadmin:一套Web用户界面,可实时查看集群的统计数据和执行各种各样的管理任务

     -lookupd-http-address value
     lookupd HTTP address (may be given multiple times)
     -notification-http-endpoint string
     HTTP endpoint (fully qualified) to which POST notifications of admin actions will be sent
     将 web 管理操作消息发送到指定节点
    
  • utilities:常见基础功能、数据流处理工具,如nsq_stat、nsq_tail、nsq_to_file、nsq_to_http、nsq_to_nsq、to_nsq

主要特性

  • 具有分布式且无单点故障的拓扑结构 支持水平扩展,在无中断情况下能够无缝地添加集群节点低延迟的消息推送
  • 消息数据既可以存储于内存中,也可以存储在磁盘中
  • 支持安全传输层协议(TLS),从而确保了消息传递的安全性
  • 非常易于部署(几乎没有依赖)和配置(所有参数都可以通过命令行进行配置)
  • 支持多种语言的客户端功能库,例如Go,Python等
  • 有强大的集群管理界面,参见nsqadmin

作为高效的分布式消息服务,NSQ实现了合理、智能的权衡,从而使得其能够完全适用于生产环境中,具体内容如下:

  • 支持消息内存队列的大小设置,默认完全持久化(值为0),消息即可持久到磁盘也可以保存在内存中
  • 保证消息至少传递一次,以确保消息可以最终成功发送(消费者客户端回复 FIN(结束)或 REQ(重新排队)分别指示成功或失败。如果客户端没有回复, NSQ 会在设定的时间超时,自动重新排队消息
  • 收到的消息是无序的, 实现了松散订购
  • 发现服务nsqlookupd具有最终一致性,消息最终能够找到所有Topic生产者

数据流模型

  • 数据流程模型

消息

  • Channels 处理

channels

参考

NSQ 官网

ngx_lua 中协程的应用

ngx_lua 中协程的使用

Nginx 在 postconfiguration 阶段执行 lua-nginx-module 模块初始化函数 ngx_http_lua_init, 该函数会调用ngx_http_lua_init_vm 来创建和初始化一个 lua 虚拟机环境,由 lua API luaL_newstate 实现,该接口函数会创建一个协程作为主协程,返回 lua_state(存放堆栈信息,包括后续的请求数据(ngx_http_request_t ),API 注册表等数据都是存放在这里,供 lua 层使用),然后调用 ngx_http_lua_init_globals ,该函数做了两件事:

  • 创建 global_state 数据结构,这个结构体保存全局相关的一些信息,主要是所有需要垃圾回收的对象。
  • 调用 ngx_http_lua_inject_ngx_api,注册各种 Nginx 层面的 API 函数,设置字符串 ngx 为表名,lua 代码中就可以使用 ngx.* 来调用这些 API 了。

另外,还会调用函数 ngx_http_lua_init_registry, ngx_http_lua_ctx_tables 就是在这里注册到 Nginx 内存的(lua 中没有引用的变量会被 GC 掉),用来存放单个请求的 ctx 数据(table),即 ngx.ctx。所以,与 ngx.var 不一样,ngx.ctx 其实是 lua table,只是在 Nginx 内存中添加了引用。也就不难理解,ngx.ctx 生命周期是在单个 location,因为内部跳转时,会清除对应的 ctx table。要想在父子请求间共享 ngx.ctx,可以参考这篇文章,过程大概是,将对应的 ctx 再次插入 ngx_http_lua_ctx_tables,创建新的索引,索引保存在 ngx.var 中,在子请求时取出重新赋值给 ngx.ctx。

master fork worker 进程时,Lua 虚拟机自然也被复制(COW)到了 worker 进程。

请求是在 worker 进程内处理的,处理共分为 11 个阶段,其中在 balancer_by_lua, header_filter, body_filter, log 阶段中,直接在主协程中执行代码,而在 rewrite_by_lua, access_by_lua 和 content_by_lua 阶段中,会创建一个新的协程(boilerplate "light thread" are also called "entry threads")去执行此阶段的 lua 代码。这些新的子协程相互独立,数据隔离,但是共享 global_state

为什么 content 等几个阶段的处理要在子协程里面处理呢?原因可能是 content 等阶段,需要调用 ngx.sleepngx.socket I/O 之类的阻塞操作,使用协程实现异步,提高执行效率。如果放在主协程,这类操作就会阻塞主协程,导致 worker 进程无法处理其它请求。ngx.socket,ngx.sleep 等 API 都会有挂起协程的操作,只能在子协程调用,因此,这些 API 不能在 header_filter 等阶段(主协程)使用。

我们知道,协程是非抢占式的,也就是说只有正在运行的协程只有在显式调用 yield 函数后才会被挂起,因此,同一时间内,只有一个协程在处理(因为 worker 是单线程的),lua 协程还有一个特性,就是子协程优先运行,只有当子协程都被挂起或运行结束才会继续运行父协程。

ngx_lua 协程的调度可以参考下面这张图(图片来自):

06224854_qsha

lua_resume 就是恢复对应的协程运行,在请求处理时,还可能调用 API ngx.thread 来创建 light thread, 可以认为是一种特殊的 lua 协程,没有本质区别,不同的是,它是由 ngx_lua 模块进行调度的(详见下面的 ngx_http_lua_run_thread 源码)。在需要访问第三方服务时,并发执行,可以减少等待处理时间。

ngx.thread.spawn(query_mysql)      -- create thread 1
ngx.thread.spawn(query_memcached)  -- create thread 2
ngx.thread.spawn(query_http)       -- create thread 3

从上面可知,在 ngx_lua 内有三层协程 —— 全局的主协程,请求阶段的子协程,以及用户创建的 light thread,它们分别为父子关系,记住这三个协程代称,后面将会用到。
使用 ngx.exit, ngx.exec, ngx.redirect 可以直接跳出协程,而不用等待子协程处理完成。

ngx.exec("/a/b/c") 			-- 内部跳转,直接从子协程结束,回到主协程
ngx.redirect("/foo", 301) 	-- 重定向,终止的当前请求的处理,即不再处理后续阶段

ngx.exit 可接受多种参数:
ngx.exit(ngx.OK) 		-- 完成当前阶段(退出子协程),继续下一个阶段
ngx.exit(ngx.ERROR)		-- 中断当前请求,报错
ngx.exit(HTTP_STATUS)	-- 结束 content 阶段,继续下个阶段

返回值说明

  • NGX_DONE 处理告一段落(子协程完成),还有协程(light thread)被挂起
  • NGX_AGIN 子协程未结束,继续等待下次唤醒重入
  • NGX_OK 当前阶段处理完成(子协程和运行在内的所有 light thread 都结束)

以上,就是协程在 ngx_lua 模块中的使用与调度。那么 lua 协程到底是个什么神奇的东西呢?

lua 协程

Lua 所支持的协程全称被称作协同式多线程(collaborative multithreading),由用户(lua 虚拟机)自己负责管理,在线程内运行,操作系统是感知不到的。特性就如上面所说,主要两条:

  • 非抢占
  • 同一时间内只有一个协程在运行

是不是很像回调?因此,lua 协程之间不存在资源竞争,也就不需要锁了。严格来说,这种协程只是为了实现异步,而不是并发。而且,lua 是没有线程概念的,lua 语言的定位就是系统嵌入式脚本,由 C 语言调度使用的,在 C 层面创建线程就行了,也使得 lua 更加简单。

主要 API

  • coroutine.create() 创建 coroutine,返回 coroutine,协程创建后是挂起状态
  • coroutine.resume(co, a1, a2 ... an) 重新恢复运行 coroutine,第一个返回值(boolean)表示协程是否正常运行:
    • 第一个返回值为 true:后面的返回值就是 yield 的参数值(b1 ... bn)
    • 第一个返回值为 false:后面的返回值就是错误原因字符串
  • coroutine.yield(b1, b2 ... bn) 挂起当前协程,唤醒后返回对应 resume 的参数(a1 ... an)

有趣的实例

使用 lua 协程实现生产者-消费者问题:

local i = 0

function receive(prod)
	i = i + 1
	local status, value = coroutine.resume(prod, i)
	return value
end

function send(x)
	return coroutine.yield(x)
end

function producer()
	return coroutine.create(function()
    	while true do
        	local x = io.read()
        	local r = send(x)
        	io.write(i,":\r\n")
    	end
	end)
end

function consumer(prod)
	while true do
    	local obtain = receive(prod)
    	if obtain then
        	io.write(obtain, "\n\n")
    	else
       		break
    	end
	end
end

io.write(i+1, ":\r\n")
p = producer()
consumer(p)

从这里可以看到,lua 协程跟线程差别很大,更像是回调,new_thread 只是在内存新建了一个 stack 用于存放新 coroutine 的变量,也称作lua_State

lua 协程与 golang 协程区别

lua 协程与 golang [协程都是协程,但是差别还是挺大的,除了都有自己独立的堆栈空间外,唯一的共同点可能就是前面说过的都是非抢占式的(实际上[Go 1.2 开始加入了简单的抢占式调度逻辑](https://golang.org/doc/go1.2#preemption))。一个最明显的区别是,golang 父子协程是独立而平等的。
golang 调度器实现更复杂,可以将协程分配到多个线程上(GPM 模型),因此,golang 协程是可以并发(并行)的。本质上,lua 协程主要作用是单线程内实现异步非阻塞执行;golang 协程与线程更加类似,用来实现多线程并发执行。

小结

OpenResty 将 lua 嵌入到 Nginx 系统,使 Nginx 拥有了 lua 的能力,大大的扩展了 Nginx 系统的开发灵活性和开发效率。达到了以同步的方式写代码,实现异步功能的效果。不用担心异步开发中的顺序问题,又因为单线程的,也不用担心并发开发中最头痛的竞争问题。比起原生的 Nginx 第三方模块开发,开发更简单,系统也更稳定。

需要注意的是,ngx_lua 并没有提高 Nginx 的并发能力,Nginx worker 本来就是使用回调机制来异步处理多个请求的, 当前请求处理阻塞时,会注册一个事件,然后去处理新的请求,从而避免进程因为某个请求阻塞而干等着(参考知乎问答)。

lua-resty-waf 实践总结

lua-resty-waf 实践总结

lua-resty-waf 是基于 OpenResty 开发的 WAF 项目,其核心的防护规则策略基本与 ModSecurity Core Rule 一致,但是具体实现有所不同。

下面主要分四块阐述其实现功能与原理:

配置模块

系统配置分为两块系统基本配置和规则配置。

规则配置

系统默认提供了基础的防护规则集,规则集文件都在 rules/ 文件夹下,默认有九个文件:

  • 11000_whitelist.json
  • 20000_http_violation.json 违反 HTTP 协议防御
  • 21000_http_anomaly.json 异常 HTPP 请求防御
  • 35000_user_agent.json user_agent 防御
  • 40000_generic_attack.json 一般攻击防御
  • 41000_sqli.json SQL 注入防御
  • 42000_xss.json XSS 攻击防御
  • 90000_custom.json 客户自定义防护规则
  • 99000_scoring.json SCORE 阀值控制

默认规则集的执行顺序也是从上到下的,注意文件的命名规律,后续添加自己的规则集的时候最好遵循这种规范。

规则配置的加载方式有三种:

1)系统启动时默认加载

waf.init 函数默认会去 package.path 前缀路径下的目录 rules/ 下加载数组 global_rulesets 指定的 .json 规则集配置文件。 global_rulesets 默认就是包括了 rules/ 目录下的所有文件名,也是基本的系统参数之一,可以通过 waf:set_option("global_rulesets", {}) 来指定,具体后面会说。

2)使用 load_secrules 加载

可以调用函数 load_secrules 函数从磁盘加载 ModSecurity SecRules 配置文件,参数就是文件所在的绝对路径。需要注意的是还需要调用函数 add_ruleset 将规则集名(文件名称)注册到系统,否则系统是识别不到的。

系统内部会按行将 ModSecurity 规则集文件转换(在 translate 包内)成 waf 对应的规则格式(json)。目前支持四种规则指令:

  • SecRule
  • SecAction
  • SecMarker
  • SecDefaultAction

3)使用 add_ruleset_string 加载

add_ruleset_string 可以直接加载规则集字符串(json)。
用户可以使用这种方式动态的加载自定义规则集合。

在每个阶段执行对应的规则集之前,都会先合并(merge)规则集,主要就是根据规则集名,当规则集名称一样时,用户自己添加的规则集优先级更高。

系统配置

waf.new 函数会初始化一个系统基础参数表,这些参数都可以通过函数 waf.set_option(参数名称, value) 来设置。
一些比较重要的参数说明:

  • _debug 开启 debug 模式,将会打印更详细的日志,默认 false
  • _debug_log_level debug 模式的日志输出级别,默认 ngx.INFO
  • _deny_status 请求被规则拒绝时返回的状态,默认 403
  • _event_log_altered_only 是否只有当请求结束时(DENYDROP)才对外输出日志数据,默认 true
  • _event_log_level 设置日志输出级别,默认 ngx.info
  • _event_log_request_* 可以指定对外日志输出 argumentsbodyheaders 字段,默认都是 false
  • _event_log_target 设置日志对外输出的方式,有三种方式可选(详见日志模块说明),默认 error
  • _mode 系统运行模式,有三种可选值,默认值 INACTIVE
    • SIMULATE 默认值,模拟模式,只会记录规则命中日志,不会执行规则 action
    • INACTIVE 不执行规则引擎,即 不执行 exec 函数
    • ACTIVE 即正常模式
  • _score_threshold 风险最大阀值,当大于该值时,请求将会被 DENY

规则模块

规则模块是 WAF 项目的核心,包括解析和执行两个部分,为了支持类似 ModSecurity 的防护规则,规则配置比较复杂,解析和执行逻辑就更复杂了。

规则解析

规则配置是按规则集(规则数组)的形式被读取的,规则集再分为多个阶段 --- access,header_filter 等。所有的规则集在解析时,会按阶段的维度,添加到对应阶段的规则集数组。

需要注意的是,规则解析时会计算两个特殊的变量值:

  • rule.offset_nomatch 数值,当当前规则匹配失败时,规则遍历迭代器接下来要跳转的规则数,即:当前规则序数 + offset_nomatch = 下条规则的序数
  • rule.offset_match 数值,当当前规则匹配成功时,规则遍历迭代器接下来要跳转的规则数

这两个变量值一般都是 1,即直接进入相邻的下个规则,但是使用 skipCHAIN 都会改变这些值。他们都被放入 table 对象 rule,供规则执行时使用。

规则配置项

  • action 规则行为定义
    • nondisrupt map 数组,非破坏请求行为,定义命中当前规则后的数据行为,可与 disrupt 配合使用,在 disrupt 之前被执行
      • action 指定具体的行为,有九个可选值:
        • setvar 设置 K-V 值,默认将会存放到变量 storage(table 类型),可作为中间缓存,生存周期是当前请求(ngx.ctx)
        • initcol 持久化存储,将指定的值做存放到 redismemcacheddict
        • sleep 调用 ngx.sleep
        • status 设置当前请求被 DENY 后,响应的 HTTP 状态
        • rule_remove_id 临时(内存)移出 data(规则 ID) 对应的规则, 与 ignore_rule 原理相同
        • mode_update 更新 _mode(系统运行模式) 值
      • data 上面 action 行为的参数值,动态数据类型,根据 action,可以是 map,字符串或者数值
        • col 设置存放到 storage 的 一维 key
        • inc 累加,当 value 为数字时,会将数值 value 累加到对应的中间缓存值
        • key 设置存储的二维 key
        • value 设置存储的值,数值或字符串
    • disrupt 字符串,定义具体防护方式,有六个可选值:
      • ACCEPT 结束当前阶段,继续执行下一阶段,目前因为规则都集中在 Acess 阶段,可以认为直接通过(PASS) Waf
      • DENY 拒绝当前请求,默认返回 403,返回状态可以在 nondisrupt.action.status 指定,但是不建议修改
      • DROP 断开当前请求连接,特殊的 444 状态,Nginx 将直接断开连接,而不响应任何字节给客户端
      • IGNORE 忽略该规则的本次命中,继续后面规则的校验
      • SCORE 调整(加减)风险数值(anomaly_score),只有当风险数值大于阀值(由配置 _score_threshold 指定)请求才会被拒绝,与 nondisrupt 配合使用
      • CHAIN 规则链,与其他防护方式的规则组合使用,相当于后续规则的前置条件,类似 and 操作
  • id 数值,唯一的标识当前规则
  • op_negated 否定规则匹配结果,即对匹配结果取反
  • operator 操作符,可选值:
    • REGEX 正则匹配,如果待匹配项为 table,则会逐次匹配,一旦匹配成功就返回,下同
    • REFIND 查找(ngx.re.find
    • EQUALS 相等
    • GREATER 大于
    • LESS 小于
    • EXISTS 在字符串数组 pattern 内存在指定的字符串
    • CONTAINS 在获取的字符串或字符串数组内包含指定的 pattern
    • STR_EXISTS 在指定的字符串内存在
      • STR_MATCH 字符串匹配
    • PM 字符串匹配,可同时与组内所有子串进行匹配(Aho–Corasick 算法
    • CIDR_MATCH IP 地址匹配,当前 IP 是否在pattern IP 数组内
    • DETECT_SQLI SQL 攻击检查
    • DETECT_XSS XSS 攻击检查
    • VERIFY_CC 验证信用卡号是否合法
  • pattern 匹配值,字符串或字符串数组,可以是具体的值或者正则表达式,与待匹配值(根据下面的 vars 计算所得)进行比较
  • skip 数值,指跳过的规则个数(下个规则位置 = 当前规则位置 + skip + 1
  • skip_after 数值,根据规则 id,直接跳转到对应规则
  • vars 对象数组,定义待匹配值的获取方式
    • type 定义数据源,可选值(部分):
      • REQUEST_HEADERS 获取请求头,map 类型
      • METHOD 获取请求方法,字符串类型,例如:GET,POST 等 HTTP 标准方法
      • TX 获取中间缓存值(ctx.storage["TX"]),map 类型
      • URI_ARGS 获取请求参数(table),map 类型
      • QUERY_STRING 获取请求参数,字符串,示例:a=1&b=2
      • REQUEST_BODY 获取请求 body 部分,map 或字符串类型
      • URI 获取请求原始路径部分(ngx.var.uri),字符串
      • REQUEST_URI 获取请求 URL,包括参数部分,字符串,示例:/a/b/c?a=1&b=2
      • COOKIES 请求 cookies(table),map 类型
      • REQUEST_ARGS 对象(map)类型。包括 URI_ARGS, REQUEST_BODY, COOKIES 三项值,但最终转化成一维 map
      • REMOTE_ADDR 获取请求端 IP 地址(remote_addr),字符串
      • HTTP_VERSION 获取 HTTP 协议版本,数值,可选值:2.0, 1.0, 1.1
      • SCORE_THRESHOLD 获取当前风险阀值,数值类型
      • ARGS_COMBINED_SIZE 获取请求参数和请求 body 的字节大小,数值
      • TIME 字符串,格式:“时:分:秒”
      • TIME_EPOCH 获取当前时间戳,精确到秒,数值类型
    • storage 是否跳过缓存,根据 vars 的定义,重新计算待匹配值,存在(not nil)即为 true。因为请求处理过程数据源可能会修改数据源的某些值,导致缓存不一致的情况。需要注意的是这里的缓存,仅仅是存在当前请求内。
    • parse 字符串数组,长度固定为 2,定义从数据源取出待匹配数据集的规则
      • [1] 数组第一项值,字符串,表示从数据源取值的方法,有以下可选值:
        • specific 取出参数指定的值
        • regex 正则匹配,取出所有正则匹配参数的数据集
        • keys 取出数据源中所有的 key,作为数据集
        • values 取出数据源中所有的 value,作为数据集
        • all 将整个数据源作为数据集
      • [2] 数组第二项,字符串,取值方法(函数)的参数,注意,不能缺省,可设为 1,表示无意义
    • unconditional 指示当前规则将一定会被命中
  • opts
    • transform 字符串或字符串数组,对上述数据源进行转换的方式,有如下可选值(部分):
      • uri_decode uri 解码
      • lowercase 转成小写
      • md5 计算 md5
    • nolog 不记录该条规则的命中日志
  • logdata 设置规则命中日志字段 logdata 的值,可以使用具体的值或者变量(%{value}),实例:"logdata" : "%{TX.anomaly_score}"

规则执行

规则是在 waf.exec 函数内执行的,每个阶段只会执行当前阶段对应的规则集,及规则集里面的规则。需要注意的是 CHAIN 规则的执行逻辑,这种类型的规则会组成一个规则链,规则链内的规则是 and 关系,即规则匹配失败,就会跳过当前整个规则链。规则链是怎么组成的呢?当遇到非 CHAIN 规则时,就会计算成一个规则链。

下面使用 C 代表 CHAIN 规则,X 代表非 CHAIN 规则,有如下规则集:

C C C X X C X

将生成两条规则链:

  • CCCX
  • CX

日志模块

规则命中后,都会将命中(匹配成功)规则日志记录到日志输出缓存数组,除了 CHAIN 类型的规则,也就是说规则链命中后只会记录一条日志。
规则日志可以在阶段结束时输出,也可以在请求结束时,汇总一起输出。

日志是以 json 格式输出的,包括以下字段:

  • timestamp 当前时间戳(秒)
  • client 请求客户端地址(remote_addr
  • method 请求方法
  • uri 请求路径
  • alerts 数组,规则命中记录
    • id 命中的规则 id
    • msg 规则说明
    • match 规则操作符(operator)函数返回的第二个值,这个值非常灵活,可以是字符串,数字或者数组
    • logdata 规则 logdata 配置指定的输出项,比如当前阀值等
  • id 唯一的标记当前请求 ID,首次调用函数 waf.new 时随机生成
  • id 唯一的标记当前请求 ID,首次调用函数 waf.new 时随机生成
  • uri_args map 类型,请求参数;可选,由参数 _event_log_request_arguments 控制
  • request_headers map 类型,请求头;可选,由参数 _event_log_request_headers 控制
  • request_body map 或字符串 类型,请求体;可选,由参数 _event_log_request_body 控制
  • ngx 数组,可选,由参数 _event_log_ngx_vars 指定的变量(ngx.var)值

日志对外输出方式由 _event_log_target 设置,有三种输出方式:

  • error 直接使用 ngx.log 输出
  • file 输出到参数 _event_log_target_path 指定的文件内
  • socket 使用库 resty.log 输出到指定的日志服务器,需要配置相关参数,初始化 log.socket 客户端

特别说明

虽然,系统支持通过函数 load_secrules 直接加载 ModSecurity 规则集文件,但是,最好别这么干,因为自动转换过程交繁琐,不小心就容易出错。

Nginx(WAF 项目)配置优化初探

proxy buffer

引用:

buffer工作原理
首先第一个概念是所有的这些proxy buffer参数是作用到每一个请求的。每一个请求会安按照参数的配置获得自己的buffer。proxy buffer不是global而是per request的。

proxy_buffering 是为了开启response buffering of the proxied server,开启后 proxy_buffers 和proxy_busy_buffers_size 参数才会起作用。

无论proxy_buffering是否开启,proxy_buffer_size(main buffer)都是工作的,proxy_buffer_size所设置的buffer_size的作用是用来存储upstream端response的header。

参考目前大多数的开源 WAF 项目,并没有对这块有特别的调整,即都是使用的默认值

默认 proxy_buffering 是开启的,开启 buffer 能有效的提高请求交互效率,特别是对于客户源站响应比较慢的情况

综上,这块参数调整如下:

proxy_buffer_size 8k;
proxy_buffers 8 32k;
proxy_busy_buffers_size 64k;
proxy_buffering on;

基础优化

worker_processes 启动 worker 进程数,为了减少上下文切换,最好等于系统内核数,提高处理效率。auto 将自动设置成内核数。
worker_cpu_affinity 将进程绑定到固定的内核。auto 自动绑定到可用的内核。

注意,如果是容器部署的话可能会有问题,因为默认情况下使用主机 CPU 资源是不受限制的,当然你可以启动的时候指定 CPU 使用数量限制。

worker_processes auto;
worker_cpu_affinity auto;

正则优化

开启 pcre_jit

pcre_jit on;

但是,这只是对配置解析时已知的正则开启 “just-in-time compilation” (PCRE JIT)。

如果要在 ngx_lua 模块中启用 PCRE JIT,需要源码编译 pcre 时,指定 --enable-jit,具体见春哥回答

具体编译命令严格按照这种方式编译,完成后,实用工具 ngx-pcrejit(./ngx-pcrejit -p 7566),验证 ngx_lua 是否启用 PCRE JIT

tcp 优化

默认 Nginx 不会立即将足够小的数据包发送出去(Nagle 算法),而是最多等待 0.2s,待数据包达到足够大时才一起发送,类似缓存机制,可以提高网络效率。

tcp_nopush 优化一次发送的数据量,需要与 sendfile 配合使用。

tcp_nodelay 禁用延迟发送(Nagle 算法)。

两者看似矛盾,但是可以一起使用,最终的效果是先填满包,再尽快发送。

tcp_nopush         on;
tcp_nodelay        on;
sendfile           on;

timeout 调整

  • proxy_connect_timeout 默认 60 s,与代理服务建立连接的超时,一般应不超过 75 s。为了及时响应请求,提高请求处理效率,不应设置太大。
  • proxy_read_timeout 默认 60 s,从代理服务读取响应超时,是指两次读取操作之间,而不是整个响应。

配置优化如下:

proxy_connect_timeout 65; 
proxy_read_timeout    65;

启用 proxy_cache(待定)

将重复请求的资源缓存到我们(Razor)这边能有效的较少源站压力,且提高客户端响应速度。但是,会带来一定副作用,比如,资源无法及时更新;缓存的资源不够精确,与请求需要的资源不符合等等问题。所以,建议根据源站实际情况,作为 razor 配置项,由用户(管理员)来配置

proxy_cache_path 指定缓存保存路径
expires 设置浏览器缓存失效时间

配置参考:

proxy_cache_path  /data/nginx/cache levels=1:2 keys_zone=one:100m inactive=1d max_size=1g;
proxy_cache_key   $host$uri$is_args$args;

server {
    location / {
        ...
        proxy_cache               one;
        proxy_cache_valid         200 304 10m;
        proxy_cache_valid 301 302 1h;
        proxy_cache_lock          on;
        proxy_cache_lock_timeout  5s;
        #proxy_cache_valid any    1m;
        expires 12h;
        ...
    }

gzip

目前,如果客户端支持压缩编码(带有请求头:Accept-Encoding,浏览器默认都支持),而且用户源站也支持对应的压缩编码格式,则返回的响应就是已经被压缩编码的。

如果,Razor 开启 zip (gzip on;)有效的唯一场景是,客户源站不支持压缩编码格式,我们(Razor)来帮它压缩。因为,客户源站一般都是支持压缩编码的,而且,在我们这边压缩会消耗 CPU,且只能节省我们到客户端的带宽资源,并不划算。而且参考其他 WAF 实现,也都没有开启 gzip。

至于 https://www.oschina.net/question/17_3971 这里说的所有请求,我们这边代理到客户源站时,都添加头(Accept-Encoding),即默认所有的请求客户端都是支持压缩格式的。我认为不妥,虽然目前主流浏览器都是支持解压的,但是,我们要对接各种源站,请求客户端也很多样,万一客户端不支持解压,就可能出现乱码的情况,作为第三方 WAF,不宜替源站作这种决策。

基于以上,这块保持现状,不启用 gzip

reuseport 启用

reuseport 开启端口复用。详见介绍 blog

NGINX 1.9.1 发布版本中引入了一个新的特性 —— 允许套接字端口共享,该特性适用于大部分最新版本的操作系统,其中也包括 DragonFly BSD 和内核 3.9 以后的 Linux 操作系统。套接字端口共享选项允许多个套接字监听同一个绑定的网络地址和端口,这样一来内核就可以将外部的请求连接负载均衡到这些套接字上来。

可以减少多个 Worker 进程接受新连接的竞争,提高多核机器的系统性能。启用后, accept_mutex 配置会失效。

使用示例:

http {
     server {
          listen 80 reuseport;
          server_name  localhost;
          # ...
     }
}

stream {
     server {
          listen 12345 reuseport;
          # ...
     }
}

ab 测试工具试用记录

测试命令

ab -n100000 -c600 -H 'Host: httpbin.org' http://192.168.3.22:9001/test_limit > razor-1.0_abtest.out

-n 总请求数
-c 并发请求数
-H 请求头,可使用多次,可重复覆盖

并且将测试报告重定向到文件 razor-1.0_abtest.out

测试报告

  • Concurrency Level 并发用户(客户端)数,由 -c 指定。
  • Requests per second 服务器吞吐率,平均每秒处理请求数
  • Time per request 用户(客户端)平均请求等待时间,等于:Time taken for tests / (Complete requests /Concurrency Level)
  • Time per request(mean, across all concurrent requests) 服务器平均请求处理时间,等于:Time taken for tests / Complete requests
  • Percentage of the requests served within a certain time (ms) 用于描述每个请求处理时间的分布情况,比如,在下面测试结果中,50% 请求的处理时间都不超过 289ms,90% 的请求都不超过 3336ms。注意这里的处理时间,是指前面的 Time per request,即对单个用户(客户端)
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking 192.168.3.22 (be patient)


Server Software:        openresty/1.11.2.2
Server Hostname:        192.168.3.22
Server Port:            9001

Document Path:          /test_limit
Document Length:        233 bytes

Concurrency Level:      600
Time taken for tests:   218.767 seconds
Complete requests:      100000
Failed requests:        99937
   (Connect: 0, Receive: 0, Length: 99937, Exceptions: 0)
Non-2xx responses:      100000
Total transferred:      16519656 bytes
HTML transferred:       1813545 bytes
Requests per second:    457.11 [#/sec] (mean)
Time per request:       1312.604 [ms] (mean)
Time per request:       2.188 [ms] (mean, across all concurrent requests)
Transfer rate:          73.74 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    2  72.2      1    3010
Processing:    11 1295 1913.9    289    9323
Waiting:        6  961 1646.4    202    6593
Total:         11 1297 1916.2    289    9485

Percentage of the requests served within a certain time (ms)
  50%    289
  66%    328
  75%   3197
  80%   3242
  90%   3336
  95%   6273
  98%   6314
  99%   6336
 100%   9485 (longest request)

分布式锁的实现及原理

分布式锁的实现及原理

概述

锁是在执行多线程时用于强行限制资源访问的同步机制,在分布式系统场景下,为了使多个进程(实例)对共享资源的读写同步,保证数据的最终一致性,而引入了分布式锁。

分布式锁应具备以下特点:

  • 互斥性:任意时刻,同一个锁,只有一个进程能持有
  • 安全性:避免死锁,当进程没有主动释放锁(进程崩溃退出),保证其他进程能够加锁
  • 可用性:当提供锁的服务节点故障(宕机)时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。
  • 对称性:对同一个锁,加锁和解锁必须是同一个进程,即不能把其他进程持有的锁给释放了

可以基于数据库,缓存,中间件实现分布式锁,比较主流的是使用 Redis 或 Etcd (java 可能更多的是用 ZooKeeper) 来实现,当然也可以基于数据库等支持事务的中间件实现,但相对不够健壮,也不够安全,一般不推荐,这里就不展开说明了。结合以上的四个特点,下面将深入讨论这两种方案的实现方式与原理。

实现方案

基于 Etcd

Ectd 是一个高可用的键值存储系统,具体以下特点:

  • 简单:使用 Go 语言编写,部署简单;
  • 安全:可选 SSL 证书认证;
  • 快速:在保证强一致性的同时,读写性能优秀;
  • 可靠:采用 Raft 算法实现分布式系统数据的高可用性和强一致性。

重要的是,etcd 支持以下功能,正是依赖这些功能来实现分布式锁的:

  • Lease 机制:即租约机制(TTL,Time To Live),Etcd 可以为存储的 KV 对设置租约,当租约到期,KV 将失效删除;同时也支持续约,即 KeepAlive
  • Revision 机制:每个 key 带有一个 Revision 属性值,etcd 每进行一次事务对应的全局 Revision 值都会加一,因此每个 key 对应的 Revision 属性值都是全局唯一的。通过比较 Revision 的大小就可以知道进行写操作的顺序
    在实现分布式锁时,多个程序同时抢锁,根据 Revision 值大小依次获得锁,可以避免 “羊群效应” (也称 “惊群效应”),实现公平锁。
  • Prefix 机制:即前缀机制,也称目录机制。可以根据前缀(目录)获取该目录下所有的 key 及对应的属性(包括 key, value 以及 revision 等)。
  • Watch 机制:即监听机制,Watch 机制支持 Watch 某个固定的 key,也支持 Watch 一个目录(前缀机制),当被 Watch 的 key 或目录发生变化,客户端将收到通知。

实现过程

就实现过程来说,跟“买房摇号”很相似。

1、定义一个 key 目录(如:/xxx/lock/)用于存放客户端(进程)的操作 ID。类似申请买房的号码牌;
2、客户端先 put key /xxx/lock/id,id 是全局唯一的,可以使用 UUID,并设置过期时间 TTL,防止死锁。记下返回的 RevisionR。类似你拿到一个选房序号,并规定了进去选房时间,超时还没有选中,就失效了;
3、get 目录 /xxx/lock/ 下所有的 key 及对应的 Revision 值,与上一步返回的 Revision 值进行比较:

  • 如果当前返回的 Revision 值 R 小于或等于目录下所有的 key 对应的 Revision,则当前客户端获取到了锁。类似你是排在第一个选房的,不用等了,直接选房就是;
  • 否则,记下所有比 R 小的 Revision 对应的 key,Watch /xxx/lock/。盯紧大屏幕,等待排你前面的人选房;

4、当所有靠前的 key 都被删除之后,则意味着的客户端获取到了锁。类似前面的人都选好房或者弃权了,终于轮到你选房了!

但是,这里有两个问题,也是分布式锁实现方案之间的重要区别

  • 客户端拿到锁后,在合法时间内(过期时间前)没有释放锁(工作没有做完),会导致不同客户端同时拿到或释放同一个锁的情况;
  • 当锁依赖的中间件服务是多节点集群部署时,怎么保证新节点与故障节点的数据一致性?

Etcd 和 Redis 给出了不同的答案,后面将会对比阐述。

基于 Redis

Redis 可以使用 SET 命令

SET KEY VALUE NX PX 100

这里的 KEY 是同一个,VALUE 最好是全局唯一的(原因后面会知道),如果执行成功,则意味着获取到了锁;如果失败则循环尝试,类似自旋锁的获取过程,但这里不需要太频繁,可以 Sleep 一段时间,还可以对续约次数进行限制。

看起来,这个实现方案比 Etcd 实现要简单很多,区别就是,Etcd 实现的是公平锁。但是,结合上面提的两个问题,就会发现,这只是一个简单的实现,并没有给出问题的答案。

方案对比

对于那两个问题,Etcd 与 Redis 给出了不同的答案。

1)问题一,租约(比工作完成时间)提前到期的问题。

Etcd

本身支持 KeepAlive 机制,来进行租约续期,在 put 操作成功之后,对 KEY 设置 KeepAlive 即可。Etcd 的租约是与 KV 单独分开的,有自己的租约 ID,所以实现起来并不复杂。

Redis

Redis 本身没有 KeepAlive 的机制,所以,只能客户端自己模拟实现:

1、首先客户端 SET 时,VALUE 要是全局唯一的,也可以使用 UUID,并记下这个 VALUE 值;
2、使用单独的线(协程)程 GET KEY,并对比 VALUE 值是否与前面的记录的值相同,如果相同,说明当前客户端仍然持有锁,通过 EXPIRE 更新 KEY 失效时间;
3、当工作完程,释放锁(删除 KEY)之前,先关闭这个续约线程,并且删除 KEY 之前也要比较 VALUE 是否与本客户端设置的一样,防止释放别的客户端持有的锁;

两种续约方式,基本原理,效果都类似,Etcd 更优雅一些。

2)问题二,保证节点数据一致性的问题。

这是分布式架构中的基础也是经典问题,一般分布式系统中为了保证分区容错性,节点(数据)都是主备的。
对 etcd 主节点写入时,要保证所有主从节点都写入成功,才会返回写入完成,也即是主从同步复制,这样就可以保证主从节点的数据强一致性。Redis 由于历史原因,刚开始都是单机部署的,后面才支持集群部署,为了保证性能,主从使用异步复制,因此,并不保证节点间数据的强一致性。

Redis 集群一般有多个 Master 节点,数据负载到不同的 Master 节点上(数据分片)。这种场景下,实现分布式锁时更加麻烦,因为,为了保证当前只会出现一把锁,就必须要设置 KV 到所有 Master 节点才行(实际只要超过一半就行)。为了解决这个问题,Redis 作者基于 Redis 设计实现了 Redlock 算法,实现过程过程如下:

1、得到当前的时间,微妙单位。
2、尝试顺序地在 5 个实例上申请锁,当然需要使用相同的 key 和 random value,这里一个 client 需要合理设置与 master 节点沟通的 timeout 大小,避免长时间和一个 fail 了的节点浪费时间。
3、当 client 在大于等于 3 个 master 上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
4、如果锁申请到了,那么锁真正的 lock validity time 应该是 origin(lock validity time) - 申请锁期间流逝的时间。
5、如果 client 申请锁失败了,那么它就会在少部分申请成功锁的 master 节点上执行释放锁的操作,重置状态。

当然,也可以取个巧,客户端约定在同一个 Master 申请/释放 锁,但是,这样客户端处理起来又太累赘了,不够通用。

小结

通过深入分析分布式锁的实现,可以发现,由于 Redis 主要是用于数据读写缓存,需要优先保证大流量场景下读写性能,分区容错性以及服务可用性是最重要的;而 Etcd 主要用于配置分发,必须要保证数据强一致性以及分区容错性。
这也就是 CAP 理论实例,根据系统应用场景来做取舍,选择最合适的实现方案。对于分布式锁的使用场景来说,使用 Etcd 来实现分布式锁,要更加的简洁,也更加安全。

虽然 Etcd (V3) 官方已经支持了分布式锁的 API 实现,为了理解的更深刻,我自己也造了个轮子https://github.com/jinhailang/rainforest/tree/master/ivy。此外,因为支持事务(基于软件事务内存机制(STM)实现),所以,还可以使用事务来实现分布式锁,具体参考NewSTM 使用实例

PS: 事务也是非常常见而且非常重要的概念,我也在文章 #48 较详细的阐述了事务的应用及原理。

参考

MongoDB 学习笔记

概述

MongoDB 是一个基于分布式文件存储的数据库。由 C++ 语言编写。旨在为 WEB 应
用提供可扩展的高性能数据存储解决方案。
MongoDB 是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能
最丰富,最像关系数据库的。

NoSQL

NoSQL一词最早出现于1998年,是Carlo Strozzi开发的一个轻量、开源、不提供
SQL功能的关系数据库。
2009年,Last.fm的Johan Oskarsson发起了一次关于分布式开源数据库的讨论
[2],来自Rackspace的Eric Evans再次提出了NoSQL的概念,这时的NoSQL主要指
非关系型、分布式、不提供ACID的数据库设计模式。

NoSQL,指的是非关系型的数据库。NoSQL有时也称作Not Only SQL的缩写,是对不
同于传统的关系型数据库的数据库管理系统的统称。
NoSQL用于超大规模数据的存储。(例如谷歌或Facebook每天为他们的用户收集万亿
比特的数据)。这些类型的数据存储不需要固定的模式,无需多余操作就可以横向扩展。
RDBMS vs NoSQL
  • RDBMS
    • 高度组织化结构化数据
    • 结构化查询语言(SQL) (SQL)
    • 数据和关系都存储在单独的表中。
    • 数据操纵语言,数据定义语言
    • 严格的一致性
    • 基础事务
  • NoSQL
    • 代表着不仅仅是SQL
    • 没有声明性查询语言
    • 没有预定义的模式
    • 键 - 值对存储,列存储,文档存储,图形数据库
    • 最终一致性,而非ACID属性
    • 非结构化和不可预知的数据
    • CAP定理
    • 高性能,高可用性和可伸缩性
CAP定理(CAP theorem)

在计算机科学中, CAP定理(CAP theorem), 又被称作 布鲁尔定理(Brewer's theorem), 它指出对于一个分布式计算系统来说,不可能同时满足以下三点:

  • 一致性(Consistence) (等同于所有节点访问同一份最新的数据副本)
  • 可用性(Availability)(每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据)
  • 分区容错性(Network partitioning)(以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。)
<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCN2VWYlJkam1WWjg/preview" width="640" height="480"></iframe>

组件

  • mongod:数据库节点

      mkdir -p /data/db
      cd ./bin/ && sudo ./mongod
    
    • --bind_ip:绑定服务IP,若绑定127.0.0.1,则只能本机访问,不指定默认本地所有IP
    • --port:指定服务端口号,默认端口 27017
    • --auth:启用安全验证(连接数据库需要账号密码)
    • --logpath:定MongoDB日志文件,注意是指定文件不是目录
    • --dbpath:指定数据库路径
    • --serviceName:指定服务名称
    • --replSet:设置副本集(冗余复制)
  • mongo:MongoDB后台管理 Shell

      sudo ./mongo
      OR
      sudo ./mongo 127.0.0.1/admin -supper -p123abc
      
      - 创建新用户
      > use m_test
      > db.createUser({
      	user: "mtest",
      	pwd: "123abc",
      	roles: [
     		{ role: "readWrite", db: "admin" },
     		{ role: "read", db: "test" },
     		{ role: "read", db: "m_test" },
      	]
      	})
      	
      	注意:roles 定义了操作权限,`验证数据库和权限数据库是分离的`,这里的账号密码可以连接数据库 `m_test`,但连接 `admin`或其他数据库会报验证失败
      
      - 查看当前操作的数据库
      > db
      test
      
      - 插入一些简单的记录并查找它:
      > db.runoob.insert({x:10})
      WriteResult({ "nInserted" : 1 })
      > db.runoob.find()
      { "_id" : ObjectId("5604ff74a274a611b0c990aa"), "x" : 10 }
    
  • MongoDb web:用户界面

MongoDB 提供了简单的 HTTP 用户界面。 如果你想启用该功能,需要在启动的时候指定参数 --rest

./mongod --dbpath=/data/db --rest

MongoDB 的 Web 界面访问端口比服务的端口多1000。默认:http://localhost:28017

使用

  • 常用操作

    • use DATABASE_NAME 切换/创建数据库

    • db.dropDatabase() 删除当前数据库

    • db.collection.drop() 删除集合 collection

    • show dbs 查看当前所有数据库

    • show collections 查看当前数据库的所有集合

    • db.COLLECTION_NAME.insert(document) 插入文档

    • db.COLLECTION_NAME.find() 查询集合的的所有文档

        > db.rundb.find().pretty()
        {
        	"_id" : ObjectId("58ef5da28b88263cdf51ecea"),
        	"user" : "jin",
        	"sex" : "man",
        	"age" : 24
        	"by" : "sb",
        	"tags" :[
        	"a",
        	"b",
        	"c"
        	]
        }
        
        与操作
        > db.col.find({"sex":"man","age":24}).pretty()
        
        或操作
        > db.col.find({$or:[{"sex":"man"},{"age":24}]}).pretty()
      
    • 条件操作符

      • (>) 大于 - $gt
      • (<) 小于 - $lt
      • (>=) 大于等于 - $gte
      • (<= ) 小于等于 - $lte

      示例:db.rundb.find({"age" : {$gt : 18}})

    • db.COLLECTION_NAME.find().limit(NUMBER) 读取指定数量的数据记录

    • db.COLLECTION_NAME.ensureIndex({KEY:1}) 建索引,1:升序 -1:降序

      示例:> db.rundb.ensureIndex({"title":1,"description":-1})

    • db.COLLECTION_NAME.find().sort({KEY:1}) 排序

    • aggregate() 聚合(group)

  • 高级操作

    • explain() 查询分析

    • 原子操作 mongodb不支持事务,不要要求mongodb保证数据的完整性

    • 全文检索

        建立索引
        > db.rundb.ensureIndex({user:"text"})
        查找
        >db.rundb.find({$text:{$search:"ji"}})
      
    • 正则表达式

      > db.rundb.find({user:{$regex:"ji"}})

数据操作

复制(副本集)

MongoDB复制是将数据同步在多个服务器的过程。
复制提供了数据的冗余备份,并在多个服务器上存储数据副本,提高了数据的可用性。
主节点记录在其上的所有操作oplog,从节点定期轮询主节点获取这些操作,然后对自己的数据副本执行这些操作,从而保证从节点的数据与主节点一致。MongoDB的副本集与我们常见的主从有所不同,主从在主机宕机后所有服务将停止,而副本集在主机宕机后,副本会接管主节点成为主节点,不会出现宕机的情况。

MongoDB复制结构图如下所示:

<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCdEVDSkZkVHpSUGs/preview" width="640" height="480"></iframe>
启动主节点:
mongod --port 27017 --dbpath "./data" --replSet rs0

在客户端操作
启动副本集:
> rs.initiate()

副本集添加成员:
> rs.add(HOST_NAME:PORT)
分片

在Mongodb里面存在另一种集群,就是分片技术,可以满足MongoDB数据量大量增长的需求。
当MongoDB存储海量的数据时,一台机器可能不足以存储数据,也可能不足以提供可接受的读写吞吐量。这时,我们就可以通过在多台机器上分割数据,使得数据库系统能存储和处理更多的数据。

MongoDB中使用分片集群结构分布:

<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCZl9uNy03UjBBbE0/preview" width="640" height="480"></iframe>
  • Shard
    用于存储实际的数据块,实际生产环境中一个shard server角色可由几台机器组replica set承担,防止主机单点故障
  • Config Server
    mongod实例,存储了整个 ClusterMetadata,其中包括 chunk信息。
  • Query Routers
    前端路由,客户端由此接入,且让整个集群看上去像单一数据库,前端应用可以透明使用。

操作步骤:

	1)mkdir -p /data/shard/log && mkdir -p /data/shard/s0 && mkdir -p /data/shard/s1 ...
	
	2)./mongod --port 27020 --dbpath=/data/shard/s0 --logpath=/data/shard/log/s0.log --logappend --fork
	...
	./mongod --port 27023 --dbpath=/data/shard/s1 --logpath=/data/shard/log/s1.log --logappend --fork
	
	3)./mongod --port 27100
	
	4)./mongos --port 40000 --configdb localhost:27100 --fork --logpath=/data/shard/log/route.log --chunkSize 500
		* chunk的大小的,单位是MB,默认大小为200MB.
		
	5)使用MongoDB Shell登录到mongos,添加Shard节点
		./mongo admin --port 40000
		> db.runCommand({ addshard:"localhost:27020" })
		> db.runCommand({ enablesharding:"test" }) #设置分片存储的数据库
	6)直接按照连接普通的mongo数据库那样,将数据库连接接入接口 40000
备份与恢复

在Mongodb中我们使用mongodump命令来备份MongoDB数据。该命令可以导出所有数据到指定目录中。
mongodump命令可以通过参数指定导出的数据量级转存的服务器。

备份:
> mongodump -h dbhost -d dbname -o dbdirectory

示例:> mongodump -h 127.0.0.1:27017 -d test -o /data/dump

恢复:
> mongorestore -h <hostname><:port> -d dbname <path>

管理工具

RockMongo是PHP5写的一个MongoDB管理工具。
通过 Rockmongo 你可以管理 MongoDB服务,数据库,集合,文档,索引等等。
它提供了非常人性化的操作。类似 phpMyAdmin(PHP开发的MySql管理工具)

Rockmongo 下载地址:http://rockmongo.com/downloads

参考

golang 指针引用使用场景

指针和引用的使用场景关键在于两个字 延后,当我们需要延后修改 A 变量,可以先让变量 B 指向 A ,然后操作 B 就是操作 B 了。

以下为伪代码:

B:=&A
B=xxx

但是,golang 在赋值时,需要注意是引用还是拷贝,对于数组,切片,channel,map 默认是引用,即:

mp := make(map[string]string)
mp["a"] = "aaa"

mm := mp

mm["a"] = "sbsb"
mm["b"] = "hahahha"

fmt.Printf("mp: %v", mp)   // mp: map[a:sbsb b:hahahha]

但是,当 slice 使用 append 添加元素时需要额外注意,可能踩入一个著名的坑:

mp := make([]string, 1, 1) 
mp[0] = "aaa"

mm := &mp

mm = append(*mm, "sbsb")
mm = append(*mm, "hahahha")

fmt.Printf("mp: %v\r\n", mp) // mp: [aaa]

思考上面的代码,为什么 slice 变量 mp 里面值没有被修改呢?

这是因为当使用 append,超过 slice 容量时,会自动重新分配内存大小(扩容),可能是 2 倍或 1/4 的扩容,具体自行研究。这里扩容后 mm 地址会改变,因此 mpmm 指向的地址块也就不一样了。

这种场景下,如何仍然保持 mmmp 指向的地址一致呢?可以使用 引用,上面的代码作如下修改即可:

mp := make([]string, 1, 1)
mp[0] = "aaa"

mm := &mp

*mm = append(*mm, "sbsb")
*mm = append(*mm, "hahahha")

fmt.Printf("mp: %v\r\n", mp)

合理的使用指针和引用,才能写出更高效,优雅的代码。

思考如下问题:

flag 包是用来解析程序参数输入的,我们可以自定义解析函数,例如:

package main

import (
	"flag"
	"fmt"
	"strings"
)

type ary []string

func (ar *ary) String() string {

	return fmt.Sprintf("%v", *ar)
}

func (ar *ary) Set(v string) error {
	fmt.Printf("v: %s\r\n", v)

	*ar = ary(strings.Split(v, ","))

	return nil
}

func aryVar(name, value string, usage string) *ary {
	f := ary(strings.Split(value, ","))

	flag.CommandLine.Var(&f, name, usage)

	return &f
}

func main() {
	temp := aryVar("g", "1", "spilt ,")

	flag.Parse()

	fmt.Printf("temp: %v\r\n", *temp)
}

以上代码中为什么要使用引用,返回指针呢?

这块代码还是包括了接口的使用,仔细思考,受益颇多。

json.Unmarshal 奇怪的坑

encoding/json 是 Go 代码经常使用的包,但是,可能很多人都会忽略下面这段说明:

To unmarshal JSON into an interface value, Unmarshal stores one of these in the interface value:

bool, for JSON booleans
float64, for JSON numbers
string, for JSON strings
[]interface{}, for JSON arrays
map[string]interface{}, for JSON objects
nil for JSON null

当 json 解码到 interface 类型的变量值时,会将 JSON numbers(实质是 string 类型,表示整数或浮点数数字字符串)都当作类型 float64 存储。

试想以下代码输出?

package main

import (
	"fmt"
	"encoding/json"
	"reflect"
)

func main() {
	s := `{"name":"test","it":1021,"timestamp":1557822591000,"mmp":{"a":"ax","b":999999}}`
	type st struct{
		Name string
		Timestamp interface{}
		Mmp interface{}
		It interface{}
	}
	var tmp st
	
	err := json.Unmarshal([]byte(s),&tmp)
	fmt.Printf("tmp: %+v, err: %v\r\n",tmp, err)
		
	fmt.Printf("%v, %v\r\n",reflect.TypeOf(tmp.Timestamp), tmp.Timestamp)
}


tmp: {Name:test Timestamp:1.557822591e+12 Mmp:map[a:ax b:999999] It:1021}, err: <nil>
float64, 1.557822591e+12

完成 Json 解码后,Timestamp 类型为 float64。这显然是无法让人接受的,就这里来说,时间戳应该是 int64 才对。目前,有两个解决办法:

  1. 显示声明类型

避免使用 interface,而是直接静态类型指定,在大多数情况下,Json 字符串结构都是已知的,静态的。
上面的场景,就可以将时间戳属性定义为 Timestamp int64

  1. 使用函数 UseNumber()

func (*Decoder) UseNumber() 使解码器将数字作为 json.Number 类型,
而不 float64 解码到 interface 变量。

    ...

    ds := json.NewDecoder(strings.NewReader(s))
	ds.UseNumber()
	err := ds.Decode(&tmp)
	
	fmt.Printf("tmp: %+v, err: %v\r\n",tmp, err)
		
	//rf, _ := strconv.ParseFloat("123.90",64)
	fmt.Printf("%v, %v\r\n",reflect.TypeOf(tmp.Timestamp), tmp.Timestamp)


tmp: {Name:test Timestamp:1557822591000 Mmp:map[a:ax b:999999] It:1021}, err: <nil>
json.Number, 1557822591000

可以看到,json.Number 其实就是字符串类型:

type Number string

因此,这里其实就是保留原始字符串,延迟解析。在需要的时候,使用提供的函数 Float64(), Int64() 等转化成对应的类型,其实,这些函数的实现就是使用 strconv 包将字符串转化成整型或浮点型。

但是,这里引入了一个新的类型 json.Number,会侵入到别的无关的代码中,也就是说,可能会导致,在其它模块,不得不在类型判断时,加入 json.Number case。这种耦合是比较让人难受的。

遗憾的是,目前看来,只有这两种方式了,虽然都不够优雅。

这是个很奇怪的问题,因为技术上来说,将数字字符串分别解析为整型或浮点型并不难实现,Go 编译器就很好的实现了(想想 x:=100x:=100.0 的区别);
而且,如果 Json 数字的含义是整型,默认却解析成 float64 就会有精度丢失的问题,因为 int64 比 float64 表示的范围更大。

Go issues 找了下,也并没有看到合理的解释,难道只是为了实现方便,偷了个懒?真是个奇怪的坑!

相关 issues:

补充

感谢Go 论坛网友 @H12 的指正:

float64 的表示范围显然远大于int64/uint64 (ref. math. MaxFloat64, math. MaxInt64),只是在表示整数时有可能有精度损失。
JSON (json.org)并没有规定number的精度和大小范围,所以即使用uint64或int64,在解析整数时仍然存在溢出的可能。这时如果用float64来解析,因为表示范围大于int64,溢出的可能性更小,所以更安全(精度损失总比溢出强)。如果追求完全不溢出,可以用 type Number string。

Go 中的 float64 其实等同于 double 类型:

1bit(符号位) 11bits(指数位) 52bits(尾数位)

范围是 -2^1024 ~ +2^1024,也即 -1.79E+308 ~ +1.79E+308。精度是由尾数的位数来决定的。浮点数在内存中是按科学计数法来存储的,其整数部分始终是一个隐含着的“1”,由于它是不变的,故不能对精度造成影响:2^52 = 4503599627370496,一共16位,因此,double的精度为15~16位。而 int64 等同于 long, 占8个字节,表示范围: -9223372036854775808 ~ 9223372036854775807

因此,出现这个坑的原因,是设计上的取舍,为了保证 Json 数字解析安全(不溢出),只能牺牲精度。

OpenResty 转发请求超时问题分析报告

问题描述

某产品对接最新版 waf proxy(支持 http2.0)后,上传图片超时,其他请求正常。

问题分析

从 Nginx 的 error 和 access 日志发现,请求转发到 WAF 超时(超过 60 秒),导致客户端主动断开连接(499 状态)。
为了兼容 http2(构建子请求时,将http2 改写成 http1.1),我们使用开源库 resty.http 代替了原来 Openresty 原生的 API ngx.location.capture,性能下降很大,因为 ngx.location.capture 是 Nginx 子请求,本质上不是 http 请求,直接运行在 C 语言级别,效率非常高。当 POST body 数据较大时,这个库转发
的速度会非常慢,从而引起超时。

经过多次对比测试发现,跟 client_body_buffer_size 设置的大小有关,当请求 body 大于 client_body_buffer_size 设置的值时,请求就会变得很慢,甚至超时;反之则正常。
client_body_buffer_size 用来指定 Nginx 读取请求 body 的 buffer 大小值,默认是 8k(32位操作系统)当请求的 body 大于这个值,则会将剩余的字节写入本地文件(磁盘)。所以,根本原因是 resty.http 库转发请求读写磁盘文件效率太低。

解决方案

可以从两方面进行优化解决:

  • 减少 resty.http 转发的超时时间(httpc:set_timeout(300) 不超过 300 毫秒),因为是转发 WAF 是 旁路 逻辑,可以避免转发 WAF 过慢影响客户正常业务请求。影响就是较大 body(大于 client_body_buffer_size 设置大小) 不会经过 WAF 检测,其实,考虑到性能等问题,WAF 也基本不会处理较大 body (> 1M ) 。
  • 建议优化 Nginx 配置项 client_body_buffer_size,提高请求 body 读取效率。建议设置 client_body_buffer_size 64k

更多

  • 分析 resty.http 库源码,看是否能对读写进行优化;
  • 预计很快 Openresty 的 API 就会支持 http2.0,到时候再替换回来;

问题相关代码片段

access_by_lua_block {
            local http = require "resty.http"

            local httpc = http.new()
            httpc:set_timeout(300)
            httpc:set_proxy_options({http_proxy = "http://127.0.0.1:9107"})

            ngx.req.read_body()

            local req_method = ngx.req.get_method()
            local req_body = ngx.req.get_body_data()
            local req_headers = ngx.req.get_headers()
            local req_url = "http://" .. ngx.var.host .. "/__to_waf__" .. ngx.var.uri
            if ngx.var.is_args == "?" then
                req_url = req_url .. "?" .. ngx.var.query_string
            end

            local res, err = httpc:request_uri(req_url, {
                version = 1.1,
                method = req_method,
                body = req_body,
                headers = req_headers,

                keepalive_timeout = 60,
                keepalive_pool = 16,
            })

            if not res then
                ngx.log(ngx.ERR, "failed to request: ", err, ". url: ", req_url)
                return ngx.exit(ngx.OK)
            end

            local status = res.status or 0
            local body = res.body

            if status == 200 then
                if not body or body == "" then
                    return ngx.exit(ngx.HTTP_CLOSE)
                end
            elseif status == 403 or status == 400 then
                ngx.req.set_method(ngx.HTTP_GET)
                return ngx.exec("/__waf_page__/" .. status .. ".html")
            end
        }

git 使用

git 使用

  • git merge --abort

    放弃当前正在进行的合并,深陷冲突无法自拨时使用,可以跳出来重新来过。

  • git fetch <远程主机名>

    一旦远程主机的版本库有了更新(Git术语叫做commit),需要将这些更新取回本地,这时就要用到,对你本地的开发代码没有影响。

  • git pull <远程主机名> <远程分支名>:<本地分支名>

    例如:git pull origin next:master,取回远程主机某个分支的更新,再与本地的指定分支合并。

  • git branch -b feature

    在本地创建分支feature并切换到该分支,使用该命令之前应先使用 git fetchgit pull,使当前项目到最新版。

  • git push origin feature/tests

    将本地分支feature推送到远程,分支名为tests

  • git branch -d feature

    删除本地分支feature,可以使用git push origin /tests删除远程分支tests

Nginx 缓存实践与实现讨论

Nginx 缓存实践与实现讨论

根据近来使用 Nginx 缓存的实践,做一个总结,以及阐述了一般的缓存系统实现的关键和问题。

Nginx 缓存是比较传统的单机本地缓存(需要说明的是,根据 KEY 计算缓存路径的方式是一样的,也就是说多个 Nginx 进程是可以共享相同缓存资源的),比较简单,容易理解。但是,通过 Nginx 缓存的使用与理解,能够窥探到业界通用的缓存系统的设计与实现方法。Nginx 会对上游返回的 Response 进行缓存,存放在特定目录下(磁盘),Nginx 会启动一个专门的 Worker 进程对缓存进行管理,周期性的删除过期缓存等。当一个请求进来,首先会判断是否需要使用缓存(proxy_cache_bypass),如果需要使用缓存,则直接将请求代理到上游;否则根据 proxy_cache_key 计算出 KEY(32 位 Hash 值),根据这个值从对应的目录(可设置多级目录)下获取相同名称的资源。需要注意的是,磁盘上缓存的是整个响应,包括响应头和响应体,而且,此时,Nginx 不会再对请求头和响应头进行判断,只是直接读进内存,返回响应到客户端,这点也很重要。实例:

Path: /tmp/c/5a/3a1796923cc2b162a5e7f89dc4cf95ac

▒q-\▒▒▒▒▒▒▒▒yq-\▒▒go▒
KEY: httphttpbin.org/cache/60
HTTP/1.1 200 OK
Connection: close
Server: gunicorn/19.9.0
Date: Thu, 03 Jan 2019 02:20:41 GMT
Content-Type: application/json
Content-Length: 219
Cache-Control: public, max-age=60
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Via: 1.1 vegur

{
  "args": {},
  "headers": {
    "Accept": "*/*",
    "Connection": "close",
    "Host": "httpbin.org",
    "User-Agent": "curl/7.52.1"
  },
  "origin": "103.126.92.86",
  "url": "http://httpbin.org/cache/60"
}

proxy_cache_key

缓存 KEY 的计算是缓存应用中非常关键的部分,一般的自然想到的就是根据请求 URL 来计算,即: $scheme$proxy_host$uri$is_args$args

但是,这会导致几个问题:

  • 同样的请求,参数顺序可能不一样,这会导致同一份资源被多次缓存;
  • 在后端服务中,URI 表示资源路径,很多情况下,相同 URI 对应的资源是相同的,即:$scheme$proxy_host$uri 就够了;
  • 服务端会根据某些头部字段(比如: http_accept_encoding, cookie, user_agent 等等)的不同,返回不同的资源,因此计算的时候需要加入这些头部字段,否则会返回错误的资源,可能会导致比较严重的问题;

根据标准的 HTTP 协议,服务端(源站)应该在响应头设置 Vary 字段,来显示指定这些影响缓存的头部字段,缓存系统需要支持这种协商机制,对不同的客户端请求返回不同的资源。但是,Vary 这个字段使用的比较少,甚至很多程序员都不清楚这个字段,而且,很多缓存系统也是不支持这个字段。

因此,KEY 的计算需要非常谨慎,最好的办法是根据具体的 Host 选择合适的字段来计算,即
proxy_cache_key 可配置化
。一般为了简单快速实现,通用的方式主要有:

  • 在 rewrite 阶段对参数进行排序;
  • 使用缓存一般都会同时开启资源压缩,节省带宽和磁盘空间,因此,头部字段 http_accept_encoding 一定要加入 KEY,否则会导致将压缩的资源缓存,响应给不接受压缩资源的客户端,出现乱码的情况。虽然现在大部分浏览器都是支持解压的。

总之,通用实现的场景下,最重要的是,宁可多缓存几份或;者直接回源,也不能出现请求响应的资源不匹配的情况

proxy_pass

有些请求响应(大多数动态资源)是不能缓存的,那么 Nginx 怎么会知道哪些响应应该被缓存呢?根据 HTTP 协议, 响应头 Cache-ControlExpires 控制缓存失效时间,也即,Nginx 只会缓存这种显式指定了缓存失效时间的响应。

当然,在代理层是可以做很多事情的,比如,可以使用 add_header 来添加 Cache-Control 头,使得源站返回的响应能够被缓存,或者设置 proxy_no_cache 来强制不缓存某些响应。

proxy_cache_purge

缓存系统必不可少的一个功能就是需要支持外部直接刷新的接口,因为很多场景下,需要将被缓存的资源提前失效,这个时候一般是通过 API 接口直接刷新的,也即请求对应的 PURGE 方法。缓存刷新模块看似简单,但是,在大流量的场景下,可能还要支持批量刷新功能,比如大型网站的更新迭代,往往有大量的资源需要更新,CDN 厂商经常会遇到这类需求。要保证缓存能够被快速,准确和稳定的刷新,还是挺有挑战的。一般缓存失效,磁盘上对应的文件并不会立即直接被删除掉,因为读写磁盘代价较大,而且缓存的资源实在太多了,一般先会在内存标记,然后待缓存管理进程(线程)周期性的轮询删除,配置命令 proxy_cache_path 属性 inactive 就是指定删除周期的。

Nginx 中可以使用 proxy_cache_purge 命令来支持 PURGE 请求刷新对应缓存资源。

map $request_method $purge_method {
    PURGE   1;
    default 0;
}

server {
    ...
    location / {
        proxy_pass http://backend;
        proxy_cache cache_zone;
        proxy_cache_key $uri;
        proxy_cache_purge $purge_method;
    }
}

但是,这个命令只有商业版才会有,大部分开发者用的应该都是开源版。

This functionality is available as part of our commercial subscription.

小结

在整个架构中,缓存系统往往是比较容易引起问题的模块,除了自身的问题外,因为缓存的使用还有一个与上下游协商的过程,也可能出现外部服务不规范导致的问题,一般比较多的问题已就是缓存未及时刷新,导致客户端使用了旧资源,以及 404 等错误状态未启用缓存(或过滤)导致缓存穿透,缓存刷新,过期不合理,导致缓存雪崩等。一般的使用场景直接使用 Nginx 自带的缓存就够了,但是,对于比较复杂的场景,要自建缓存系统才行,在 CDN 中缓存系统尤为重要。

以下,是我在项目中的使用实例:

    proxy_cache_path /tmp levels=1:2 keys_zone=mcache:5m max_size=5g inactive=60m use_temp_path=off;

    ...

    location / {
        proxy_cache_key $scheme$proxy_host$uri$is_args$args$http_accept_encoding; # compatible with clients that do not support gzip.
        proxy_no_cache $cookie_nocache $arg_nocache$arg_comment;
        proxy_no_cache $http_pragma $http_authorization;
        proxy_cache_bypass $cookie_nocache $arg_nocache$arg_comment;
        proxy_cache_bypass $http_pragma $http_authorization;
        proxy_cache_bypass $http_cache_control;

        proxy_cache mcahe;
        proxy_pass http://test.com;
        proxy_set_header Host "myhost.com";
        add_header X-Cache $upstream_cache_status;
    }

网页截图方案

网页截图技术似乎并不是很复杂,网上有很多实例,但是真的想搞清楚的话,还是有很多细节需要注意的。下面是我个人一些经验总结。

有3个技术方案,可以实现IE截图这篇博客说的很详细了,我实现了第2,3种方案,第1个方案缺点太明显了就没做了。

我的实现部分代码

  • 方案2:
 Rectangle body = webBrowser1.Document.Body.ScrollRectangle;  
 body.Height = height;  
 body.Width = width;
 
 IntPtr hmemdc = CreateCompatibleDC(hscrdc)
 SelectObject(hmemdc, hbitmap);
 IViewObject ivo = webBrowser1.Document.DomDocument as IViewObject;  
 ivo.Draw(1, -1, IntPtr.Zero, IntPtr.Zero,  
                 hscrdc, hmemdc, ref body,  
                 ref body, IntPtr.Zero, 0);  

这个方案的优点是可以实现缩放,但是效果很不好,截图比较有点模糊;放大会崩溃,这个问题似乎并没有好解决方案;有些第三方ActiveX没有实现IViewObject接口,就不能显示在截图里面,如银行密码输入控件等。

  • 方案3:
public static Bitmap GetWindow(IntPtr hWnd,int width,int height)  
   {  
       IntPtr hscrdc = GetWindowDC(hWnd);  
       IntPtr hbitmap = CreateCompatibleBitmap(hscrdc, width, height);  
       IntPtr hmemdc = CreateCompatibleDC(hscrdc);  
       SelectObject(hmemdc, hbitmap);  
       bool re= PrintWindow(hWnd, hmemdc, 0);  
       Bitmap bmp = null;  
       if(re)  
       {  
           bmp = Bitmap.FromHbitmap(hbitmap);                    
       }  
       DeleteObject(hbitmap);  
       DeleteDC(hmemdc);       
       ReleaseDC(hWnd, hscrdc);  
       return bmp;  
   }  

这个方案的唯一缺点是不能对特定元素截图,虽然不能直接缩放,但是对得到截图后再进行缩放也是很容易的。不管哪种技术方案,都只能对网页可见区域进行截图,不是完整的网页,为此我调研了很久,也使用了360浏览器的网页截图,也是只能截取可见部分。这个理论上是可以理解的,IE为了性能考虑,只渲染了可见区域的网页,当用户滚动滚动条的时候才会向下渲染。

但是有两个间接完整网页的实现方法:

1)将浏览器设大足够大,一次性显示所有完整网页。

2)滚动滚动条多次截图,再拼接。

使用PrintWindow是最稳定,有效的方法,综合考虑我选择了第三种方案,下面就详细谈谈PrintWindow:

参数详见MSDN,主要就是将窗口绘制成位图,这里我遇到了一个问题:就是当窗口弹出了一个子对话框的时候,是没办法截取到的。可以使用GetWindow(GetParent(vHandle), 6) 取到弹窗的句柄,然后分别截图。

以上就是我的一些经验总结了。

Redis 集群方案

Redis 集群方案

架构简图

redis 集群架构图

说明

  • 基础元数据特殊元数据合并到一个 redis 集群,集群预先分成若干片。N >= 3
  • 任意 reids master 故障时,redis sentinel 自动主备切换
  • twemproxy 对 key 进行哈希,路由到对应的 redis,主备切换后,触发脚本修改 twemproxy 配置的 servers
  • 每个 master 节点挂 2 个 slave 节点,twemproxy 和 redis sentinel 至少 3 个,可以横向扩展

对比

优势:

  • 去除单节点故障,提高可用性
  • 方便管理和使用,避免多个项目使用多个 redis 实例
  • twemproxy 代理和预先对数据分片,横向扩展,支持更大的数据缓存和并发连接
  • 集群架构相对 redis cluster 更简单,且不需要客户端支持最新的跳转等特性

不足:

  • 增加 reids master 节点,旧数据需要手工调整,但是可以预先多分片避免

迁移方案

  • 编写数据迁移脚本mRedisMove,迁移流程:

    old redis => mRedisMove => Ohm API => mongodb & new redis

  • 有些动态临时缓存数据,如 带宽控制,不需要存入 mongodb,由API控制

部署

本地测试

master:10.0.3.42:4500 10.0.3.42:4501
slave:10.0.2.238:4500 10.0.2.238:4501
sentinel:10.0.2.238:26379 10.0.3.42:26379
twemproxy:10.0.3.42:14555
redis-benchmark:10.0.2.238

直接测试 redis :

<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCQlkwdWpJVFBjRDA/preview" width="500" height="100"></iframe>

测试 twemproxy :

<iframe src="https://drive.google.com/file/d/0B_bGvu4-BQOCUnZBZjhhSW5wbEk/preview" width="500" height="100"></iframe>

测试结果相近,官方说法是:twemproxy 性能最多低20%

参考

etcd 使用小结

etcd 使用小结

Revision

Revision 表示改动序号(ID),每次 KV 的变化,leader 节点都会修改 Revision 值,因此,这个值在 cluster(集群)内是全局唯一的,而且是递增的。

需要特别说明的是,RevisionModRevisonVersion 三者之间的区别:

  • ModRevison 记录了某个 key 最近修改时的 Revision,即它是与 key 关联的。
  • Version 表示 KV 的版本号,初始值为 1,每次修改 KV 对应的 version 都会加 1,也就是说它是作用在 KV 之内的。

使用参数 --write-out 可以格式化(json/fields ...)输出详细的信息,包括 RevisionModRevisonVersion

etcdctl get foo --write-out=fields

参考 etcd issues

JavaScript关于闭包,匿名函数,this,对象等的一些理解

闭包原本就是指所有的函数,但我们一般是指能够读取其他函数内部变量的函数,主要有两个作用:一个是可以读取函数内部的变量,另一个就是让这些变量的值始终保持在内存中。

当函数a的内部函数b被函数a外的一个变量引用的时候,就创建了一个闭包。匿名函数不但可以省去命名的问题,同时可以提高程序的安全性,增加内聚。this 指针的作用对象取决与其所在的运行环境,闭包是运行在 Window 全局的,所以闭包里面的 this 也是指向 Window 的。在 JavaScript 中一切都可以看见作对象,变量,函数等都可以用来创建对象。在 JS 中属性是公有的,但是有私有变量,使用 var 定义就是私有变量,同时也没有块级作用域的概念(注:for(var i=0;i<5;i++),i存在于整个函数),那怎么才能访问私有变量以及仿造块级作用域,跟其他语言一样的效果呢?

闭包+匿名函数就派上用场了。块级作用域可以使用自我执行来解决(注:function(){}()),为什么?这里要引入作用域链和内存回收的概念了。JS 中对象之间的关系是从下往上的,也就是说如果子函数还在引用,那么以上的所有父函数都不会被回收,所以上面的 for 循环就可以放在函数闭包里面自我执行,完了后,i就被回收了,相当于块级变量。

既然通过闭包等方式可以创建出跟其他语言一样的对象,那么也肯定可以达到一些特殊的模式设计效果了。比如,静态变量,单例模式。使用 prototype 使方法(如构造函数等)共享,从而使相应的变量变成静态变量。

JS 相对于 C++ 而言更加面向对象(貌似是个废话),两个语言的所有用法不同之处就在于此,JS 中的许多特性就是强制要使用者养成面向对象的概念,在语言层面很自然的能够设计出高内聚,低耦合的程序来。比较适合初学者,但 C++ 更加自由化一些,要有一定的功力才能达到这样的效果。

面试汇总

面试汇总

这几年经历过不少面试,记录下来,偶尔看看,每次体验应该都会不一样吧。

某电商/平台容器调度

在线编程题

由于时间有限,写的比较仓促,基本满足要求。

  • 请实现两个线程,使之交替打印1-100,如:两个线程分别为:Printer1+和+Printer2,最后输出结果为:

Printer1--1
Printer2--2
Printer1--3
Printer2--4

package main

import (
	"fmt"
	"time"
)

const (
	total = 100
)

//Printer1

func Printer1(a, b chan int) {
	for i := range a {
		if i > 100 {
			return
		}

		fmt.Printf("Printer1--%d\r\n", i)
		b <- i + 1
	}
}

func Printer2(a, b chan int) {
	for i := range a {
		if i > 100 {
			return
		}

		fmt.Printf("Printer2--%d\r\n", i)
		b <- i + 1
	}
}

func main() {
	a := make(chan int)
	b := make(chan int)

	go Printer1(a, b)
	go Printer2(b, a)

	a <- 0

	time.Sleep(time.Millisecond * 10) //阻塞

	close(a)
	close(b)

	fmt.Printf("exit\n")
}
  • 设计并实现一个程序,达到和下面的 shell 命令相同的效果:
    cat /home/admin/logs/data.log | grep alibaba | sort | uniq -c | sort -nr
package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"sort"
	"strings"
)

const (
	LOG = "/Users/jinhailang/Desktop/test.txt" //"/home/admin/logs/data.log"
)

func cat(path, key string) ([]string, error) {
	var ss []string

	fi, err := os.Open(LOG)
	if err != nil {
		fmt.Printf("Open error: %v\n", err)
		return nil, err
	}

	defer fi.Close()

	br := bufio.NewReader(fi)
	for {
		a, _, c := br.ReadLine()
		if c == io.EOF {
			break
		}

		s := string(a)
		if strings.Contains(s, key) {
			ss = append(ss, s)
		}
	}

	fmt.Println("ss: ", ss)

	return ss, nil
}

func uniq(ss []string) map[string]int {
	mp := make(map[string]int)

	for _, s := range ss {
		count, ok := mp[s]
		if ok {
			count++
			mp[s] = count
		} else {
			mp[s] = 1
		}
	}

	return mp
}

type kv struct {
	K string
	V int
}

type kvs []kv

func (p kvs) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
func (p kvs) Len() int           { return len(p) }
func (p kvs) Less(i, j int) bool { return p[i].V < p[j].V }

func sort_nr(mp map[string]int) []kv {
	p := make(kvs, len(mp))
	i := 0
	for k, v := range mp {
		p[i] = kv{k, v}
		i = i + 1
	}

	sort.Sort(p)

	return p
}

func main() {
	ss, _ := cat(LOG, "alibaba")

	sort.Strings(ss)
	fmt.Println("sort after ss: ", ss)

	mp := uniq(ss)
	fmt.Println("uniq after mp: ", mp)

	kk := sort_nr(mp)
	fmt.Println("sort_nr after kk: ", kk)

	fmt.Println("exit.")
}

nginx 发送日志到 syslog-ng 配置小结

nginx.conf 配置

error_log syslog:server=127.0.0.1,facility=local6 debug;`
access_log syslog:server=127.0.0.1,facility=local5 main;
  • facility 表示消息 facility(设备),可以理解为通道,这里是 local6 ,这个值稍后还将在 syslog-ng 用到 ,更多可选值
  • debug 即为日志输出的日志级别,更多可选值
  • tag 可缺省,默认为 nginx,日志数据标记,后面会用来做筛选

syslog-ng 端配置

为了方便查看日志,这里产生两个日志文件:access.log 记录访问日志;error.log 记录程序日志,包括DEBUG,INFO,ERROR 等。

修改配置文件 /etc/syslog-ng/syslog-ng.conf,增加如下配置项:

  • 定义日志文件地址
destination d_nginx_err { file("/var/log/nginx/error.log"); };
destination d_nginx_access { file("/var/log/nginx/access.log"); };
  • 定义通道筛选规则
 filter f_access_nginx { facility(local5); };
filter f_err_nginx { level(debug .. emerg ) and facility(local6); };
  • 执行
log { source(s_src); filter(f_err_nginx); destination(d_nginx_err); };
log { source(s_src); filter(f_access_nginx); destination(d_nginx_access); };
  • 重启 syslog-ng
    /etc/init.d/syslog-ng restart

end.

TCP 拥塞控制算法

TCP 协议两个重要的控制算法,流量控制和拥塞控制算法。流量控制是解决接收端处理不过来的问题,即停车场还可以停多少车的问题;拥塞控制是处理网络链路出问题,数据包发送超时或丢失的问题,即去停车场的路太窄,挤堵了,一次性可以通过多少车(并行发送)的问题。

流量控制

TCP 中采用滑动窗口来进行传输流量控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为 0 时,发送方一般不能再发送数据报,但有两种情况除外,一种情况是可以发送紧急数据,例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个1字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。

滑动窗口

容许发送方在接收任何应答(ACK)之前可以继续发送的数据包大小。接收方告诉发送方在某一时刻能送多少数据包(称窗口大小),滑动窗口协议的基本原理就是在任意时刻,发送方都维持了一个连续的允许发送的帧的序号,称为发送窗口;同时,接收方也维持了一个连续的允许接收的帧的序号,称为接收窗口。发送窗口和接收窗口的序号的上下界不一定要一样,甚至大小也可以不同。不同的滑动窗口协议窗口大小一般不同。发送方窗口内的序列号代表了那些已经被发送,但是还没有被确认的帧,或者是那些可以被发送的帧。

拥塞控制

拥塞窗口(cwnd):某一源端(发送端)数据流在一个 RTT 内可以最多发送的数据包数。
慢启动阈值(ssthresh):cwnd 超过此阈值则转变控制策略。

拥塞状态

  • Open 拥塞控制状态机的默认状态
  • Disorder 当发送方检测到DACK(重复确认)或者SACK(选择性确认)时,状态机将转变为Disorder状态
  • CWR 当cwnd正在减小并且网络中有没有重传包时,这个状态就叫CWR(Congestion Window Reduced,拥塞窗口减少)状态
  • Recovery 发送方接收到足够(推荐为三个)的DACK(重复确认)后,进入该状态。在该状态下,拥塞窗口cnwd每收到两个ACK就减少一个段(segment),直到cwnd等于慢启动阈值ssthresh,也就是刚进入Recover状态时cwnd的一半大小。
  • Loss 当一个RTO(重传超时时间)到期后,发送方进入Loss状态

四大算法

拥塞控制主要是四个算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复
- 慢启动算法  Slow Start
所谓慢启动,也就是 TCP 连接刚建立,一点一点地提速,试探一下网络的承受能力,以免直接扰乱了网络通道的秩序。

慢启动算法:

1) 连接建好的开始先初始化拥塞窗口 cwnd 大小为 1,表明可以传一个 MSS 大小的数据。
2) 每当收到一个 ACK,cwnd 大小加一,呈线性上升。
3) 每当过了一个往返延迟时间 RTT(Round-Trip Time),cwnd 大小直接翻倍,乘以 2,呈指数让升。
4) 还有一个 ssthresh(slow start threshold),是一个上限,当 cwnd >= ssthresh 时,就会进入“拥塞避免算法”(后面会说这个算法)

- 拥塞避免算法  Congestion Avoidance
如同前边说的,当拥塞窗口大小 cwnd 大于等于慢启动阈值 ssthresh 后,就进入拥塞避免算法。
算法如下:

1) 收到一个 ACK,则 cwnd = cwnd + 1 / cwnd
2) 每当过了一个往返延迟时间 RTT,cwnd 大小加一。

过了慢启动阈值后,拥塞避免算法可以避免窗口增长过快导致窗口拥塞,而是缓慢的增加调整到网络的最佳值。

- 拥塞状态时的算法
 一般来说,TCP 拥塞控制默认认为网络丢包是由于网络拥塞导致的,所以一般的TCP拥塞控制算法以丢包为网络进入拥塞状态的信号。对于丢包有两种判定方式,一种是超时重传 RTO[Retransmission Timeout] 超时,另一个是收到三个重复确认 ACK。

超时重传是 TCP 协议保证数据可靠性的一个重要机制,其原理是在发送一个数据以后就开启一个计时器,在一定时间内如果没有得到发送数据报的ACK报文,那么就重新发送数据,直到发送成功为止。

但是如果发送端接收到3个以上的重复ACK,TCP就意识到数据发生丢失,需要重传。这个机制不需要等到重传定时器超时,所以叫做快速重传,而快速重传后没有使用慢启动算法,而是拥塞避免算法,所以这又叫做快速恢复算法。

超时重传 RTO[Retransmission Timeout] 超时,TCP 会重传数据包。TCP 认为这种情况比较糟糕,反应也比较强烈:

由于发生丢包,将慢启动阈值 ssthresh 设置为当前 cwnd 的一半,即 ssthresh = cwnd / 2。cwnd重置为 1。

进入慢启动过程:最为早期的TCP Tahoe算法就使用上述处理办法,但是由于一丢包就一切重来,导致cwnd重置为1,十分不利于网络数据的稳定传递。所以,TCP Reno 算法进行了优化。当收到三个重复确认 ACK 时,TCP开启快速重传 Fast Retransmit 算法,而不用等到 RTO 超时再进行重传:

1)cwnd 大小缩小为当前的一半
2)ssthresh 设置为缩小后的cwnd大小
3)然后进入快速恢复算法 Fast Recovery

- 快速恢复算法  Fast Recovery
 TCP Tahoe 是早期的算法,所以没有快速恢复算法,而 Reno 算法有。在进入快速恢复之前,cwnd 和ssthresh 已经被更改为原有 cwnd 的一半。快速恢复算法的逻辑如下:

cwnd = cwnd + 3 * MS  // 加 3 * MSS 的原因是因为收到3个重复的ACK

重传 DACKs 指定的数据包。如果再收到 DACKs,那么 cwnd 大小增加一。

如果收到新的ACK,表明重传的包成功了,那么退出快速恢复算法。将 cwnd 设置为 ssthresh,然后进入拥塞避免算法。

参考

Go 依赖管理

Go 依赖管理

Go 引用的包,可以分为两类:内部包和外部包,对于内部包,因为是项目内部自己写的,管理起来比较简单;但是,对于外部包,因为是开源的,一般由第三方维护管理,可能会版本不一致,出现非预期的情况,导致编译错误,或者出现程序 BUG,有经验的程序员应该都知道,这种由于第三方包引起的问题,定位和解决往往都是很烦人的。所以,目前所谓的依赖包管理工具,都是针对外部包的管理。

大部分编程语言都是将项目代码目录作为单独的工作目录(workspace),但 Go 只有一个工作目录 ——
$GOPATH,因为 Go 自身提供了很多工具,固定的工作目录,便于这些工具的运行。因此,需要强调一点是,我们应该在 $GOPATH/src 下,创建自己的项目目录。因为,项目构建时,就是从这个路径下开始的(即作为环境路径)。
其实,对于 Go 本身来说,是不区分内部包和外部包的,因为 go get 下载的包,也都放在 $GOPATH/src 目录下,因此,当我们 import 依赖包时,只要直接路径指定就行了,类似 github.com/xx/x。又因为 $GOPATH 目录下可能创建了多个项目,因此下载在这里的依赖包,也可能会被多个项目引用,随时可能会被其他项目更新修改。

所以,在最开始的时候(Go 1.5 之前),go 项目包的依赖管理是很简陋的,为了保证外部包的安全,就必须要把外部包拷贝到项目内部,变成“内部包”。此时,包的查找顺序是: $GOROOT --> $GOPATH.

在 Go 1.5 时,为了解决这个问题,引入了 vendor 这一特殊目录,该目录在项目内创建,这就相当于,每个项目有了自己独立的 GOPATH,此时,包的查找顺序是:离引用代码最近的 vendor --> $GOROOT --> $GOPATH.
项目内的每个目录下都能创建自己的 vendor 目录,引用时,使用的是最近一层的 vendor。但是,最好只在根目录创建 vendor,便于管理,也避免同样的包,出现在同一项目内的多个 vendor 目录下。

基于 vendor 能够实现真正意义的依赖包管理,而告别简陋的拷贝操作。

内部包管理

内部包管理其实很简单,但是有一个特性需要说明,那就是 internal 目录,位于 internal 目录下的包,只能被同父目录下的包引用,否则会编译报错。 这里需要说明的是,父目录的父目录也算,即位于项目根目录下 internal 里面的包,是可以被项目内所有包引用的。
vendor 类似,项目内也可以有多个 internal 目录,但是建议在项目根目录创建一个就够了。

internal 目录的目的是保护该目录下的包被其他项目所引用,例如某个包还不太成熟,不想被其他项目使用。很明显,这只是一种简单的“警告”机制。

外部包管理

基于 vendor 实现的依赖包管理工具,比较流行的有 dep, Godep, Glide, Govendor 等,实现原理大同小异。官网这里对这几个工具进行了分类说明,建议使用 dep 作为项目的依赖管理工具,原因主要有两点:

  • dep 是官方推出的管理工具,虽然目前还是处于 official experiment. 状态,但是已经可以作为正式工具使用了;
  • Godep 已经是 Gopher 比较常用的依赖工具了,但是可以看到,其官网已经声明后面只会进行维护性的开发工作,建议大家使用 dep 或其他工具代替:Please use dep or another tool instead.

dep 使用

dep 的使用,官方有较详细的说明文档:dep introduction.

这里根据自己的理解,做些说明。

安装

主要有两个安装方式:

   mv dep-linux-amd64 $GOPATH/bin/dep
  • 使用脚本自动下载安装:
   curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh

当然你也可以使用源码安装,但是,你最好不要这么干。安装完成后,执行 dep -h 查看帮助信息。

使用

  • dep init
    首先进入需要 dep 管理依赖包的项目,以 dep-test 项目为例。执行命令 dep init
cd $GOPATH/src/dep-test
dep init

完成后,在项目根目录下会创建文件 Gopkg.lock, Gopkg.tomlvendor 目录,vendor 里面就是依赖的包源码了,前两个文件保存内保存了依赖包的规则类型信息,依赖包版本信息以及依赖关系描述,后面还会对 dep 实现原理进行简单说明。

  • dep ensure 自动下载项目所有引用的依赖包

    • dep ensure add 下载指定路径的引用包,当有新的包引用时,尽量使用这种方式,而不是偷懒直接用 dep ensure,因为这个指令会将引用包版本信息自动写入文件 Gopkg.toml,否则只会更新文件 Gopkg.lock
    • dep ensure -update 更新依赖包版本,可以指定包路径,否则会更新所有依赖包;
    • dep ensure -vendor-only 只会根据当前的 Gopkg.lock 文件内包信息下载依赖包,而不会根据项目引用,自动更新 Gopkg.lock
    • dep ensure --no-vendor 会更新 Gopkg.lock,但是不会更新 vendor
  • dep check 检查 vendor 里面的包源码是否与 dep 记录的信息一致,如果不一致,则使用 dep ensure 更新;

实际应用中,提交代码时,我们可以将 vendor 目录一起提交,此时 其他开发者应该先使用 dep check 检验。
但是,一般 vendor 目录都比较大,这时可以只提交 Gopkg.tomlGopkg.lock 文件,开发者在本地使用 dep ensure -vendor-only 自行下载依赖包。

实现模型

实现模型也有详细说明:Models and Mechanismsdep 使用 four state system 模型,这是一种经典的包管理模型。这四个模块分别是:

  • Project source code 当前项目代码,即你的项目代码
  • A manifest 依赖清单,这里就是指 Gopkg.toml 文件,最初由 dep init 生成,主要由用户编辑,来实现个性化需求。它包含几种类型的规则声明来管理 dep 的行为,可以实现灵活的下载依赖包,例如可以指定特定区间版本的依赖包。
  • A lock 依赖描述文件,这里是指 Gopkg.lock 文件,里面详细记录了依赖包的地址,hash 值,版本等基本信息,根据描述能够复现完整的依赖图。这个文件完全是由 dep 根据 Gopkg.toml 和项目引用自动生成的。
  • Source code of the dependences themselves 依赖包自身源码,这是就是指 vendor 目录下的代码。

dep 主要就是围绕对这四个模块进行输入输出管理实现。流程大概如下:

dep 根据项目引用代码和 Gopkg.toml 文件内的规则信息,计算自动生成 Gopkg.lock 文件,此时,依赖的包信息就确定了,然后根据这些信息将对应的包下载到 vendor 目录内。

踩过的坑

使用软链接问题

对于 go 这种必须将项目放在 $GOPATH/src 的约定,实际应用中可能并不灵活,于是有两个技巧:

  • 动态指定 $GOPATH,也即将当前项目目录作为 $GOPATH,代码也放在 src 下,早期很多项目就是这么干的,要频繁的修改 $GOPATH,比较折腾;
  • 使用软链接,这是个小技巧,即在 $GOPATH/src 下创建一个软链接,指向项目目录;

但是,当使用软链接这种方式的时候,vendor 目录会失效,变成普通的目录了,因为 vendor 目录只有位于 $GOPATH 路径下,Go 才会把它当作特殊目录处理。而符号链接只是创建了指向文件名的符号,实际的文件位置依然没变。

使用相对路径问题

实际上对于内部包,我们在引用时可以使用相对路径(相对 import 代码的路径,例如: ./a/b 或者 ../x/y 等),这可能是 go 命令的潜规则,这种方式相对较灵活而且直观,项目存放位置也可以很随意。所以对于简单项目,个人是比较喜欢使用这种方式的。

但是,这会影响 dep 生成依赖关系,导致 dep 误以为没有依赖的外部包,非常奇怪的问题。

其实,这两个坑都是因为我之前比较随意,没有按规范,将项目放在 $GOPATH/src 导致的。

Go Modules

dep 还没有应用推广,在 Go 1.11 中,又推出了全新的依赖包管理机制 Go Modules,原来叫 vgo ,在 Go 1.11 被采纳合并到了主分支,正式被发布,不出意外的话,后续版本应该都会将这个当作包依赖管理的官方解决方案。

模块为 GOPATH 提供了替代方案,用来为项目定位依赖和管理版本化。如果 go 命令在 $GOPATH/src 之外的目录中运行,并且该目录中有一个模块的话,那么模块功能就会启用,否则 go 将会使用 GOPATH(Go 1.11)。与 dep 不同,模块是集成在 go 命令的,可以使用 go mod init 创建。

小结

Go 作为比较新的语言,包依赖管理方式也在持续变动,主要分为三个阶段: 纯依赖包源码拷贝 --> 基于 vendor --> Go Modules

每次变动都使管理变的更先进,这对于 Go 和 Gopher 都是有益的,特别是 Go Modules 据称使用了其它语言包管理工具不同的理念算法,看起来比较复杂。因为刚出来,有较多问题需要讨论,后续比较成熟了再研究,可能要等到 Go 1.12 版本吧。

所以,如果不想成为第一个吃螃蟹的人,目前,还是使用 dep 作为管理工具比较稳妥。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.