Dec 12, 2018 - 散列函数与分流算法

散列函数

散列函数(hash function)对一种对任意输入,都返回一个固定长度输出的函数。常被用来检测信息的完整性,常用的函数有MD5,SHA1等。下载软件时,有的网站会提供一个md5值,下载完成后可以计算软件的md5值,对比是否与网站上的一致。如果不一致,可能是没下完整,也可以是被黑客”改造后”的软件,尽量不要安装。

散列函数应该有以下特点:

  • 同样的输入,保证会有同样的输出。

  • 很难找到其他的输入,使得它的输出与指定的输出相等。保证如果输入的信息被篡改,那输出的散列值变化的概率几乎为1。

第二个特点被称做“弱抗碰撞性”。碰撞就是说两条不同的信息,散列值相等。理论上碰撞当然是不可避免的,比如MD5函数固定地返回32位字母和数字的组合。 这个组合有(26 + 10)^32种,但输入的信息是无穷多个可能。所以散列函数无法保证不碰撞,只能尽量让输出保持随机性,降低碰撞的概率。

分流算法

分流算法是公司做AB测试系统时,将不同的用户分配到不同实验时使用的算法。分流算法需要做到的效果是:

  • 随机性,保证每个实验的用户群体结构类似

  • 指定时间内,同一个用户被分配到的实验id不会变

这两个特点刚好是散列函数的特长。只要把时间因素加入散列函数,就可以保证在指定时间内,输出的不变性,同时随机性也完全有保证。

实战

import time
import random
from hashlib import md5
SALT = 'add some random salt in hash function'
EXPID_CONF = {'A': 30, 'B': 20, 'C': 50}

def split_stream(uid, expid_conf=None, unchange_time=7 * 24 * 3600):
    """
    @param uid: 用户id
    @param expid_conf: 实验ID和流量配置,默认使用 EXPID_CONF 的配置
    @param unchange_time: 多长时间内保持分流结果不变,默认7天
    """
    expid_conf = expid_conf or EXPID_CONF
    for val in expid_conf.values():
        assert val >= 0
    # 计算随机的hash值
    time_factor = int(time.time() / unchange_time)
    msg = '{uid}+{salt}+{t}'.format(uid=uid, salt=SALT, t=time_factor)
    hash_bytes = md5(msg.encode()).digest()

    # hash值转为数字, 对总流量取模, 保证  0 <= rand_int <= stream_sum
    stream_sum = sum(expid_conf.values())
    rand_int = int.from_bytes(hash_bytes, byteorder='big') % stream_sum

    # 计算分流结果,判断rand_int的取值落在哪个实验区间即可
    stream_seq = sorted(expid_conf.items(), key=operator.itemgetter(1))
    for expid, stream_count in stream_seq:
        if rand_int < stream_count:
            return expid
        rand_int -= stream_count


if __name__ == '__main__':
    # 随便测试
    from collections import Counter
    res = []
    for i in range(0, 10000):
        uid = random.randint(0, 100000)
        res.append(split_stream(uid))
    print(Counter(res))

Dec 11, 2018 - 性能提升: reids与内存缓存

redis是基于内存的数据库,单次查询的速度很快。但要查询的数据分布在不同的key里,且查询字段较多时,速度依然会被拖慢。简单来说,性能分为网络耗时查询耗时

当数据量比较小,更新不频繁,而且查询逻辑复杂时,可以把数据读到内存中,定时从redis更新。这种方法把网络耗时降到0,查询耗时又不会比redis差。

如果数据量比较大,读进内存这一步就很耗时,只能在redis中查询。可以使用pipiline或者lua脚本来减少网络耗时,尽量在一次网络交互就拿到所有查询结果。

内存缓存

将redis中的数据,读到内存中。使用时,直接对自带的内存做计算,并且可以转换成更方便的数据结构进行查询。适用的场景是数据量小,更新不频繁

关键的问题,在于更新内存中的缓存。什么时候更新,如何更新?

带有效期的字典: expiredict

expiringdict 实现了一个带有效期(和长度限制)的字典,可以设置key在多久之后过期。简单的测试:

import time
from expiringdict import ExpiringDict
cache = ExpiringDict(max_age_seconds=3, max_len=10)
cache['test'] = 123
time.sleep(3)
'test' in cache # False

接下来一个简单的函数,就能保证(1)总能拿到有效的数据 (2)过期后自动获取数据,并返回更新后的数据。

def get_cache_data(key):
  global cache
  if key in cache:
    cacahe[key] = 'run function to get data from redis'
  return cacahe[key]

值得注意的是,run function to get data from redis这一步也可以做适当的优化。 如果在redis存贮的数据,是hash或者set等复杂的数据结构,应该把数据序列化成字符,存贮成字符串对象,读取后再反序列化。因为redis对hash结构HGETALL的操作,比GET操作要耗时的多(GET操作是最快的)。做个简单的测试就可以体会到了:

import time
import json

cli = get_redis_cli()

def read_hash():
  t1 = time.time()
  res = cli.hgetall('hash_data')
  print('read_hash time cost: {0:.2f}ms'.format(1000 * (time.time() - t1)))
  return res

def read_kv():
  t1 = time.time()
  res = json.loads(cli.hget('kv_data'))
  print('read_kv time cost: {0:.2f}ms'.format(1000 * (time.time() - t1)))
  return res

if __name__ == '__main__':
  data = {i: i + 1 for i in rang(10000)}
  cli.hmset('hash_data', data)
  cli.set('kv_data', json.dumps(data))
  read_kv(); read_hash()

data比较小时可能差异不明显,测试中长度达到1w,区别就很显著了。

基于共享内存的更新方法

不论怎样,更新内存时run function to get data from redis这一步总会有多余的耗时。有没有可能把这部分也优化掉?

随手查到mmap,这家伙把内存数据映射到一个文件,其他进程可以对文件进行读写操作,进而共享控制同一块内存。

可行的操作是,线下定时更新文件对应的内存数据,线上的N个进程从文件读取更新的数据。这就把内存更新的耗时完全移到线下了,而且多进程共享还能节省内存。唯一的缺点就是,需要再线下维护一个定时任务。

降低网络开销

数据量比较大或者更新频繁时,只能从redis做查询,得到结果。能够优化的,仅仅是减少服务器与redis的通信次数,降低频繁的数据传输的耗时。

两种方案,一是redis内置的pipeline,一次发送多个查询,执行完毕后返回多个查询的结果;二是使用lua脚本,将脚本发送到redis服务器执行。相比pipeline,lua脚本的灵活性更高一些。如果多个查询之间有逻辑依赖(比如如果’test’在某个set里,就直接返回结果,否则再继续查询’test’是否在另一个set里),就是最适合使用lua 的场景了。

pipeline

pipeline使用起来很简单,编写指令,最后execute会把所有指令的结果一起返回。适合多个查询相互独立的场景。

cli = get_strict_redis()
pipe = cli.pipeline()
pipe.sismember('set1', '我在这里吗')
pipe.sismember('set2', '你在哪里啊')
res = pipe.execute()
# [False, False]      # 你们都不在这里

lua脚本

lua是门编程语言,灵活性自然是有保障的。redis提供了对lua环境的支持,可以把lua代码发给redis执行。整体也不算太难,看看语法比葫芦画瓢就行了。

实现一个稍微复杂一点的例子,如果 '我在这里吗' in 'set1'为True,就继续看 '你在哪里啊' in 'set2'是否为True;否则第二个查询就不需要做了,直接返回1。

script = '''local ret = {}
    local exist = redis.call('sismember', 'set1', KEYS[1])
    table.insert(ret, exist)
    if exist1 == 1 then
      table.insert(ret, redis.call('sismember', 'set2', KEYS[2]))
    else
      table.insert(ret, 1)
    end
    return ret
'''
res = rcli.eval(script, 2, '我在这里吗', '你在哪里啊')
# [0, 1]

Dec 11, 2018 - 小程序开发经验

记录一些开发小程序的经验。

自定义组件

tabBar

底栏tabBar是可以自定义的。但如果自定义的话,最好把几个tab对应的页面做成一个单页面。否则切换tab时,由于页面的跳转,底栏tab会有明显的闪烁。

顶部状态栏

顶部状态栏也是可以自定义的,设置"navigationStyle": "custom"即可。但是要注意适配,尤其是像iPhone X,小米8等有刘海的机型。 官方自带的状态栏,高度是会随着机型变化的。

另外,左上角的返回键是无法控制的。小程序内部会维护一个栈(LIFO, Last In First Out)来存贮访问过的页面。 当发生页面跳转时,把上一个页面的路由放入栈中,点击返回键时从栈推出最后访问的页面路由。栈的最大长度时6,也就是页面最多跳转6次。当栈为空时,返回键消失。

官方明令警告,不要尝试修改页面栈,会导致路由以及页面状态错误。一开始把整个小程序做成了单页面,后来悲剧地发现没有返回键了。也没办法自定义,无奈只好重来。

所以自定义状态栏,最多也就能控制下它的高度,字体的大小等等,没有太大的必要。

toast

官方自带的toast组件太不好用了,只有个对号的icon,连个X的icon都舍不得给。而且宽度无法改变,字体长度也有限制。好在自定义toast组件过程中,也没遇到什么问题。

只有一个,在使用wx.setClipboardData复制文本时,复制成功后会自动调用官方自带的toast组件显示内容已复制。这时再唤醒自定义的组件,就两重toast,有些尴尬。解决方法也很简单,手动隐藏掉即可:

wx.setClipboardData({
  data: 'xxxxxx',
  success: () => {
    wx.hideToast()  # 手动隐藏官方的toast
    self.myOwnToast('已复制到剪贴板')
  }
})

cover-view 与 cover-image

cover-view 和 cover-image 是可以覆盖在小程序原生组件之上的文本视图和图片视图。 原生组件指的是像video,mapcanvas等组件。使用时有以下几点需要注意:

  • cover-view标签内部,只能嵌套cover-view,cover-image,button,其他标签无效。

  • 不支持background-image, overflow等样式

其实所有值得注意的点,官方也都列出来了:cover-view.html。 坑爹的是,这些不支持的效果,在开发者工具预览时都是有效的,只有在真机上测试才会失效

Canvas画布

小程序定义了一个新的尺度rpx,所有手机在小程序内部的宽度都是750rpx。写样式的时占满所有宽度就可以width:750rpx。然而当使用canvas绘图时, canvas并不支持rpx,仍然只能用px。若要画图占满整个宽度,只能用以下的方式:

<canvas class='canvas' canvas-id="canvasElement" style="width:px"></canvas>

使用小程序接口动态获取屏幕宽度。

wepy的坑

  • 当修改数据时,记得运行this.$apply()更新页面

  • 没有完整的生命周期,只有一个onLoad事件