Skip to content

18系统的实践设计(下):完成一个通用投票系统

上一讲,我们完成了准备工作,包括架构设计、数据库设计、接口设计以及接口时序图绘制,这一讲就着重实现活动相关接口、票相关接口和抢票接口,为的是让你熟悉使用 Node.js 来开发后台服务。

这三个部分的实现都有一定的代表性,活动相关接口因为访问量较大,要完全走缓存的设计方式;与个人相关的票接口,访问量较少并且缓存意义不大,所以走数据库的设计方式;而抢票是我们核心接口,也是最大的并发接口,则涉及应用 Redis 的链表技术点。

明确上述需要开发的接口,以及要掌握的技术点之后,接下来我们就正式进入今天的内容,看一下你在开发时需要注意的细节。

开发流程

在开发之前,我们做这样 4 个准备。

  • 安装本地的 Redis 服务,如果你是 Mac 可以直接使用 brew install redis,如果是 Linux 或者 Windows 可以前往redis 官网下载配置,完成后需要修改项目代码 src/cache.js 中的 redis 配置。

  • 重新配置 pm2.config.js 文件,在 env_development 中增加以下配置,主要是为了在开发阶段,自动重启不需要监听下面目录的变化。

java
ignore_watch: ["log", "node_modules", "bin", "config"]
  • 创建好 PM2 的系统日志路径(可以自行在 pm2.config.js 中修改你自己希望的地址)。
java
mkdir -p ~/data/nodejs-column-io/
  • 由于 Mongodb 使用的是云服务,所以也需要你自行去申请一个Mongodb 云服务。当然,你也可以自行在官网下载本地安装一个,安装完成后,需要修改 src/lib/baseMongodb.js 中第一行的配置,并且要在 Mongodb 中创建 nodejs_cloumn 数据库。

完成上述 4 点准备之后,要在项目根目录使用下面的命令启动服务:

java
pm2 start pm2.config.js --env development

启动成功后,访问以下路径来检测 Mongodb 和 Redis 配置是否正确。

java
http://127.0.0.1:3000/tools/test?token=piu12naern9023izcx

如果返回图 1 的信息则表示正常,如果出现异常,就要检查一下具体的配置是不是正确,或者 Redis 以及 Mongodb 是否正常启动。

图 1 正常检测结果

前期检查正常后,接下来我们就来实现 17讲中的那 5 个接口。为了方便你开发调试,你可以直接调用以下链接,初始化我们的数据库:

java
http://127.0.0.1:3000/tools/init?token=piu12naern9023izcx

成功会提示正常的结果,如果失败就要前往项目根目录的 log/_tools_init.log 中查看具体的报错信息。

活动相关接口

活动相关的接口包含活动列表接口、活动详情接口,下面我们先来看下活动列表接口的实现。

活动列表接口

在 src/controller 路径下创建 act.js 作为活动的类,其中的 list 方法为活动列表,我们看一下该方法的代码,如图 2 所示。

图 2 活动列表接口

在图 2 中的红色框部分,就是应用框架的 loadService 加载 actService 这个类,然后调用该类的 getList 获取数据,那么我们继续来看 actService.getList 的逻辑,如图 3 所示。

图 3 actService getList 逻辑

在图 3 ,我们首先在第一个红色框中获取缓存数据,如果没有获取到则从数据库中获取,也就是第二个红框逻辑。

为了性能考虑以及实时性考虑,我们只能将活动列表的缓存设置为 120 秒,但是我们可以定时地每 60 秒去生成这个缓存。为了定时缓存,我们特意在 actService 中实现了一个 cacheList 方法,你可以前往GitHub查看详细实现。

这里你可以思考一下:为什么一个设置为 120 秒和另外一个为 60 秒?这在课程的某一讲提及过,你知道是哪一讲以及原因吗?欢迎在评论区分享你的答案。

活动详情

活动详情和活动列表的实现基本是一样的,核心都是从缓存中获取,代码如图 4 所示。

图 4 活动详情接口

在完成两个接口开发以后,你可以直接访问下面两个地址,就可以看到正常的响应结果了。

java
http://127.0.0.1:3000/act/list?page=0&token=piu12naern9023izcx
http://127.0.0.1:3000/act/detail?id=607bc870647e4cc06f7f3df7&token=piu12naern9023izcx

如果这个过程中出现失败异常情况,你要分别打开 PM2 的日志路径文件,项目根目录下的 log/_act_list.log 和 log/_act_detail.log 日志信息查看具体的错误原因。

票相关接口

由于个人的票券码列表访问比较少,并且每个人访问的列表内容不一样,因此缓存设置的意义不太大,直接从数据库读取就行,没有必要单独为这部分进行缓存了。在项目 src/controller 中创建 ticket.js 用来实现 ticket 类。

个人票券码列表

在 ticket.js 中实现 list 方法,代码实现如图 5 所示。

图 5 个人票券码列表

从图 5 中可以看到,其实现逻辑和上面的活动列表实现非常相似,都是调用 Service 方法逻辑,只是区别在 ticketService 中的实现不同,代码如图 6 所示。

图 6 ticketService.getUserTickList 实现

在图 6 中两个红色框中都是直接 load 了数据库模块来获取数据,没有从缓存中获取。

票券码详情

票券码详情也是一样,这部分我们也是使用的数据库读取的方式来实现,在我们项目源码中已经实现。但是这里我希望你进行改进,在原有的基础上改造为从缓存中读取,这部分留作你自己实现。

在完成以上两个接口以后,我们可以访问以下两个请求来查看接口是否正常。

java
http://127.0.0.1:3000/ticket/list?token=piu12naern9023izcx
http://127.0.0.1:3000/ticket/detail?token=piu12naern9023izcx&code=OPIADCV23

由于我们还没有券码,因此返回都是空的数据,接下来我们来实现抢票,抢票完成后再回过来看两个接口的响应数据。

抢票接口

这部分是核心的实现,我们需要使用到 Redis 的原子操作 lpush lrem lpop 三个指令。

  • lpush:向一个双向链表插入一个数据元素,在我们应用中插入的是一个券码;

  • lrem:在链表中删除指定的元素,在我们应用中需要在插入之前先进行删除,避免重复;

  • lpop:弹出一个数据元素,在我们应用中从链表中获取一个券码。

这三个命令就帮我们实现了一个简单的抢票逻辑: 首先在初始化时,将我们的券码插入 Redis 中,这部分逻辑在 codeService 中的 import 方法中实现,具体代码如图 7 所示。

图 7 import 券码逻辑

在图 7 中的红色框部分,就是调用 codeModel 中的 lpush 将券码插入 Redis 中,而这部分逻辑就是在调用 /tools/init 时完成。在 lpush 中有一个比较取巧的技巧,如图 8 所示。

图 8 lpush 逻辑实现

在图 8 的红色框内,我们需要先删除该项,删除完成后再插入,为什么这么复杂呢? 主要是为了避免插入重复项,如果删除成功代表原来就有该数据,因此不算新插入,如果删除为 0 则表示新插入,这样就可以方便后续的逻辑,比如需要落地数据库,那么重复的就没有必要再次落数据库了。

数据初始化完以后,我们再来看下抢票逻辑是如何实现的,该逻辑实现在 ticket controller 中的 get 方法。

图 9 抢票接口校验实现

图 9 主要是我们的抢票的资格校验,判断该用户是否参与过该项目,或者提交的一个活动是否是非法的(其次还可以根据需求校验该活动是否在生效期内)。校验通过后,就调用 codeService.getOneCode 方法,来抢一张票,如图 10 所示。

图 10 抢票逻辑

图 10 中的红色框部分就是调用 lpop 来实现抢票。抢票完成后,我们需要设置缓存,以方便后续用户再次进入活动时,主动提示用户已经参与过该活动。抢票实现完成后,我们再回过来看抢票后的逻辑。

图 11 抢票数据落地

在上一讲的时序图中我们说明过,只要抢票成功,就代表成功了,后续的一些逻辑数据都使用异步的方式去存储,存储失败则需要记录日志,并定时回写失败数据,比如图 11 中的 historyService.insertHistory 是一个异步执行逻辑,不会阻塞用户成功响应。

以上就完成了抢票部分逻辑,开发完成以后,你只需要调用以下接口就可以测试。

java
http://127.0.0.1:3000/ticket/get?token=piu12naern9023izcx&actId=607bc99b7e96f0c1e8057f3c

响应如图 12 所示。

图 12 抢票结果信息

当你再次调用上面的接口时,就会提醒你已经参与过该活动了。

clinic 检测

在完成了所有接口以后,我们就需要使用 clinic 工具来检测是否存在一些性能问题,我们在 bin/clinic_test.js 中增加需要测试的接口列表,这部分也需要大家自行修改,因为其中的参数可能会不同。

比如我们修改以下配置。

图 13 clinic 测试配置

配置修改完成以后,我们就回到项目根目录,首先需要将 PM2 中的该服务进程关闭,使用下面命令即可关闭(如果你修改过 PM2 中的配置,则需要根据配置中的进程名来进行关闭)。

java
pm2 delete nodejs-main-server-3000

接下来我们运行下面命令启动测试。

java
npm run clinic-test

接下来你会看到如下的结果。

java
启动服务开始测试...
开始检测 act/list 的接口性能问题
该接口无任何异常问题
开始检测 act/detail 的接口性能问题
该接口无任何异常问题
开始检测 ticket/list 的接口性能问题
该接口存在异常
具体详情请查看项目根目录下的
./.clinic/56983.clinic-doctor.html
开始检测 ticket/detail 的接口性能问题
该接口存在异常
具体详情请查看项目根目录下的
./.clinic/57041.clinic-doctor.html
开始检测 ticket/get 的接口性能问题
该接口无任何异常问题
你需要处理以下问题汇总,具体请查看下面详细信息

上面结论很清晰告诉了我们 ticket/list 和 ticket/detail 存在性能问题,我们打开测试结果的 html 文件以后发现主要是 I/O 问题,然后再思考我们没有使用任何的缓存,并且 Mongodb 使用的还是一个远程云服务,因此这部分是的确存在问题的。

其他的几个接口都没有任何问题,因此对于我们来说是否就完事了呢?

虽然 ticket/get 没有问题,但是是基于已经完成抢票的逻辑,只是一个简单的判断,并不能说明可以支持批量用户同时来抢票,那么接下来我们就使用压测的方法,来查看该接口是否能满足高并发的情况。

抢票接口压测

我们需要模拟批量用户,在我们项目中没有用户登录,但是在中间件 checkToken 中做了一个假的用户校验操作,那么我们在该模块中去写一个脚本来随机生成用户 ID(源码中这部分为注释代码,大家尝试时需要主动打开,如图 14 所示)。

我们先请求活动列表,拿到一个可用的活动 ID ,我们获取图 15 中的最后一个活动 ID。

图 15 活动列表

接下来我们多次请求以下链接。

java
http://127.0.0.1:3000/ticket/get?actId=607be8b34bf4efe9f8d04baa

你会发现,每次请求都可以获取新的券码,这样我们每次就可以走不同的逻辑了,为了测试性能,我们使用 wrk 来进行验证,使用下面命令来进行压测。

java
wrk -t2 -c300 -d20s "http://127.0.0.1:3000/ticket/get?actId=607be8b34bf4efe9f8d04baa"

压测完成后,你可以看到数据如下

java
Running 20s test @ http://127.0.0.1:3000/ticket/get?actId=607bc99b7e96f0c1e8057f3c
  2 threads and 300 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    45.99ms   69.01ms   1.99s    98.47%
    Req/Sec     2.82k   655.32     4.71k    78.55%
  106002 requests in 20.07s, 21.53MB read
  Socket errors: connect 0, read 132, write 0, timeout 283
Requests/sec:   5282.13
Transfer/sec:      1.07MB

这个数据和我们第 12 讲的数据进行对比,QPS 在 5282.13 (我们当前启用 1 个进程),平均耗时 45.99 ms 因此整体上我们的抢票接口性能还是非常好的,这里唯一有问题的可能是我们的 Mongodb ,你测试的时候最好在本地搭建环境,避免这类影响。

总结

本讲核心是实践开发活动相关接口、票相关接口以及主要的抢票接口,并利用我们所学的一些知识 clinic 检测和 wrk 压测工具来分析接口的性能问题。

总的来说,Node.js 开发效率还是非常高的,整个系统我大概花费了 1 天半的时间完成这部分演示代码,代码还会存在一些缺陷,你可以尝试应用项目,然后共同来解决这个项目中存在的问题。

实际开发过程中的需求大部分离不开我们通用抢票系统的流程,最核心的是要了解哪些是高并发需要使用缓存处理,哪些是低访问可以直接读取数据库的方式。在实际应用过程中,我们还需要动态的根据现网的访问情况进行扩容,但是每次都需要手动扩容非常的不方便,那有没有弹性的服务机制,在我请求较大时分配更多服务资源,在请求少时减少服务资源呢?那么这就是我们下一讲的内容 serverless 的知识点。