Node服务高并发高可用之容器化与负载均衡中,我们已经解决了QPS如何满足用户需求的问题,这一篇我们再讲讲限流,以及如何使用限流来提高我们服务的可用性。在使用node-rate-limiter-flexible服务限流中,已经提过一次限流,这里我们继续说一说。

限流有很多用途,比如很多道友会提供接口给人用,为了限制对方调用的调用频率,此时就可以加上限流功能。

比如有个网站每秒只支持100个人访问,结果有同时有10000个人去访问,如果没有限流,可能网站就会奔溃了。

比如一个接口每秒只能支持10QPS, 但调用方因为突发流量,每秒调用100次,那这时候第10次~20次请求就要1~2秒才响应,第20~30次请求就要2~3秒才响应,第90~100次请求就要9~10秒才响应,导致接口超时严重,运维就会找过来了。

如果加上限流功能,这上面的问题就都解决了。当访问数量超过支持的数量时,就直接告诉对方,你被限流了,这样能保证自己的服务不会奔溃。

Node里有很多限流模块,我这里使用node-rate-limiter-flexible,它不需要引入额外模块如Redis等做限流,只是在每个Node进程里做限流,满足我的需求。一个简单的配置如下

1
2
3
4
5
6
7
8
9
10
11
const burstyLimiter = new BurstyRateLimiter(
new RateLimiterMemory({
points: 3,
duration: 1,
}),
new RateLimiterMemory({
keyPrefix: 'burst',
points: 5,
duration: 5,
})
);

它是用了两个限流配置,第一个的令牌用完了,就会去用第二个的。如下就是一个QPS是4,支持突发流量到8的配置,相当好用。第一个配置每一秒生成3个令牌,第二个配置每5秒生成5个令牌,那么5秒内就一共可以生成3 * 5 + 5 = 20个令牌,相当于这5秒内的QPS是4,而它的突发流量是8,也就是第一秒内如果进来8个请求,它也不会触发限流。

在服务高可用方面,除了限流,还有熔断,服务降级,我也只听过,没用过。我这里只使用限流功能就够了,其它就不管了。

联系作者

Node服务高并发之PM2使用中,我们介绍了如何使用PM2将单台机器上Node服务性能最大化,这一篇我们讲一讲当单台机器的QPS无法满足需求时,如何解决。

其中一个解决办法是提高单台机器的CPU核数,比如之前是2核(即2C)的,提高到4核,再不够提高到8核,再不够继续提高。虽然这也能解决问题,但这意味着这个服务是单点的,假设服务器宕机了,服务就挂了。

这里我更赞成使用多台服务器,比如原先单台需要32核服务器,我们可以分成申请8台4核服务器。这样当其中一台服务器宕机了,还能有7台服务器提供服务。这里我提一句,有道友申请4C/8G的服务器用来做Node服务,大可不必,一般Node token生成服务都是CPU密集型,内存占用不会太多,有这个钱还不如申请两台4C/4G的服务器。

现在我们就需要涉及到如何讲代码发布到这多台服务器中,其中一个办法先到每台服务器上安装需要的Node环境,然后再上传代码。另一个更好办法是容器化,例如使用Docker技术,将需要的Node环境和代码一起打包到镜像中,然后把镜像上传到服务器中。

既然涉及到多台服务器,就得加上负载均衡,让请求均匀分发到各台服务器上。简单点就用Nginx做负载均衡,还有一些更复杂的API网关如Envoy, APISIX等等。

具体如何使用我也没有深入了解,毕竟公司有现成的一套容器化和API网关,还有动态迁移(容器所在的物理机宕机时,在其它物理机重新启动一个新容器提供服务),动态库容(CPU负载过高时,扩容)等功能,直接用就完事, 美滋滋了。

联系作者

最近和yd验证码看对眼了,于是决定锤一锤它。在锤它之前先看了十一姐y小白的文章,再参考小白佬的例子,就开始干活了。具体要搞定哪一些参数,直接看十一姐的文章好了,十一姐的文章目前是我看到的最详细的,实在是太细了,我这里就只讲讲我遇到的一些坑点。

代码有一万多行,ob风格的混淆,但因为蔡老板的星球一直提供ob解混淆脚本的原因,我已经是个ob废物了,用脚本跑了下,没有还原出来,估计是版本不一致的原因,于是只好硬刚了。在掏出补环境框架之前,先尝试手动抠了一份生成cb参数的代码,虽然生成的参数能用,但还是有些麻烦,于是决定用补环境来解决。先拿官网的例子练练手,因为官网参数会少一些,会更简单。站在大佬们的肩膀上,很快就搞定了。

然后去测试具体网站,这时就开始遇到问题了,先是生成的fp参数不能用,慢慢排查后才发现location还用的是官网的地址,改过来后就好了。然后又遇到个现象,能获取两次验证码,第三次就不行了,隔一段时间又能获取两次验证码,第三次就不行了,百思不得其解,问了一圈道友都没有遇到过这种情况。于是只好一个个接口,一个个参数慢慢排查,排查了好几天,最后发现是验证码校验接口cb参数没有动态生成,就离谱。

明明记得这个参数改成动态生成了,但事实就是没改,估计是记忆错乱了,把改过官网的代码记成具体网站了。除了这个问题,全程再没有遇到其它坑点。

像这种代码是固定的网站,是真的好搞,补环境也是真的香,即便现在所有接口都跑通了,我都不知道它这混淆代码写的都是些啥。

最近十一姐和时光佬搞了个星球,值得一看。

联系作者

两年前,去了某里的同事发来一个滑块,是他们所在的团队维护的,让我试试能不能通过校验。但那时我对补环境还不是很熟悉,某里的控制流也没能还原,硬刚太累了,业务上也没有需求,就只好放在收藏夹中吃灰。

去年学习了蔡老板的9大节点合并算法,能还原221版本的滑块代码,但代码量实在是太多了,一个case里塞进去2万多行代码,没有耐心看下去,于是继续吃灰。

今年,某里滑块已经有人均的趋势,圈子里已经卷到飞起,00后已经开始乱杀。自己业务上也没有前些年紧张,于是可以腾出手来搞一搞。此前已经能生成226的签名,但就是过不去,也不知道错在哪里。最近正好时光佬也在搞滑块,开始佬也写了篇分析226的文章,在他们的帮助下很快就搞好了普通226滑块。此时我才知道,上一次生成226签名时就差临门一脚。

真正的挑战来自于某里系旗下网站的滑块,这些滑块圈子里一般称之为x82y。虽然代码和226是一样的,但校验更严格。此时才真正开始看226的代码,和浏览器对比,大大小小有35处不一致的地方,只好一个一个排查,排查的过程中,才知道自己的补环境框架的缺陷,才知道自己的补环境方法不合理的地方,是时候回炉重造了。好在目前应付普通226滑块就够用了,x82y等有需要了再慢慢搞了。

其实不补环境,扣算法的话,也是可以的,某里滑块离谱的是代码是固定的,能够通过AST还原代码的话,搞起来容易很多。

一直都只在国内反爬圈里打转,某里滑块是迄今为止遇到的检验最多的js反爬了,如果想测试自己补环境框架的强度,某里滑块是首选。

联系作者

和道友交流的时候,发现他对Node服务高并发一知半解,于是和他讲了下自己的理解,他说受益匪浅,于是写文章记录下来,希望能帮助到其他人。

当我们用 Express 搭建好一个 token 生成服务给其他开发调用时,我们就要开始考虑服务的性能,考虑服务的并发能力。同时也要考虑服务如何方便部署,方便更新程序。而由于 token 生成服务都是 CPU 密集型程序,所以只能通过提高进程数,提高 CPU 核数来提高并发能力。

刚来公司的时候,看到接手的 token 生成服务是这样部署的,在连续的几个端口中,比如5000,5001,5002,每个端口用 Node 启动一个 Express 服务,客户端调用的时候,随机取其中一个端口访问。服务需要更新的时候,得一个一个 Node 进程杀死后再重新启动,过程很不友好。

正好之前做过一点前端开发,前后端分离时用到过 PM2 来管理Node进程, 于是将这些服务都用 PM2 来管理,并在全组范围内推广使用 PM2,现在我们所有的 token 生成服务都用 PM2 来管理,就挺好。

PM2 很好用的一点是可以设置进程超过一定内存后就重启,通过配置 –max-memory-restart 参数即可实现。这个功能就很实用,因为 JavaScript 的闭包使用真的很容易内存泄漏,想找到原因不容易。为了不影响服务器上的其它进程,得不断重启Node进程。

单台机器上,CPU核数和进程数一致,此时并发性能最高。如果这样了还不能满足业务需求,只能堆机器,在多台机器上部署。

联系作者

wasm补环境初体验

最近看到群里在讨论,y小白也写了篇相关的文章,于是也想试试。wasm 即 WebAssembly,这还是第一次遇到了。

放在补环境框架里跑一跑,就遇到 vm2 读取 wasm 文件的问题。具体是这样的,在 Node里先把文件加载进来后,在传到 vm2 沙盒里时,一般都是通过 sandbox 对象传入。代码如下

1
2
3
4
5
6
7
8
wasmCode = fs.readFileSync('test.wasm');
const sandbox = {
wasmCode: Buffer.from(wasmCode).buffer,
}

const vm = new VM({
sandbox: sandbox,
});

然后 sandbox 会被 proxy 代理了,此时发现无法使用 WebAssembly.instantiate 来实例化 WebAssembly 代码。查看 vm2 源码就会发现代码里会对sandbox进行操作。目前只想到通过修改 vm2 源码的办法来解决这个问题。这到时候发布啥的就会麻烦一些,需要单独打Docker镜像。

解决这个问题后就简单多了,缺啥补啥就行了,补出来的token直接就能访问。估计现在还是初级版本,还在持续更新中,目前没有什么特别的检测点。

为了便于发布,顺便搞了个Node的版本,有了之前的 vm2 版本基础,补起来就挺顺畅的,没遇到什么困难。

唯一值得一提的是,它这个环境会根据传入的参数不同,做不同的环境检测,走不同的检测分支,这还是第一次见这种情况。整体下来并没遇到什么难点,估计后面会越来越难了,得先准备起来。

联系作者

关于DOM树解析功能,本来想着套用jsdom,但jsDOM太大了,于是看jsdom底层如何实现DOM树解析,发现是使用的parse5,从parse5的相关项目中又知道了cheerio, 于是想着如何套到框架里。后来从蔡老板和风佬的补环境课程里学了一手DOM树解析后,把之前的补环境框架中DOM树解析相关代码更新了下,简化了很多代码。DOM树相关的操作如createElement, appendChild,childNodes也不再是硬编码了,框架也变得通用一些。这就挺好的,虽然执行速度上慢了一些,也不像浏览器的DOM树操作那样完备,但通用性已经大大增强。

于是拿着这个补环境框架锤子到处锤一锤,反正不用细细研究加密逻辑,花不了太多时间。锤rsvmp,把之前的服务升级了一下,简化了下代码。锤某里225,目前的情况是能生成token但过不去,估计补的不对,225主要不是刚需,就先放一放。后续有时间了,还是先锤一下某里的140才行,循序渐进,正好140有哲哥现成的AST还原代码,补不对的时候可以对着代码看一看,而225的还原没有现成的,得自己写AST还原插件。

蔡老板和风佬的补环境课程还是值得一学的。

联系作者

之前因为Node环境容易被检测,就用vm2沙盒来跑,可以避免很多检测。虽然大佬们都说vm2也是可以被检测的,但一直能跑,就一直用着。

最近在搞一个网站,一直过不去,仔细调试后发现是因为VM2_INTERNAL_STATE_DO_NOT_USE_OR_PROGRAM_WILL_FAIL.handleException这个东西。搜索vm2的源码,在transform.js里可以找到如下代码

1
2
3
4
5
6
7
8
9
10
const name = assertType(param, 'Identifier').name;
const cBody = assertType(node.body, 'BlockStatement');
if (cBody.body.length > 0) {
insertions.push({
__proto__: null,
pos: cBody.body[0].start,
order: TO_LEFT,
coder: () => `${name}=${INTERNAL_STATE_NAME}.handleException(${name});`
});
}

类似的代码还有好几次,作为实用主义,就不细细研究vm2为啥要加上这些代码,简单粗暴的把这一部分删掉后就跑起来了。

之前用jsdom, jsdom被检测,现在用vm2, vm2也被检测,对抗之路真是永无止境。看来还是得selenium一把梭才是王道啊,只是selenium如何方便的做成一个服务,工程化问题如何解决呢,这是个问题,要不然selenium自动化真是香喷喷。

联系作者

最近技术群里分享了篇文章《WebRTC泄露源IP的防范措施》,关于WebRTC泄露真实IP,在我对反爬的一些理解中其实也提到过这个问题

对于浏览器环境的检测,有Canvas指纹,Webgl指纹,Audio指纹,字体指纹,还有WebRTC指纹等等。WebRTC可以获取到客户端的实际内外网IP,即便浏览器加了代理。对Canvas指纹,Webgl指纹,Audio指纹,字体指纹做校验是为了防止爬虫用程序批量抓取,因为这种批量抓取,指纹大都是一样的。当然对于有经验的爬虫,这些都防不住,指纹可以修改,WebRTC可以禁用。

一些知名厂商的反爬方案中就有用到WebRTC这个漏洞,如果使用浏览器采集的时候,发现并发上不去,很有可能是因为WebRTC暴露了真实IP。

网上说解决的办法是安装WebRTC Leak Shield等类似插件,在测试网站上https://ip8.com/webrtc-test可以测试是不是生效,加上代理,安装插件后,测试发现确实生效,没有暴露真实IP。但在https://api.ipify.org/网站上测试的时候,还是发现暴露了真实IP,目前还不知道为啥。

联系作者

虽然知道ChatGPT厉害,但一直没有时间去注册个账号玩玩,最近终于有时间了,注册了个账号体验了下,真心不错。

之前群友提了个正则表达式的问题,用正则表达式匹配出现2次及2次以上的字符,这个问题读大学的时候舍友郭老曾经问过我,作为曾经的正则小王子,当时就知道要用反向引用,现在当然也知道。和群友说了下这可得用到正则的高级用法,可以看一下反向引用,一会儿他问了ChatGPT,就给他解答了。现在拿着这个问题问ChatGPT, 得到如下答案。

要匹配出现2次及2次以上的字符,可以使用正则表达式中的花括号 {} 来指定匹配次数。

假设要匹配的字符串为 hellooo worlddd,我们可以使用如下的正则表达式来匹配出现2次及2次以上的字符:
(.)\1{1,}
这个正则表达式的含义是:匹配任意一个字符 (.),然后匹配和它相同的字符至少1次 {1,}。其中 \1 表示反向引用前面匹配的字符,也就是 \1 所在的位置应该与前面 (.) 所匹配的字符相同。

去年初在学习js补环境的时候,想知道在jsdom中如何在解析页面前执行js代码的办法,找了一圈没找到答案。买了猿人学的课,看了所有的js视频,最终找到了解决的办法,也就是beforeParse方法,如今把这个问题丢给ChatGPT, 就能得到答案。

在使用 jsdom 解析 HTML 页面之前,你可以通过设置 beforeParse 选项来在解析之前执行 JavaScript 代码。

具体来说,你可以创建一个 JSDOM 对象并设置 beforeParse 选项为一个回调函数,在这个回调函数中编写需要执行的 JavaScript 代码。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const jsdom = require("jsdom");
const { JSDOM } = jsdom;

const html = "<html><body><div id='myDiv'></div></body></html>";
const options = {
beforeParse(window) {
// 在这里执行需要在解析页面前执行的 JavaScript 代码
const myDiv = window.document.getElementById("myDiv");
myDiv.textContent = "Hello, world!";
}
};

const dom = new JSDOM(html, options);
console.log(dom.window.document.querySelector("#myDiv").textContent);
// Output: "Hello, world!"

在这个例子中,我们创建了一个包含一个空 div 元素的 HTML 页面,并设置了 beforeParse 选项为一个回调函数。在这个回调函数中,我们使用 getElementById 方法获取了这个 div 元素,并将其文本内容设置为了 “Hello, world!”。然后我们创建了一个 JSDOM 对象并传入了这个 HTML 页面和选项对象,最后我们验证了修改后的 div 元素的文本内容是否正确。

注意,beforeParse 回调函数只能在 JSDOM 对象创建时执行一次,无法在解析完成后再次执行。如果你需要在解析完成后执行 JavaScript 代码,可以使用 window.onload 或 window.addEventListener 等方法。

前几天前同事又问了个算法题,写一个函数split_text,参数一个是字符串text 一个是列表 dic,都是英文单词 返回一个列表 每个元素都是一个英文单词,并且顺序连起来,就是text 比如 split_text(‘appledesktop’, [‘a’, ‘app’, ‘apple’, ‘desk’, ‘top’, ‘led’]) 返回 [‘apple’, ‘desk’, ‘top’] split_text(‘appledappleatop’, [‘a’, ‘app’, ‘apple’, ‘desk’, ‘top’, ‘led’]) 返回 [‘app’, ‘led’, ‘apple’, ‘a’, ‘top’]

您可以使用动态规划来解决这个问题。首先,创建一个长度为text长度+1的列表dp,dp[i]表示text[0:i]的分割结果。初始化dp[0]=[]。然后,从i=1开始,遍历dp列表。对于每个i,从j=i-1到j=0倒序遍历。如果text[j:i]在字典dic中,那么将dp[i]更新为dp[j]加上[text[j:i]]。最后返回dp[-1]作为结果即可。

下面是实现这个函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
def split_text(text, dic):
dp = [[] for _ in range(len(text) + 1)]
dp[0] = []

for i in range(1, len(dp)):
for j in range(i - 1, -1, -1):
if text[j:i] in dic:
if dp[j]:
dp[i] = dp[j] + [text[j:i]]
break
else:
dp[i] = [text[j:i]]
return dp[-1]

您可以运行一些测试来验证函数是否正常工作:

1
2
3
4
5
6
7
text = 'appledesktop'
dic = ['a', 'app', 'apple', 'desk', 'top', 'led']
print(split_text(text, dic)) # ['apple', 'desk', 'top']

text = 'appledappleatop'
dic = ['a', 'app', 'apple', 'desk', 'top', 'led']
print(split_text(text, dic)) # ['app', 'led', 'apple', 'a', 'top']

问ChatGPT,还有更优的办法吗?它又给出了回答。

上面给出的动态规划方法是一个不错的解决方案,但是也可以使用更简单和更高效的贪心算法来解决这个问题。

具体来说,对于给定的文本字符串text和字典列表dic,我们可以考虑从text的左侧开始遍历,每次查找能够匹配字典中某个单词的最长前缀,并将该单词加入结果列表中。重复这个过程,直到text被完全分割成单词为止。

下面是实现这个贪心算法的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def split_text(text, dic):
result = []
i = 0

while i < len(text):
found = False
for word in dic:
if text.startswith(word, i):
result.append(word)
i += len(word)
found = True
break
if not found:
# 如果在字典中找不到匹配的单词,则返回空列表。
return []
return result

真的是强到离谱,这之后用搜索引擎的频率就低很多了。

联系作者