投票系统的重构

曾经用原生 PHP 写过一个功能较为完善的投票系统,但是扩展性和可维护性都非常差,后来学了 Laravel ,打算用 Laravel 来重构这个项目。记录如下:

一、项目要求

1. 功能要求

作为一个简单的投票系统,基本功能要求如下:

  • 活动的创建和控制
  • 候选人的添加和修改
  • 每一个投票参与者的信息记录
  • 每一票的信息记录
  • 票数的可视化展示

然后除此之外,需要让整个投票系统能够尽可能的承受大流量的冲击,保证每一票的正常到位:

  • 能够承受大流量、高并发的流量冲击(做压力测试)
  • 能够自动根据投票的数据,检测投票数据的异常(即刷票检测)

2. 开发工具

  • Laravel 框架
  • Redis
  • Mysql

3. 扩展性

后期整理出开发 API 接口,便于进行小程序版和原生应用的开发。

二、项目设计

1. 对象设计

活动

由管理员添加和修改

候选者

隶属于一个活动,可以由管理员创建或者自主报名

参与者

自动记录每一个活动的每一个参与者

2. 数据采集

票数

每一票的来源和去向以及时间等信息都必需采集好

参与者信息

收集每一个参与者的基本信息,用于进行票的有效性鉴定

三、 数据库

  • 尽量使用满足条件的最小数据类型

1. 数据库设计

数据表设计

activitys_info-活动信息

字段名 数据类型 注释
id int(10) 自增 ID。主键。
uniquekey char(36) 活动唯一ID,UUID, Str::uuid() 获取。唯一。
intro varchar(512) 活动介绍
host varchar 主办方。可选。
undertake varchar 承办方。可选
sponsored varchar 赞助方。可选
refresh_period int 票数刷新周期,默认为 1
refresh_ballot int 每周期可投票数,默认为 1
user_from enum(1,2,3,4) 投票者来源:1-QQ,2-weibo,3-Wechat,4-Other
rules json 活动规则,字符串数组,可选
ava_type json 开放的参赛作品类型,1-4的键值数组并各自对应一个布尔值
creator varchar 活动创建者,来自 users 表的 email
backimg varchar(512) 投票页面的背景图,有默认值
logo varchar(512) 投票页面的logo,有默认值
start_at timestamp 活动开始时间
end_at timestamp 活动结束时间
create_at timestamp 活动创建时间
update_at timestamp 活动上次更新时间

activitys_record-活动记录

字段名 数据类型 注释
id int(10) 主键,自增ID
activity_key char(36) 活动唯一性 key,来自 activitys_info 的 uniquekey, 外键
pv int 浏览量,默认为 0
uv int 浏览人数,默认为 0

candidates_info-候选人信息

字段名 数据类型 注释
id int(10) 主键,自增ID
uniquekey char(36) 候选人唯一ID,UUID, Str::uuid() 获取。唯一。
name varchar 候选单位-名称
tel varchar 手机号,可选
QQ varchar QQ号,可选
intro varchar(512) 候选单位-介绍
belong_ac char(36) 所属活动唯一性 key,来自 activitys_info 的 uniquekey, 外键
type enum(1,2,3,4) 参赛作品类型:1-图片,2-视频,3-音频,4-外链
img_url varchar(512) 图片链接
video_url varchar(512) 视频链接
audio_url varchar(512) 音频链接
link_info json 外链的链接和封面,linkurl + linkcoverurl
create_at timestamp 候选人创建时间
update_at timestamp 候选人上次更新时间

candidates_record-候选人票数

字段名 数据类型 注释
id int(10) 主键,自增ID
candidate_key char(36) 候选人唯一ID,UUID, Str::uuid() 获取。唯一。
ballot int 所得票数,默认 0

####participant_info-参与者信息

字段名 数据类型 注释
id int(10) 主键,自增ID
uniquekey char(36) 参与者唯一ID,UUID, Str::uuid() 获取。唯一。
name varchar 参与者昵称
plat_from enum(1,2,3,4) 参与者来源:1-QQ,2-weibo,3-Wechat,4-Other
detail text 详细信息,从 Oauth2.0 登录的接口获取的信息
create_at timestamp 候选人创建时间

vote_record-投票记录

字段名 数据类型 注释
id int 主键,自增ID
plat_form enum(1,2,3,4) 投票来源:1-QQ,2-weibo,3-Wechat,4-Other
ip char(15) 投票者ip
area varchar 投票者所属地区
vote_at timestamp 投票时间
voter_name varchar 投票者昵称
voter_key char(36) 投票者唯一ID,UUID, Str::uuid() 获取。唯一。外键
activity_key char(36) 投票活动唯一ID,UUID, Str::uuid() 获取。唯一。外键
candidate_key char(36) 投票对象唯一ID,UUID, Str::uuid() 获取。唯一。外键

user-系统管理员

字段名 数据类型 注释
id int 主键,自增ID
name varchar 姓名
email varchar 邮箱
password varchar 密码
role enum(1,2,3,4) 角色:1-超级管理员,2-高级账户,3-一般账户,4-封禁账户

2. 代码部分

###编写数据库迁移

可以顺便写一条 seeder,填充点测试数据。

编写模型绑定数据库

值得注意的是模型除了管理表,还可以互相进行关联,进行更加方便的操作。

举一个具体的 Model 实例:

<?php

namespace App\Models\DB;

use Illuminate\Database\Eloquent\Model;

class Activity extends Model
{
    protected $connection = 'mysql';  // 多数据库操作时最好进行绑定
    protected $table = 'activitys_info'; // 指定表
    protected $primaryKey = 'uniquekey'; // 指定主键
    protected $keyType = 'char'; // 主键数据类型
    public $timestamps = true;  // 是否自动维护时间戳
}

这里其实在数据库中真正的主键是 id,但是我这可以把它设置为 uniquekey,只要它在数据库中是唯一的,就可以使用 find(uniquekey) 等方法。注意还要讲 keyType 改成 uniquekey 对应的字段类型。

四、具体实现及逻辑

1. 安装扩展 JWT、Dingo 、socialite

JWT 实现 api 的 token 授权

dingo实现统一的错误返回

socialite 实现社会化登录

2. 控制器

定义资源控制器和路由

资源路由,有专门的 API 资源路由。

###表单验证

定义了表单验证,值的注意的是,在 Laravel 中,如果表单验证没有通过会有两种情况:

  • 如果是页面表单提交的会重定向,这时候可以利用模板引擎从一个变量中取得错误并显示

  • 如果是 Ajax 调教的会返回一个 Json,如果想用 Postman 测试,要添加一个 header

    X-Requested-With:XMLHttpRequest
    

3. Redis 实现计数器

整个项目中有三个地方要用到计数:uv、pv 以及 ballot,所以计数器的设计很有必要。经过考虑决定用 redis 实现,具体逻辑如下:

读 --> redis
写 --> redis --> 定时或根据条件写入到 mysql

具体使用中需要注意的几点如下:

  • 三者的读写都在 redis 中进行,所以持久化到 mysql 这个操作的频率并不要求太高
  • 三者有根据计数排序的需求,所以可以使用 redis 的有序集合来做
    • zset:ballots:activity_key,元素名为 candidate_key,score 为票数(过期时间为活动结束时间)
    • zset:activity_uv,元素名为 activity_key,score 为 uv(这个不过期没关系)
    • zset:activity_pv,元素名为 activity_key,score 为 pv(这个不过期没关系)

4. 关于投票机会的控制

对于投票机会判断的控制以及投票时限的控制想到的有以下几种方法:

  1. 投票的时候由于会记录 vote_record,所以可以根据条件 count 来判断,只要建立了索引,速度还是很快的。缺点是 vote_record 是一个高速增长的表,系统收集的投票越多,效率会越低,并且高并发的有很大的读写压力;(因为是根据 vote_record 来判断是否具有投票机会,所以这里投票记录得同步记录)

  2. 给每个 voter 增加一个字段表示今日可投票数,然后用定时命令 00:00 更新,更新只会在投票期内更新,截止日最后一秒的更新为把机会全部置 0 ,当然票数更新的时候要上乐观锁或悲观锁。比上一个方法好一点,但仍然存在:高并发时对数据库的读写压力大的问题;(这里 vote_record 可以异步队列去写,但是却有额外的更新剩余机会这个写压力)

  3. 使用 Redis:

    这里同样异步写 vote_record

    • 一个 string:key 为 activity_info:activity_key,值为 period.chance.start.end (四个值用 . 拼接)

      在活动创建或更新的时候存储到 redis 中,设定为过期时间为活动的过期时间。

      是否在活动设定的可投票时间内,只需要看此 string 是否存在于 redis 中即可;

    • 一个 hash:key 为 vote_record:activity_key,里面每个域为 voter_key,值为周期内投过的票数,在刷新票数的时候清空

      是否还剩余可投票机会,由 redis 中周期内投过的票数少于活动设定的 chance 值而判断,初始过期时间为第一个周期结束的时间(活动创建时设置)

    清空 voter_record 有两种办法,一种是设定定时任务,一种是给 voter_record 设定过期时间:

    # 下面给出过期时间的计算过程
    $cycs = floor((time() - $start) /  86400 * $cyc);  // 得到已过去周期数
    $remain = (time() - $start) % (86400 * $cyc);      // 当前在一个新周期内已经过了多久
    $days = floor($remain / 86400);                    // 当前已在新周期类过去的完整天数
    $start = $start + $cycs * $cys * 86400;            // 计算新周期的开始
    $exp = $start + $days * 86400 + ($cyc - $days)* 86400;   // 得出应该过期的时间
    

5. 关于投票请求的效率

投票时,需要传入三个参数:candidate_key、activity_key、voter_key。

如果在请求传入后去 mysql 检查这三个值是否存在于数据库,再用 redis 增加票数,而这个检查操作会成为性能瓶颈,因为高并发情况下,查询迟迟无法成功就无法进行后续操作。所以有必要把这三个值也存入 redis,前面 activity_info:activity_key 已经可以用于 activity_key 的检验,所以还需要两个:

redis 数据对象设计:

  • 一个 set:key 为 candidates:activity_key,值为所有的 candidate_key

    元素在 candidate 创建时加入,整个 key 设置过期时间为活动的过期时间(活动创建时设置)

  • 一个 set:key 为 voters,值为所有的 voter_key

    元素在 voter 首次登陆时加入并同步存储到 mysql,永不过期

关于 voter 热数据设计的构想:

首先讨论 voter 整个 set 的容量会不会爆炸?答案是会的,考虑到多个活动同时进行,当所有正在进行的活动的参与者(点进页面并且第三方登录)达到 1 亿到 10 亿的数量级,内存占用会达到 2.8G 到 28G 左右。

虽然可能只有全民公投才有可能有这种数据量,但是也不是不可能。

所以也可以针对 voter 做如下改动,进行热数据的设计:

一个 string 表示一个 voter,key 为 voter_key,value 为空,过期时间为两个投票周期,并每次受到访问重置为两个投票周期。

检验 voter_key 的有效性就为先从 redis 数据中检查,不存在再从 mysql 中查一次,查不到返回错误,查到了设置到 redis 并设置过期时间为 两个投票周期。

6. 关于投票请求的安全

一般为了刷票,很多攻击手段都会通过伪造请求参数来进行攻击,投票请求的参数有:

  • candidate_key
  • activity_key
  • voter_key

接下来分别讨论:

candidate_key

这个是候选人 ID,如果把这个改了,首先要求改成的必须是正确的一个 candidate_key,否则直接已错误返回,改成别人的就是帮别人投票,所以攻击手段就只有拦截别人投票的包,把 key 改成自己的 key,对于这个解决办法是:把返回到客户端的 candidate_key 加密;

activity_key

这个 key 的改成不存在的 key 会直接错误返回,改成其他活动的 key 会存在一个 bug,即可以通过其他正在进行的 活动 key 来绕过时间限制和投票限制,是个挺严重的 bug。解决办法同样是:加密再返回客户端;

voter_key

这个 key 改成不存在的会直接错误返回,但是如果一个人利用自己申请的第三方权限来收集大量用户 key(微信公众号有这方面的处理,所以安全,其他不清楚) ,且这些 key 的主人也在此系统登录过就可能造成攻击。解决办法:加密再返回客户端;

所以总结就是所有的 key 都要加密再返回客户端或者存在客户端 cookie

五、后台

Voyager

1. 安装和设置

语言设置

config/app.php

'locale' => 'zh_CN'

voyager.php

'multilingual' => [
    /*
     * Set whether or not the multilingual is supported by the BREAD input.
     */
    'enabled' => true,

    /*
     * Set whether or not the admin layout default is RTL.
     */
    'rtl' => false,

    /*
     * Select default language
     */
    'default' => 'zh_CN',

    /*
     * Select languages that are supported.
     */
    'locales' => [
        'zh_CN',
        'en',
        // 'pt',
    ],
],

2. 报错

###2.1 relationLoaded()

Call to a member function relationLoaded() on null

这个一般是因为 user 表中有个 role 字段,造成了干扰,去掉这个字段就可以了。

2.2 date 字段没有 picker

app.js:77 报错。

这是原项目调用的一个 js 有 bug 导致的,比较方便的解决办法是在 master 上加上以下这一段

\vendor\tcg\voyager\resources\views\master.blade.php

<script type="text/javascript" src="{{ voyager_asset('js/app.js') }}"></script>

<!-- 以上是原有的 -->

<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.19.1/moment-with-locales.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.47/js/bootstrap-datetimepicker.min.js"></script>

我自己云服务器提供的,比上面的快一点

<script type="text/javascript" src="http://static.yfree.cc/moment-with-locales.js"></script>
<script type="text/javascript" src="http://static.yfree.cc/bootstrap-datetimepicker.min.js"></script>

这个挺麻烦的,每个新机器上都要去对应目录加上这一行

六、数据安全

Redis 中的数据及时删除和防止误删

七、路由设计

名词用负数

八、日志