本文主要分享内容如下:

  1. 低延迟api设计
  2. 服务自动拉起
  3. 日志转储(硬盘容量问题)
  4. 云redis连接数上限处理问题
  5. redis存储优化,及频控压力处理
  6. 定时任务设计及潜在bug(golang使用ticker.C制定定时任务潜在bug)

项目背景

项目实现特定app组的广告系统,app日活在百万级别,请求量每秒约1k。
与头条的广告系统架构不同的是,其中主要的推荐算法部分我们系统以简单逻辑代替。

低延迟api设计

低延迟api主要实现为三个方面,存储解耦,请求异步,cache。
机器配置为1核1G3M,压测数据,gin处理延迟50us,访问延迟2ms。

存储解耦

图片存储解耦

广告系统通过拉取存储中数据,向客户端提供广告数据。因为带宽压力不足及资源问题,如在服务端直接给用户返回图片在应用中是不可行的。以业务请求1k,其中约200拉取+曝光的比例来看,每秒下行图片200张,广告图片平均500kb的时候要求百兆带宽,实际测试业务场景中,我们图片下行流量峰值(3min平均)约170Mb/s。于是要求大数据上云。及利用云存储来存储图片,我们的服务仅负责轻量的计算和路由转发url。

值得一提的是,由于腾讯云CDN价格是0.2RMB/G,COS汇源0.22RMB/G,COS0.5RMB/G,我们下行流量每天150GB,回源数据每天约50MB,使用CDN流量一方面降低了延迟,另一方面业务运维每个月减少了约1350RMB(0.315030)的流量消耗.

业务数据存储解耦

出于防单点,可伸缩的需求,我们存储全部使用云端存储,没有服务器的本地存储。由于轻量查询Redis的QPS远高于mysql,并且可以添加pipeline,mget这些技术,我们将所有线上数据使用redis提供服务,虽然有连接池的优化,在redis使用过程中同样遇到了许多问题。在后面章节进行分析,优化。

这样我们的广告系统主要仅涉及api为拉取和上报api,其他存储解耦。

请求异步

利用golang优秀的异步业务开发能力,可以将请求异步化程度提高,具体体现如下:

  1. gin框架启动业务协程异步处理请求
  2. 拉取广告直接从cache内拉取,不阻塞等待cache(是否为空)
    • 这样的潜在风险是第一次拉取可能拉取不到未存取的广告,不过对于海量业务来说是可以容忍的
  3. 上报数据只校验客户端需要的合法性,启用异步协程进行流水缓存,batch上报,然后直接返回。

cache

redis需要走网络通信,广告排序亦需要计算复杂度(测试没有做cache的时候一个请求达到几十,几百ms)于是异步cache成为了最显然的优化策略。

1
2
3
4
5
6
7
8
// 临时cache, 每1min进行一次广告抽取及排序
var cacheTime int64
var cacheDeliver map[string]deliverArray

func drawAdvertisementFromRedis(position string) (advLst deliverArray) {
go updateCache(position) // 非阻塞更新
return cacheDeliver[position]
}

这里有几个比较关键的优化策略

  1. 有损更新cache,更新cache逻辑和业务逻辑走异步协程
  2. cache更新时间戳校验(1分钟)也是在更新cache的协程里面实现,不影响业务请求

服务自动拉起

由于golang服务可以直接监听端口,内部做异步处理,没有配置nginx,apache做epoll,于是偶发的进程宕机成为了线上稳定性的灾难。
原生启动脚本:

1
nohup ./bootstrap &

不能应对不可自动恢复问题。

不可自动恢复问题强行panic

(云redis连接数上限处理问题)
初步上线阶段,发现腾讯云redis连接池大小超过一定数目(约10k)后业务请求不到redis,然后报错连接池为空,但是服务不宕机,手动kill服务之后重启即可。初步分析为垃圾链接未释放,但是这个问题对于我们系统是灾难性的,一方面不便于监听状态,一方面业务流失。参考windows立刻宕机理念,我们也就把服务直接在确定位置panic即可:

  • 修改garyburd/redigo源码pool.go,
    1
    2
    3
    4
    5
    6
    // Handle limit for p.Wait == false.
    if !p.Wait && p.MaxActive > 0 && p.active >= p.MaxActive {
    p.mu.Unlock()
    panic(ErrPoolExhausted)
    //TODO: return nil, ErrPoolExhausted
    }

服务自动拉起

  1. 编写拉起脚本,注意这时候stdout和stderr需要输出到/dev/null,不然会有“幽灵硬盘占用”问题,需要定时重启

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    #!/bin/sh

    while true; do
    #启动一个循环,定时检查进程是否存在
    server=`ps aux | grep bootstrap | grep -v grep`
    if [ ! "$server" ]; then
    #如果不存在就重新启动
    sh ./restart.sh
    sleep 5
    nohup ./bootstrap >/dev/null 2>&1 &
    fi
    #每次循环沉睡10s
    sleep 5
    done
  2. 对于启动脚本设计开机任务,防止服务宕机问题

    1
    2
    3
    vim /etc/rc.d/rc.local
    # 添加下面这一行, 也可以将输出路由到/dev/null
    nohup ./hold_process_monitor.sh &
  3. 启动时使用邮件通知管理员

日志转储

日志解耦

对于不同业务模块日志进行解耦,可以直接规定重要性进行转储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func initLog(outputFile string) (log *logrus.Logger) {
log = logrus.New()
log.SetFormatter(&logrus.JSONFormatter{})

file, err := os.OpenFile(outputFile, os.O_CREATE|os.O_WRONLY, 0755)
if err == nil {
log.SetOutput(file)
} else {
panic(err.Error())
}
log.SetLevel(logrus.DebugLevel)
return log
}

timeStrLog := time.Now().Format("200601021504") + ".log"
conf.MLog = initLog("./log/infos_" + timeStrLog)
conf.DLog = initLog("./deduction/deduction_" + timeStrLog)
conf.ELog = initLog("./expires/expires_" + timeStrLog)
conf.HLog = initLog("./log/http_" + timeStrLog)
conf.NLog = initLog("./log/notification_" + timeStrLog)
conf.TLog = initLog("./log/trace_" + timeStrLog)
conf.SLog = initLog("./log/mysql_" + timeStrLog)

重启转储

restart.sh,对于gin框架路由日志直接删除(因为CLB有日志),其他日志转移文件夹

1
2
3
rm -rf gin_record/* 
mv log/* old_log/
mv deduction/* old_deduction/

crontab定时任务转储cos

每日日志量>1g的运行结果和仅仅50g的硬盘空间,逼着我们将日志上云。
这里利用了腾讯云的cosfs挂载目录到本地

1
*/1 * * * * mv /root/qykj/golang-svr/old_log/* /root/cos/go_svr/

cos配置转储归档存储

归档存储价格较低,低于7天日志转储低频,30天日志存储归档。

redis存储优化

频控存储优化(1.8G->100M)

由于我们将很多业务数据保存在redis里面,一开始与产品沟通没有确认,使得最初许多频控数据也放在redis里面。

利用redis原子增方法,我们添加了用户的项目频控,投放频控,总频控,对于每一个,由于没有采用事务,我们又区分了点击曝光和关闭,最初的redis键值设计如下

1
2
3
4
hashmap: 
mapkey: user_expose/click/..._count
key: userid_targetid_expose/click
value: int

以至于资源消耗达到1.8G时候存在一个巨大的hashmap,里面有800w+个key。这样做的好处是支持key *这样危险的操作。

与产品沟通添加过期一定时间不会影响用户体验,修改设计如下

1
2
3
4
hashmap:
mapkey: user_id
key: 编码后的操作
value: int

autoincr封装方法,每次更新expire time,取数据时候也可以用hmget取数据,七天过期,不过需要禁止key *操作

trace_info 压缩

对于用户拉取广告,我们系统存储一个用户访问行为,用于计费和校验。由于访问量很大,一度成为容量的瓶颈。

1
2
3
4
hashmap
mapkey: trace_info
key: trace_id # 返回给客户端,上报时携带
value: json_encode(trace_info)

json_encode 压缩

参考thrift压缩字的策略,我们对于key字段进行编码,这样的话serilize会减少存储压力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//json编码压缩key, 减少redis存储成本
type TraceInfo struct {
xxx int64 `json:"1"` // traceid
xxx int64 `json:"2"`
xxx string `json:"3"` // app uid
xxx string `json:"4"` //
xxx uint `json:"5"` //
xxx uint `json:"6"`
xxx uint `json:"7"`
xxx uint `json:"8"` //
xxx int64 `json:"9"`
xxx string `json:"b"`
ExpireTime float64 `json:"c"`
}

过期时间

由于trace_info这个hashmap没有采用单独id分key的策略,不能使用redis自带的expire,
所以我们添加过期时间字段,然后使用定时任务更新trace_id,同时删除依赖数据缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
for range ticker.C {
update_traceinfo_id()
}

func update_traceinfo_id() {
currentTime := time.Now().Unix()
sqlRedisConn := store_tools.GetStatisRedisConnect()
defer sqlRedisConn.Close()

var traceInfo model.TraceInfo
maxTraceID := traceInfo.GetMaxTraceInfoID()
for {
if conf.MinSyncPullTraceInfo > maxTraceID {
break // 最大
}

traceInfo.Load(conf.MinSyncPullTraceInfo)
if traceInfo.ExpireTime > currentTime {
break
}
if traceInfo.ID > 0 { // 拿到了真实数据
conf.TLog.Info("DEL: " + traceInfo.Serilize())
sqlRedisConn.Do("HDEL", store_tools.TRACE_INFO_MAP, traceInfo.ID)
sqlRedisConn.Do("HDEL", store_tools.ONCE_EXPOSE_TRACE, traceInfo.ID)
}

conf.MinSyncPullTraceInfo = conf.MinSyncPullTraceInfo + 1
}
traceInfo.SetMinTraceInfoID(conf.MinSyncPullTraceInfo)
conf.MLog.Info("update traceinfo id to ", conf.MinSyncPullTraceInfo)
}

ticker.C制定定时任务潜在bug

利用ticker.C同步流水到mysql发现每天线上总有些部分流水没有成功同步。

原始业务逻辑为

1
2
3
4
5
6
ticker := time.NewTicker(time.Minute) // 每分钟同步一次
for range ticker.C {
currentTime := time.Now()
recordTime := currentTime.Add(time.Minute * -1)
go syncAcquisitionData(recordTime, 0)
}

检查日志发现是syncAcquisitionData方法没有进入,也就是说for range ticker.C并没有恰好每分钟进入一次,
在临界条件下,time.Now(),Add(time.Minute * -1)操作使得recordTime“错过”了一分钟数据的上报

由于上报后设定一分钟过期,通过重复尝试(将三个九可用性提升至九个九),暂时监控没有复现此问题

1
2
3
4
5
6
7
8
9
10
11
// 使用1,3,5是考虑互素性。
ticker := time.NewTicker(time.Minute) // 每分钟同步一次
for range ticker.C {
currentTime := time.Now()
recordTime := currentTime.Add(time.Minute * -1)
go syncAcquisitionData(recordTime, 0)
recordTime = currentTime.Add(time.Minute * -3) // 过期时间为1分钟
go syncAcquisitionData(recordTime, 0)
recordTime = currentTime.Add(time.Minute * -5)
go syncAcquisitionData(recordTime, 0)
}