layer.js源码分析_极客神殿-CSDN博客_layer源码解析

mikel阅读(494)

来源: layer.js源码分析_极客神殿-CSDN博客_layer源码解析

最近在看layer.js源码,从中得到了一些启发,对于一个框架的设计也有了一定的看法,现在对于这个框架的设计以及其他的问题来说明一下。

layer.js是一个专注于弹出层的框架,这个框架本身可以实现5种弹出层类型,其他的就不多说了,可以去看看它的官网,下面说一下它的主要组织形式:

  1. 首先,这个框架本身就是一个IIFE(立即执行函数表达式),保证了局部环境,避免了全局变量污染的问题
  2. 框架内部主要是三个对象构成,分别是Class构造函数、layer对象、ready对象
  3. 通过window来暴露对外api

以前看过一点JQuery的源码,layer.js的框架结构和JQuery是相同形式,框架内部主要是这三个对象来组成,对于这三个对象上具体的方法以及属性我列举了下,如下图所示:

这里写图片描述

整个框架的结构组织以及脉络还是很清晰的,框架整体的代码量大概1300多行左右,我对这个框架运行的具体流程做了个较为详细的流程,具体流程如下:
这里写图片描述


/*!

 @Title: Layui
 @Description:经典模块化前端框架
 @Site: www.layui.com
 @Author: 贤心
 @License:MIT

 */

;!function(win) {

    "use strict";

    var Lay = function() {
        this.v = '1.0.9_rls'; //版本号
    };

    Lay.fn = Lay.prototype;

    var doc = document,
        config = Lay.fn.cache = {},

        // 获取本js所在目录
        getPath = function() {
            var js = doc.scripts,
                jsPath = js[js.length - 1].src;
            return jsPath.substring(0, jsPath.lastIndexOf('/') + 1);
        }(),

        // 异常提示
        error = function(msg) {
            win.console && console.error && console.error('Layui hint: ' + msg);
        },

        // 检测opera环境
        isOpera = typeof opera !== 'undefined' && opera.toString() === '[object Opera]',

        // 内置模块
        modules = {
            layer : 'modules/layer', //弹层
            laydate : 'modules/laydate', //日期
            laypage : 'modules/laypage', //分页
            laytpl : 'modules/laytpl', //模板引擎
            layim : 'modules/layim', //web通讯
            layedit : 'modules/layedit', //富文本编辑器
            form : 'modules/form', //表单集
            upload : 'modules/upload', //上传
            tree : 'modules/tree', //树结构
            table : 'modules/table', //富表格
            element : 'modules/element', //常用元素操作
            util : 'modules/util', //工具块
            flow : 'modules/flow', //流加载
            carousel : 'modules/carousel', //轮播
            code : 'modules/code', //代码修饰器
            jquery : 'modules/jquery', //DOM库(第三方)  
            mobile : 'modules/mobile', //移动大模块 | 若当前为开发目录,则为移动模块入口,否则为移动模块集合
            'layui.all' : 'dest/layui.all' //PC模块合并版
        };

    config.modules = {}; //记录模块物理路径
    config.status = {}; // 记录已注册的模块集。
    config.timeout = 10; //符合规范的模块请求最长等待秒数
    config.event = {}; //记录模块自定义事件

    // 定义模块
    Lay.fn.define = function(deps, callback) {
        var that = this,
            type = typeof deps === 'function',
            mods = function() {
                // 参数callback,可选,用于回调。
                // 回调参数function,用于回调时,注册模块。
                typeof callback === 'function' && callback(function(app, exports) {
                    // 回调参数function的参数app,必要,代表模块名。
                    // 回调参数function的参数exports,必要,代表模块的接口方法。
                    layui[app] = exports;
                    // config.status,记录已注册的模块集。
                    config.status[app] = true;
                });
                return this;
            };

        // 参数deps,代表依赖的模块集,可选。
        type && (
            callback = deps,
            deps = []
        );

        // 相当于layui['layui.all'] || layui['layui.mobile']
        // 模块名layui.all,代表所有模块。
        // 模块名layui.mobile,代表手机版的所有模块。
        // 如果已经加载所有模块,则直接执行回调。
        if (layui['layui.all'] || (!layui['layui.all'] && layui['layui.mobile'])) {
            return mods.call(that);
        }

        // 方法layui.use,动态加载所依赖的模块集deps。
        that.use(deps, mods);
        return that;
    };

    // 动态加载模块集
    Lay.fn.use = function(apps, callback, exports) {
        var that = this,
        // config.dir,内置文件的基目录,默认值为layui.js的所在目录,需以斜杠结束。
            dir = config.dir = config.dir ? config.dir : getPath;
        var head = doc.getElementsByTagName('head')[0];

        // 参数apps,必要,可以是字符串或数组。
        apps = typeof apps === 'string' ? [ apps ] : apps;

        // 参数apps中存在jquery时,如果页面已加载jQuery1.7+库,则直接使用该库。
        if (window.jQuery && jQuery.fn.on) {
            that.each(apps, function(index, item) {
                if (item === 'jquery') {
                    apps.splice(index, 1);
                }
            });
            layui.jquery = jQuery;
        }

        var item = apps[0],
            timeout = 0;
        // 参数exports,可选。
        exports = exports || [];

        // config.host,格式为“//.../”,默认值为config.dir中的主机,或当前页面的主机。
        config.host = config.host || (dir.match(/\/\/([\s\S]+?)\//)/* 匹配“//.../” */ || [ '//' + location.host + '/' ])[0];

        // apps.length === 0 || (layui['layui.all'] || layui['layui.mobile']) && modules[item]
        // 参数apps,允许为空集。
        // 如果需要加载的模块集为空集,则执行回调。
        // 模块名layui.all,代表所有模块。
        // 模块名layui.mobile,代表手机版的所有模块。
        // modules,代表layui的内置模块集。
        // 如果已经加载所有模块,并且当前模块是layui的内置模块,则当前模块不需要加载。
        if (apps.length === 0
            || (layui['layui.all'] && modules[item])
            || (!layui['layui.all'] && layui['layui.mobile'] && modules[item])
        ) {
            return onCallback(), that;
        }

        // 用于监听文件加载完毕
        function onScriptLoad(e, url) {
            var readyRegExp = navigator.platform === 'PLaySTATION 3' ? /^complete$/ : /^(complete|loaded)$/
            if (e.type === 'load' || (readyRegExp.test((e.currentTarget || e.srcElement).readyState))) {
                config.modules[item] = url;
                head.removeChild(node);
                // 轮询查看当前模块是否已注册,每0.025秒轮询一次,共论询config.timeout秒。
                // config.timeout,文件加载超时,默认值为10秒。
                (function poll() {
                    if (++timeout > config.timeout * 1000 / 4) {
                        return error(item + ' is not a valid module');
                    };
                    config.status[item] ? onCallback() : setTimeout(poll, 4);
                }());
            }
        }

        var node = doc.createElement('script'),
        // config.base,代表扩展模块的JS文件目录,默认值为空串,需要以斜杠结束。
        // modules,代表layui的内置模块集。
        // layui.modules[name],代表模块name的相对路径(不包括后缀.js),默认值为name。
        //         如果当前模块是内置模块,则相对路径相对于config.dir + "lay/"。
        //        如果当前模块是扩展模块,则相对路径相对于config.base。
            url = (modules[item] ? (dir + 'lay/') : (config.base || '')) + (that.modules[item] || item) + '.js';
        node.async = true;
        node.charset = 'utf-8';
        node.src = url + function() {
            // config.version=true时,使用config.v作为版本号,否则自己作为版本号,默认值不启用版本号。
            // config.v,代表版本号,默认值为当前时间。
            // config.version=true,config.v不设置时,使流览器不会加载缓存文件,而是重新加载。
            var version = config.version === true ? (config.v || (new Date()).getTime()) : (config.version || '');
            return version ? ('?v=' + version) : '';
        }();

        // config.modules[name],代表已加载,或正在加载中的模块name的相对路径(不包括后缀.js)。
        if (!config.modules[item]) {
            head.appendChild(node);
            if (node.attachEvent && !(node.attachEvent.toString && node.attachEvent.toString().indexOf('[native code') < 0) && !isOpera) {
                node.attachEvent('onreadystatechange', function(e) {
                    onScriptLoad(e, url);
                });
            } else {
                node.addEventListener('load', function(e) {
                    onScriptLoad(e, url);
                }, false);
            }
        } else {
            // 轮询查看是否加载完毕,每0.025秒轮询一次,共论询config.timeout秒。
            // config.timeout,文件加载超时,默认值为10秒。
            (function poll() {
                if (++timeout > config.timeout * 1000 / 4) {
                    return error(item + ' is not a valid module');
                };
                // config.status,记录已注册的模块集。
                (typeof config.modules[item] === 'string' && config.status[item]) ? onCallback() : setTimeout(poll, 4);
            }());
        }
        config.modules[item] = url;

        //回调
        function onCallback() {
            // 参数exports,记录模块的接口。
            exports.push(layui[item]);
            // 加载下一个模块,如果没有下一个,则执行回调。
            apps.length > 1 ? that.use(apps.slice(1), callback, exports) : (typeof callback === 'function' && callback.apply(layui, exports));
        }

        return that;

    };

    // 获取节点的style属性值
    Lay.fn.getStyle = function(node, name) {
        var style = node.currentStyle ? node.currentStyle : win.getComputedStyle(node, null);
        return style[style.getPropertyValue ? 'getPropertyValue' : 'getAttribute'](name);
    };

    // 动态加载CSS
    Lay.fn.link = function(href, fn, cssname) {
        var that = this,
            link = doc.createElement('link');
        var head = doc.getElementsByTagName('head')[0];

        // 参数fn,可选。
        if (typeof fn === 'string')
            cssname = fn;

        // 参数cssname,用于标识CSS文件的ID,默认值为href。
        var app = (cssname || href).replace(/\.|\//g, '');
        var id = link.id = 'layuicss-' + app,
            timeout = 0;

        link.rel = 'stylesheet';
        // config.debug=true时,使流览器不会加载缓存文件。
        link.href = href + (config.debug ? '?v=' + new Date().getTime() : '');
        link.media = 'all';

        // 参数cssname,同一ID的CSS文件的只许加载一次。
        if (!doc.getElementById(id)) {
            head.appendChild(link);
        }

        // 参数fn,用于监听CSS加载完毕。
        if (typeof fn !== 'function') return;
        // 轮询查看是否加载完毕,每0.1秒轮询一次,共论询config.timeout秒。
        (function poll() {
            if (++timeout > config.timeout * 1000 / 100) {
                return error(href + ' timeout');
            };
            parseInt(that.getStyle(doc.getElementById(id), 'width')) === 1989 ? function() {
                fn();
            }() : setTimeout(poll, 100);
        }());
    };

    // css内部加载器
    Lay.fn.addcss = function(firename, fn, cssname) {
        // 全局配置dir,用于内置文件的基目录,默认值为layui.js所在的目录,需要以斜杠结束。
        layui.link(config.dir + 'css/' + firename, fn, cssname);
    };

    // 图片预加载
    Lay.fn.img = function(url, callback, error) {
        var img = new Image();
        img.src = url;
        if (img.complete) {
            return callback(img);
        }
        img.onload = function() {
            img.onload = null;
            callback(img);
        };
        img.onerror = function(e) {
            img.onerror = null;
            error(e);
        };
    };

    // 全局配置
    Lay.fn.config = function(options) {
        options = options || {};
        for (var key in options) {
            config[key] = options[key];
        }
        return this;
    };

    // layui.modules[name],代表模块name的相对路径(不包括后缀.js),默认值为name。
    Lay.fn.modules = function() {
        var clone = {};
        for (var o in modules) {
            clone[o] = modules[o];
        }
        return clone;
    }();

    // 设置模块的相对路径(不含后缀.js)
    Lay.fn.extend = function(options) {
        var that = this;

        options = options || {};
        for (var o in options) {
            // layui[name],如果存在,则表示模块name已注册。
            // layui.modules[name],代表模块name的相对路径(不包括后缀.js),默认值为name。
            // 已注册或已设置相对路径的模块集,不允许再设置相对路径。显然,内置模块的相对路径不允许更改。
            if (that[o] || that.modules[o]) {
                error('\u6A21\u5757\u540D ' + o + ' \u5DF2\u88AB\u5360\u7528');
            } else {
                that.modules[o] = options[o];
            }
        }
        return that;
    };

    // 路由
    Lay.fn.router = function(hash) {
        var hashs = (hash || location.hash).replace(/^#/, '').split('/') || [];
        var item,
            param = {
                dir : []
            };
        for (var i = 0; i < hashs.length; i++) {
            item = hashs[i].split('=');
            /^\w+=/.test(hashs[i]) ? function() {
                if (item[0] !== 'dir') {
                    param[item[0]] = item[1];
                }
            }() : param.dir.push(hashs[i]);
            item = null;
        }
        return param;
    };

    // 本地存储
    Lay.fn.data = function(table, settings) {
        table = table || 'layui';

        if (!win.JSON || !win.JSON.parse) return;

        //如果settings为null,则删除表
        if (settings === null) {
            return delete localStorage[table];
        }

        settings = typeof settings === 'object'
            ? settings
            : {
                key : settings
            };

        try {
            var data = JSON.parse(localStorage[table]);
        } catch (e) {
            var data = {};
        }

        if (settings.value)
            data[settings.key] = settings.value;
        if (settings.remove)
            delete data[settings.key];
        localStorage[table] = JSON.stringify(data);

        return settings.key ? data[settings.key] : data;
    };

    // 设备信息
    Lay.fn.device = function(key) {
        var agent = navigator.userAgent.toLowerCase();

        //获取版本号
        var getVersion = function(label) {
            var exp = new RegExp(label + '/([^\\s\\_\\-]+)');
            label = (agent.match(exp) || [])[1];
            return label || false;
        };

        var result = {
            os : function() { //底层操作系统
                if (/windows/.test(agent)) {
                    return 'windows';
                } else if (/linux/.test(agent)) {
                    return 'linux';
                } else if (/iphone|ipod|ipad|ios/.test(agent)) {
                    return 'ios';
                }
            }(),
            ie : function() { //ie版本
                return (!!win.ActiveXObject || "ActiveXObject" in win) ? (
                    (agent.match(/msie\s(\d+)/) || [])[1] || '11' //由于ie11并没有msie的标识
                    ) : false;
            }(),
            weixin : getVersion('micromessenger') //是否微信
        };

        //任意的key
        if (key && !result[key]) {
            result[key] = getVersion(key);
        }

        //移动设备
        result.android = /android/.test(agent);
        result.ios = result.os === 'ios';

        return result;
    };

    // 提示
    Lay.fn.hint = function() {
        return {
            error : error
        }
    };

    // 遍历
    Lay.fn.each = function(obj, fn) {
        var that = this,
            key;
        if (typeof fn !== 'function') return that;
        obj = obj || [];
        if (obj.constructor === Object) {
            for (key in obj) {
                if (fn.call(obj[key], key, obj[key])) break;
            }
        } else {
            for (key = 0; key < obj.length; key++) {
                if (fn.call(obj[key], key, obj[key])) break;
            }
        }
        return that;
    };

    // 阻止事件冒泡
    Lay.fn.stope = function(e) {
        e = e || win.event;
        e.stopPropagation ? e.stopPropagation() : e.cancelBubble = true;
    };

    // 自定义模块事件
    Lay.fn.onevent = function(modName, events, callback) {
        if (typeof modName !== 'string'
            || typeof callback !== 'function') return this;
        config.event[modName + '.' + events] = [ callback ];

        //不再对多次事件监听做支持
        /*
        config.event[modName + '.' + events] 
          ? config.event[modName + '.' + events].push(callback) 
        : config.event[modName + '.' + events] = [callback];
        */

        return this;
    };

    // 执行自定义模块事件
    Lay.fn.event = function(modName, events, params) {
        var that = this,
            result = null,
            filter = events.match(/\(.*\)$/) || []; //提取事件过滤器
        var set = (events = modName + '.' + events).replace(filter, ''); //获取事件本体名
        var callback = function(_, item) {
            var res = item && item.call(that, params);
            res === false && result === null && (result = false);
        };
        layui.each(config.event[set], callback);
        filter[0] && layui.each(config.event[events], callback); //执行过滤器中的事件
        return result;
    };

    win.layui = new Lay();

}(window);

使用 Redis 实现一个轻量级的搜索引擎,牛逼啊! - 入她程序员 - 博客园

mikel阅读(508)

来源: 使用 Redis 实现一个轻量级的搜索引擎,牛逼啊! – 入她程序员 – 博客园

场景

大家如果是做后端开发的,想必都实现过列表查询的接口,当然有的查询条件很简单,一条 SQL 就搞定了,但有的查询条件极其复杂,再加上库表中设计的各种不合理,导致查询接口特别难写,然后加班什么的就不用说了(不知各位有没有这种感受呢~)。
下面以一个例子开始,这是某购物网站的搜索条件,如果让你实现这样的一个搜索接口,你会如何实现?(当然你说借助搜索引擎,像 Elasticsearch 之类的,你完全可以实现。但我这里想说的是,如果要你自己实现呢?)

从上图中可以看出,搜索总共分为6大类,每大类中又分了各个子类。这中间,各大类条件之间是取的交集,各子类中有单选、多选、以及自定义的情况,最终输出符合条件的结果集。
好了,既然需求很明确了,我们就开始来实现。关注公众号Java技术栈回复面试,可以获取整理的 Redis 系列面试题及全部答案。
实现1

率先登场是小A同学,他是写 SQL 方面的“专家”。小A信心满满的说:“不就是一个查询接口吗?看着条件很多,但凭着我丰富的 SQL 经验,这点还是难不倒我的。”
于是乎就写出了下面这段代码(这里以 MYSQL 为例):
select … from table_1
left join table_2
left join table_3
left join (select … from table_x where …) tmp_1

where …
order by …
limit m,n
代码在测试环境跑了一把,结果好像都匹配上了,于是准备上预发。这一上预发,问题就开始暴露出来。
预发为了尽可能的逼真线上环境,所以数据量自然而然要比测试大的多。所以这么一个复杂的 SQL,它的执行效率可想而知。测试同学果断把小A的代码给打了回来。
实现2

总结了小A失败的教训,小B开始对SQL进行了优化,先是通过了explain关键字进行SQL性能分析,对该加索引的地方都加上了索引。同时将一条复杂SQL拆分成了多条SQL,计算结果在程序内存中进行计算。这篇Explain 最完整总结,推荐看下。
伪代码如下:
$result_1 = query(‘select … from table_1 where …’);
$result_2 = query(‘select … from table_2 where …’);
$result_3 = query(‘select … from table_3 where …’);

$result = array_intersect($result_1, $result_2, $result_3, …);
这种方案从性能上明显比第一种要好很多,可是在功能验收的时候,产品经理还是觉得查询速度不够快。MySQL 实现一个简单版搜索引擎,这篇推荐看下。
小B自己也知道,每次查询都会向数据库查询多次,而且有些历史原因,部分条件是做不到单表查询的,所以查询等待的时间是避免不了的。
实现3

小C从上面的方案中看到了优化的空间。他发现小B在思路上是没问题的,将复杂条件拆分,计算各个子维度的结果集,最后将所有的子结果集进行一个汇总合并,得到最终想要的结果。
于是他突发奇想,能否事先将各个子维度的结果集给缓存起来,这要查询的时候直接去取想要的子集,而不用每次去查库计算。
这里小C采用 Redis 来存储缓存数据,用它的主要原因是,它提供了多种数据结构,并且在 Redis 中进行集合的交并集操作是一件很容易的事情。
具体方案,如图所示:

这里每个条件都事先将计算好的结果集ID存入对应的key中,选用的数据结构是集合(Set)。查询操作包括:
子类单选:直接根据条件 key,获取对应结果集;
子类多选:根据多个条件 Key,进行并集操作,获取对应结果集;
最终结果:将获取的所有子类结果集进行交集操作,得到最终结果;
这其实就是所谓的反向索引。
这里会发现,漏了一个价格的条件。从需求中可知,价格条件是个区间,并且是无穷举的。所以上述的这种穷举条件的 Key-Value 方式是做不到的。这里我们采用 Redis 的另一种数据结构进行实现,有序集合(Sorted Set):

将所有商品加入 Key 为价格的有序集合中,值为商品ID,每个值对应的分数为商品价格的数值。这样在 Redis 的有序集合中就可以通过ZRANGEBYSCORE命令,根据分数(价格)区间,获取相应结果集。
至此,方案三的优化已全部结束,将数据的查询与计算通过缓存的手段,进行了分离。在每次查找时,只需要简单的查找 Redis 几次就能得出结果。查询速度上符合了验收的要求。
扩展

分页
这里你或许发现了一个严重的功能缺陷,列表查询怎么能没有分页。是的,我们马上来看 Redis 是如何实现分页的。关注公众号Java技术栈回复面试,可以获取整理的 Redis 系列面试题及全部答案。
分页主要涉及排序,这里简单起见,就以创建时间为例。
如图所示:

图中蓝色部分是以创建时间为分值的商品有序集合,蓝色下方的结果集即为条件计算而得的结果,通过ZINTERSTORE命令,赋结果集权重为0,商品时间结果为1,取交集而得的结果集赋予创建时间分值的新有序集合。对新结果集的操作即能得到分页所需的各个数据:
页面总数为:ZCOUNT命令
当前页内容:ZRANGE命令
若以倒序排列:ZREVRANGE命令
数据更新
关于索引数据更新的问题,有两种方式来进行。一种是通过商品数据的修改,来即时触发更新操作,一种是通过定时脚本来进行批量更新。
这里要注意的是,关于索引内容的更新,如果暴力的删除 Key,再重新设置 Key。因为 Redis 中两个操作不会是原子性进行的,所以中间可能存在空白间隙,建议采用仅移除集合中失效元素,添加新元素的方式进行。
性能优化
Redis 是内存级操作,所以单次的查询会很快。但是如果我们的实现中会进行多次的 Redis 操作,Redis 的多次连接时间可能是不必要时间消耗。通过使用MULTI命令,开启一个事务,将 Redis 的多次操作放在一个事务中,最后通过EXEC来进行原子性执行(注意:这里所谓的事务,只是将多个操作在一次连接中执行,如果执行过程中遇到失败,是不会回滚的)。
总结

这里只是一个采用 Redis 优化查询搜索的一个简单 Demo,和现有的开源搜索引擎相比,它更轻量,学习成本页相应低些。其次,它的一些思想与开源搜索引擎是类似的,如果再加上词语解析,也可以实现类似全文检索的功能。
总结了一些2020年的面试题,这份面试题的包含的模块分为19个模块,分别是: Java 基础、容器、多线程、反射、对象拷贝、Java Web 、异常、网络、设计模式、Spring/Spring MVC、Spring Boot/Spring Cloud、Hibernate、MyBatis、RabbitMQ、Kafka、Zookeeper、MySQL、Redis、JVM 。

获取资料以上资料:关注公众号:有故事的程序员,获取学习资料。
记得点个关注+评论哦~

利用redis实现多属性快速查询 - SegmentFault 思否

mikel阅读(535)

来源: 利用redis实现多属性快速查询 – SegmentFault 思否

前言

拿京东举例,如下图

我们要找一款电子琴,牌子有:雅马哈、卡西欧,价格有各种区间,各种颜色、不同的音色数。

现如今动不动就得整点高并发啥的,直接用mySQL我们是不是真的扛不住?在前面加一层cache?怎么加?各种属性的组合存到一个属性组合成的key中?如何相对实时的更新属性?

之前的文章我有介绍过redissetbitbitop的使用方法,就是将某一位标记为1或者0代表存在不存在,然后利用bitop进行AND或者OR计算,得到我们想要的结果,今天我们就从零开始打造一个“高性能”的属性筛选器!

按属性储存数据

假设现在我们有三款电子琴,一款雅马哈、两款卡西欧,具体的属性表格为:

ID 品牌 颜色 价格 音色
1 雅马哈 红色 1000 100
2 卡西欧 黑色 2000 150
3 卡西欧 白色 2000 200

我们将属性+属性值组合为key,ID为对应的某位偏移量,这样使用下面的语句初始化数据到redis

//初始化品牌
$redis->setBit('brand-雅马哈', 1, 1);
$redis->setBit('brand-卡西欧', 2, 1);
$redis->setBit('brand-卡西欧', 3, 1);

//初始化颜色
$redis->setBit('color-红色', 1, 1);
$redis->setBit('color-黑色', 2, 1);
$redis->setBit('color-白色', 3, 1);

//初始化价格
$redis->setBit('price-1000', 1, 1);
$redis->setBit('price-2000', 2, 1);
$redis->setBit('price-2000', 3, 1);

......

随意组合属性筛选

我想要搜一下,2000元的白色卡西欧,只需要这样

$redis->bitop('AND', 'cacheKey', 'brand-卡西欧', 'color-白色');
$redis->bitop('AND', 'cacheKey1', 'cacheKey', 'price-2000');

结果cacheKey1的二进制形式为001,这样我们就知道搜索的结果是ID为3的商品。

然而redis并没有提供查询哪些位位1的方法,我们只能通过get方法将内容获取出来,自己处理。提供一段参考代码:

$bit = $redis->get($cacheKey);

$bitLength = strlen($bit);
//redis返回的数据长度可能不是8的倍数,为了方便解包,我们将它补齐
while($bitLength % 8 != 0) {
    $bitLength++;
}

$bit = str_pad($bit, $bitLength, pack('N', 0));
$bit = unpack('N*', $bit);
$bit = array_filter($bit);
$ids = [];
foreach($bit as $k => $b) {
    $bitPos = [];
    while($b) {
        $bin = sprintf('%032s', decbin($b));
        $bitPos[] = strrpos($bin, '1');
        $b &= ($b - 1);
    }

    foreach($bitPos as $pos) {
        $ids[] = ($k - 1) * 32 + $pos;
    }

}

我在本地试了一下,20W的数据(单个属性-属性值redis占用大概24k),同时搜索4个属性只需要不到10ms,当然现实中肯定没这么理想,但效果一定不会太差。

优化setbit

如果商品和属性过多,对redis的写入压力是相当大的(商品数属性数属性值数的写入数),我们可以先自行组合成字符串,然后单个属性-属性值对写入,具体实现细节就不写了,就是利用pack函数打包。

Redis for .NET 系列之实现分页需求 - b̶i̶n̶g̶.̶ - 博客园

mikel阅读(571)

来源: Redis for .NET 系列之实现分页需求 – b̶i̶n̶g̶.̶ – 博客园

代码笔记:

                var tableName = "Table1";
                redisClient.AddItemToSortedSet(tableName, "1", 1);
                redisClient.AddItemToSortedSet(tableName, "2", 2);
                redisClient.AddItemToSortedSet(tableName, "3", 3);
                var pageIndex = 1;
                var pageSize = 5 ;
                //分页查询
                var value = redisClient.GetRangeFromSortedSetDesc(tableName, (pageIndex - 1) * pageSize, (pageSize * pageIndex) - 1);

 

ServiceStack.Redis高效封装和简易破解 - 东汉 - 博客园

mikel阅读(698)

来源: ServiceStack.Redis高效封装和简易破解 – 东汉 – 博客园

 1.ServiceStack.Redis封装

封装的Redis操作类名为RedisHandle,如下代码块(只展示部分代码),它的特点:

1)使用连接池管理连接,见代码中的PooledClientManager属性。如果不用连接池,而是代码直接RedisClient client = new RedisClient(“localhost”, 6379, “password”);去获取一个连接实例操作,那么当Redis操作频繁时,代价很大,不可行。

2)支持读写分离的Redis服务端(如果你只用一个Redis服务端,那么读写服务端连接字符串一样即可)。

3)操作Redis时,自动切换读写Redis连接实例,见代码中的GetRedisClient函数,所有写操作取“写连接实例”PooledClientManager.GetClient(),所有读操作取“读连接实例”PooledClientManager.GetReadOnlyClient()。

注意:如果你读写是两个做了主从复制的Redis服务端,那么要考虑主从复制是否有延迟,是否有一些读操作要求实时数据,如果是,那么需要在GetXX读数据时用写连接实例。这时候,可以改写此GetXX函数,可在函数参数末尾增加 bool? isReadOnly = null 带默认值的参数,即支持外部调用指定用哪种连接实例操作。这种情况一般是系统把Redis当作一个NoSQL数据库;而更多时候我们系统是把Redis当作一个缓存,不需要做主从复制,读写连接实例指向的是同一个Redis服务端,当系统比较大时可能会用到缓存集群(比如一致性哈希缓存等)。

4)后继如果Redis需要做一致性哈希等集群,那么可以实例化多个RedisHandle实例,然后撰写算法来取相应的RedisHandle实例。

复制代码
  1 namespace NetDh.RedisUtility
  2 {
  3     /*
  4      * 一个RedisHandle实例对应一个Redis服务端或者一组主从复制Redis服务端。
  5      * 如果Redis需要做一致性哈希等集群,则要自己撰写算法来取相应的RedisHandle实例。
  6      */
  7 
  8     /// <summary>
  9     /// Redis操作类
 10     /// </summary>
 11     public class RedisHandle
 12     {
 13         /// <summary>
 14         /// Redis连接池管理实例
 15         /// </summary>
 16         public PooledRedisClientManager PooledClientManager { get; set; }
 17 
 18         /* 如果你的需求需要经常切换Redis数据库,则可把Db当属性,这样每一个RedisHandle实例可以对应操作某Redis的某个数据库。此时,可在构造函数中增加int db参数。*/
 19         ///// <summary>
 20         ///// 一个Redis服务端默认有16个数据库,默认都是用第0个数据库。如果需要切换数据库,则传入db值(0~15)
 21         ///// </summary>
 22         //public int Db { get; set; }
 23 
 24         /// <summary>
 25         /// 构造函数
 26         /// </summary>
 27         public RedisHandle()
 28         {
 29             #region 此代码为创建“连接池示例”,配置信息直接用静态类RedisClientConfig1承载,你也可以选择用配置文件承载
 30             var config = new RedisClientManagerConfig
 31             {
 32                 AutoStart = true,
 33                 MaxWritePoolSize = RedisClientConfig1.MaxWritePoolSize,
 34                 MaxReadPoolSize = RedisClientConfig1.MaxReadPoolSize,
 35                 DefaultDb = RedisClientConfig1.DefaultDb,
 36             };
 37             //如果你只用到一个Redis服务端,那么配置读写时就指定一样的连接字符串即可。
 38             PooledClientManager = new PooledRedisClientManager(RedisClientConfig1.ReadWriteServers
 39                 , RedisClientConfig1.ReadOnlyServers, config)
 40             {
 41                 ConnectTimeout = RedisClientConfig1.ConnectTimeout,
 42                 SocketSendTimeout = RedisClientConfig1.SendTimeout,
 43                 SocketReceiveTimeout = RedisClientConfig1.ReceiveTimeout,
 44                 IdleTimeOutSecs = RedisClientConfig1.IdleTimeOutSecs,
 45                 PoolTimeout = RedisClientConfig1.PoolTimeout
 46             };
 47             #endregion
 48         }
 49         /// <summary>
 50         /// 构造函数
 51         /// </summary>
 52         /// <param name="poolManager">连接池,外部传入自己创建的PooledRedisClientManager连接池对象,
 53         /// 可以把其它RedisHandle实例的PooledClientManager传入,共用连接池</param>
 54         public RedisHandle(PooledRedisClientManager poolManager)
 55         {
 56             PooledClientManager = poolManager;
 57 
 58         }
 59         /// <summary>
 60         /// 获取Redis客户端连接对象,有连接池管理。
 61         /// </summary>
 62         /// <param name="isReadOnly">是否取只读连接。Get操作一般是读,Set操作一般是写</param>
 63         /// <returns></returns>
 64         public RedisClient GetRedisClient(bool isReadOnly = false)
 65         {
 66             RedisClient result;
 67             if (!isReadOnly)
 68             {
 69                 //RedisClientManager.GetCacheClient()会返回一个新实例,而且只提供一小部分方法,它的作用是帮你判断是否用写实例还是读实例
 70                 result = PooledClientManager.GetClient() as RedisClient;
 71             }
 72             else
 73             {
 74                 //如果你读写是两个做了主从复制的Redis服务端,那么要考虑主从复制是否有延迟。有一些读操作是否是即时的,需要在写实例中获取。
 75                 result = PooledClientManager.GetReadOnlyClient() as RedisClient;
 76             }
 77             //如果你的需求需要经常切换Redis数据库,则下一句可以用。否则一般都只用默认0数据库,集群是没有数据库的概念。
 78             //result.ChangeDb(Db);
 79             return result;
 80         }
 81 
 82         #region 存储单值 key-value,其中value是string,使用时如果value是int,可以把比如int转成string存储
 83         public void SetValue(string key, string value, int expirySeconds = -1)
 84         {
 85             using (RedisClient redisClient = GetRedisClient())
 86             {
 87                 //redisClient.SetEntry(key, value, expireIn);
 88                 if (expirySeconds == -1)
 89                 {
 90                     redisClient.SetValue(key, value);
 91                 }
 92                 else
 93                 {
 94                     redisClient.SetValue(key, value, new TimeSpan(0, 0, 0, expirySeconds));
 95                 }
 96             }
 97         }
 98 
 99         public string GetValue(string key)
100         {
101             using (RedisClient redisClient = GetRedisClient(true))
102             {
103                 var val = redisClient.GetValue(key);
104 
105                 return val;
106             }
107         }
108 
109         public bool Remove(string key)
110         {
        ...
复制代码

5)在GetRedisClient函数中有句注释的代码//result.ChangeDb(Db);。其中,ChangeDb是切换Redis数据库(Redis默认有16个数据库,见redis-server.exe目录下的redis.conf配置文件中的“databases 16”)。我们一般默认都是用第0个数据库,如果需要切换数据库,则传入Db值(0~15)。我这边一般不会用到切换数据库的需求,如果你的需求需要经常切换Redis数据库,此句可用。否则一般都只用默认0数据库,集群是没有数据库的概念。

为了说明一个Redis服务端有多个数据库以及数据库之间的切换,做个小示例,如下图,我在Redis的第0个数据库存放了键值对数据”test2:1″,当我切到第1个数据库ChangeDb(1)时,GetValue(“test2”)返回的是null,当切回第0个数据库时,就取到1的值。

现在用命令登录Redis再演示一遍这个过程,如下图:

6)RedisHandle操作类包含的操作,大致如下图,Redis支持的数据类型比Memcache多,而且很实用,如果你的系统存取缓存会涉及比较复杂的逻辑,推荐使用Redis,Memcache能的Redis都能。

完整的源码请参考:https://gitee.com/donghan/NetDh-Framework/tree/master/Data/NetDh.RedisUtility

此工具类已经并到我的NetDh框架项目中,NetDh框架码云地址:https://gitee.com/donghan/NetDh-Framework

 

2.ServiceStack.Redis破解

我这边封装的是ServiceStack.Redis最新版本5.7.0,它在4.0版本之后就商业化,有做限制:每小时只能有6000次的Redis访问。网上有对ServiceStack.Redis和StackExchange.Reids进行比较,结果是前者性能比较好,不管真假,我是ServiceStack.Redis 3.x就开始用它了,一如既往继续用呗,有限制就破解呗。

步骤:

1)限制6000次是在ServiceStack.Text.dll中,而且在两个地方,用ILSpy打开ServiceStack.Text.dll,在搜索栏输入“RedisRequestPerHour”,可以看到RedisRequestPerHour=6000的限制,如下图(第1步你可不做,看看就好):

再搜索“AssertValidUsage”,发现另一个地方的6000次限制,如下图:

2)下载一个十六进制编辑器,我网上找的是wxMEdit工具(下载页面:http://wxmedit.github.io/downloads.html)。

3)先备份ServiceStack.Text.dll,用十六进制编辑器打开ServiceStack.Text.dll。

分析:6000转换成字节形式是 70 17 00 00(虽然6000的16进制是00001770),int的最大值2147483647转换成字节形式是 FF FF FF 7F,所以只要把70 17 00 00替换成FF FF FF 7F即可。

如下图,替换之前点了“查找下一个”发现全局就两个地方,那就确定是要修改的值,然后点击“替换”两次,ctrl+s保存文件,dll修改完成。

4)再用ILSpy看这两个值,已经修改了,如下图(第4步你也可不做,看看就好):

5)把修改的dll覆盖原来dll,最好在IDE中把原来的引用移除,重新添加引用一次,以防有缓存执行的还是旧的dll。编写如下代码测试:

覆盖dll之前会报6000限制,覆盖之后输出ok正常:

完美,点赞!

使用redis的zset实现高效分页查询(附完整代码) - 东汉 - 博客园

mikel阅读(639)

来源: 使用redis的zset实现高效分页查询(附完整代码) – 东汉 – 博客园

一、需求

移动端系统里有用户和文章,文章可设置权限对部分用户开放。现要实现的功能是,用户浏览自己能看的最新文章,并可以上滑分页查看。

 

二、数据库表设计

涉及到的数据库表有:用户表TbUser、文章表TbArticle、用户可见文章表TbUserArticle。其中,TbUserArticle的结构和数据如下图,字段有:自增长主键id、用户编号uid、文章编号aid。

 

自增长主键和分布式增长主键如何选(题外讨论):

TbUserArticle的主键是自增id,它有个缺陷是,当你的数据库有主从复制时,主从库的自增可能因死锁等原因导致不同步。不过,我们可以知道,这里的TbUserArticle的主键id不会用在其它表里,所以可以是自增id。不像用户表的主键,它就不能用自增id,因为用户表主键(uid)会经常出现在其它表中,当主从库自增不一致时,很多有uid字段的表数据在从库中就不正确了。用户表主键最好是用分布式增长主键算法生成的id(比如Snowflake雪花算法)。

那么你可能就要说了,TbUserArticle的主键为什么不直接用雪花算法产生,不管有没有用,先让主从库主键值一致总是有恃无恐。要知道,雪花算法产生的id一般是18位,而redis的zset的score是double类型,只能表达到16位”整数”部分(精确的说是9007199254740992=2的53次方)。因此,TbUserArticle的主键选择自增id。那么能不能产生一个16位(具体是53bit)的分布式增长id用于支持zset的score呢,当然也是可以的,因为目前的雪花算法是可以根据实际系统环境压缩bit位的,怎么压缩bit位呢,有许多方案,以后有需要我可以把它写出来。

建议:主键一般都要选自增id或分布式增长id,这种主键好处多多,它符合自增长(物理存储时都是在末尾追加数据,减少数据移动)、唯一性、长度小、查询快的特性,是聚集索引的很好选择。

 

三、redis缓存设计-zset

zset的作法及其优点说明:

1.zset的score倒序取数可以很好的满足取最新数据的需求。

2.用TbUserArticle的文章编号当value,用自增长id当score。自增id的唯一性可很方便的取下一页数据,直接取小于上次最后一笔的score即可(用lastScore表示)。而如果用文章的时间做score,则要考虑两笔文章的时间是同分同秒问题,当lastScore落在同分同秒的两篇文章之间时,就尴尬了,虽然有解,但麻烦了一点。有时的场景你用不了自增id当score,只能用文章时间,那怎么解决呢,方案就是当是同分同秒时,再根据文章编号做比较就好了,zset的score相同时,也是再根据value排序的,这块的代码实现请看下文第五点,只需稍微改点代码即可。

3.当新增或重新添加一项时,zset也会保持score排序。而如果用的是redis的list,一般就得从db重载缓存,新增进来的数据项就算是最新的,也不敢直接添加到list第一笔,因为并发情况下,保证不了最新就是在第一笔;至于重新添加进非最新项,那更是要从db取数重新装载缓存(一般是直接删除缓存,要用的时候才装载)。

4.第一次从db加载数据到zset时,可只取前N笔到zset。因为我们移动端的数据浏览,一般是只看最新N笔,当看到昨天浏览过的数据一般就不会再往下浏览。

5.控制zset为固定长度,防止一直增长,一是减少缓存开销,二是队列长度越短操作性能越高。而且redis服务端有两个参数:zset-max-ziplist-entries(zset队列长度,默认值128)和 zset-max-ziplist-value(zset每项大小,默认值64字节),它们的作用是,当zset长度小于128,且每个元素的大小小于64字节时,会启用ziplist(压缩双向链表),它的内存空间可以减少8倍左右,而且操作性能也更快。如果不满足这两个条件则是普通的skiplist(跳跃表)。另,数据结构hash和list默认长度是512。如果系统有100万个用户,每个用户都有自己的队列缓存,那么使用ziplist将节省非常大的内存空间,并提升很大的性能。

注意,当从zset移除一项数据,则看场景是否需要清空队列。否则有可能添加进来了一项很旧的数据,它会跑到缓存队列最底部,如果此旧数据比db中未进队列的数据还旧,那么队列中的数据就不正确了。(此时,用户滑到缓存最后一页时,就有可能浏览到这项不正确的数据,为什么是“有可能”,因为当取到zset最后一笔,很可能不够一页(一页10笔计算的话,90%会取不够一页),而不够一页就会从db直接取一页,从db直接取就不会有这项不正确的数据。而当zset又添加进一项新数据,末端那笔旧数据就会被T出队列(因为队列保持固定长度),zset数据又恢复正确了。不管怎样,这种问题几率虽不高,也是有解决方案,可搞个临界点处理此问题,不细说,否则又是长篇大论,最好的方案就是根据实际场景设计,比如从zset队列移除数据的情况多不多)。而如果添加到zset的数据都是最新数据,则不会有此问题。

当用唯一主键id做score时,这可是非常有用,你可以直接根据id定位到项了,至于如何大用它,我会再出篇博客。

 

四、代码实现

从redis缓存按页取数一般要考虑的点:

1.当根据cacheKey未取到数据时(可能是缓存过期了导致redis无此cacheKey数据),则触发重载数据(reload):从db取limit N笔数据,装载到redis zset队列中,并直接取N笔的第一页数据返回;
2.如果db本身也无对应数据,则添加”no_db_mark”标识到cacheKey队列中,下次请求则不会再触发db重载数据;
3.当取到缓存末尾时,从db取一页数据直接返回。这种情况是很少的,要根据业务场景合理规划缓存长度。

上代码:

代码注释比较详细和有用,请直接看代码。

其中,批量添加数据到zset的函数AddItemsToZset很有用,它使用lua一次性添加多笔数据到zset(注意,使用lua时,要保证lua执行快,否则它会阻塞其它命令的执行),经测试:AddItemsToZset添加1w笔数据,只需要39ms;10w笔需要448ms。因为我们只取前N笔数据到缓存,因此一般不会添加超过1w笔。

另一个通用有用的函数是GetPageDataByLastScoreFromRedis,它支持从指定的score开始取pageSize笔数据,即支持了zset分页。它是第二页(及之后)的取数,而如果取第一页取数,则直接用redis原生函数即可redis.GetRangeWithScoresFromSortedSetDesc(cacheKey, 0, pageSize – 1);。

复制代码
    /// <summary>
    /// 分页取数帮助类
    /// </summary>
    public class PageDataHelper
    {
        public readonly static string NoDbDataMark = "no_db_data";//在zset中标识db也无数据
        public static RedisHandle RedisClient = new RedisHandle();//redis操作对象示例
        public static DbHandleBase DbHandle = new SqlServerHandle("Data Source=.;Initial Catalog=Test;User Id=sa;Password=123ewq;");//db操作对象示例
        /// <summary>
        /// 按页取数。返回文章编号列表。
        /// </summary>
        /// <param name="lastInfo">上一页最后一笔的score,如果为空,则说明是取第一页。</param>
        /// <param name="getPast">true,用户上滑浏览下一页数据;false,用户上滑浏览最新一页数据</param>
        /// <returns>返回key-value列表,key就是文章编号,value就是自增id(可用于lastScore)</returns>
        public static IDictionary<string, double> GetUserPageData(string uid, int pageSize, string lastInfo, bool getPast)
        {
            long lastScore = 0;
            //1.解析lastInfo信息。->getPast为false,则固定取最新第一页数据,不用解析。lastInfo为空,则也不用解析,默认第一页
            if (getPast && !string.IsNullOrWhiteSpace(lastInfo))
            {
                lastScore = long.Parse(lastInfo);//外层有try..catch..
            }
            string cacheKey = $"usr:art:{uid}";
            bool isFirstPage = lastScore <= 0;
            using (IRedisClient redis = RedisClient.GetRedisClient())
            {
                if (isFirstPage)
                {
                    //2.第一页取数
                    var items = redis.GetRangeWithScoresFromSortedSetDesc(cacheKey, 0, pageSize - 1);
                    if (items.Count == 0)
                    {
                        //2.1 无数据时,则从db reload数据
                        items = ReloadDataToRedis(redis, cacheKey, uid, pageSize);
                        if (items.Count == 0 && pageSize > 0)
                        {
                            //如果db中也无数据,则向zset中添加一笔NoDbDataMark标识
                            redis.AddItemToSortedSet(cacheKey, NoDbDataMark, double.MaxValue);
                        }
                    }
                    else if (items.Count == 1 && items.ContainsKey(NoDbDataMark))
                    {
                        //2.2如果取到的是NoDbDataMark标识,则说明是空数据,则要Clear,返回空列表
                        items.Clear();
                    }
                    //设置缓存有效期,要根据业务场景合理设置缓存有效期,这边以7天为例。
                    redis.ExpireEntryIn(cacheKey, new TimeSpan(7, 0, 0, 0));
                    //2.3 第一页,有多少就返回多少数据。数据如果不够一页,说明本身数据不够。
                    return items;
                }
                else
                {
                    //3.第二页(及之后)取数
                    var items = GetPageDataByLastScoreFromRedis(redis, cacheKey, pageSize, lastScore);
                    if (items.Count < pageSize)
                    {
                        //3.1 如果取不够数据时,就到db取。如果db也不能取到一页数据,前端会显示无更多数据,不会一直db取。
                        return GetPageDataByLastScoreFromDb(uid, pageSize, lastScore);
                    }
                    //3.2 如果缓存数据足够,则返回缓存的数据。
                    return items;
                }
            }
        }
        public static Dictionary<string, double> ReloadDataToRedis(IRedisClient redis, string cacheKey, string uid, int pageSize, string bizId = "")
        {
            //1.db取数 取top 1000笔数据。不需要全取到缓存。
            IEnumerable<dynamic> models;
            using (var conn = DbHandle.CreateConnectionAndOpen())
            {
                var sql = $"select top 1000 id,aid from TbUserArticle where uid=@uid order by id desc;";// limit 1000;";
                models = conn.Query<dynamic>(sql, new { uid = uid });
            }
            if (models.Count() <= 0) return new Dictionary<string, double>();
            //2.数据加载到redis缓存。
            var itemsParam = new Dictionary<string, double>();
            foreach (dynamic model in models)
            {
                itemsParam.Add((string)model.aid, (double)model.id);
            }
            //使用lua一次性添加数据到缓存。lua语句要执行快,经测试添加1w笔数据,只需要39ms;10w笔需要448ms。因为sql中有limit,因此一般不会添加超过1w笔。
            //因为是原子性操作、并且是zset结构,这边不需要加锁。db取到数据应第一时间加载到redis。
            AddItemsToZset(redis, cacheKey, itemsParam, true, true);
            if (pageSize <= 0) return null;
            //3.直接由models返回第一页数据。
            return models.Take(pageSize).ToDictionary(x => (string)x.aid, y => (double)y.id);
        }

        public static Dictionary<string, double> GetPageDataByLastScoreFromDb(string uid, int pageSize, double lastScore)
        {
            //db取一页数据。
            var sql = $"select top {pageSize} id,aid from TbUserArticle where uid=@uid and id<{lastScore}order by id desc;";// limit {pageSize};";
            using (var conn = DbHandle.CreateConnectionAndOpen())
            {
                return conn.Query<dynamic>(sql, new { uid = uid }).ToDictionary(x => (string)x.aid, y => (double)y.id);
            }
        }
        #region 通用函数
        /// <summary>
        /// ZSet第一页之后的取数,从lastScore开始取pageSize笔数据(第一页之后才有lastScore)。
        /// 使用lua,保证原子性操作。
        /// </summary>
        public static Dictionary<string, double> GetPageDataByLastScoreFromRedis(IRedisClient redis, string zsetKey, int pageSize, double lastScore)
        {
            //ZREVRANGEBYSCORE: from lastScore to '-inf'.
            var luaBody = @"local sets = redis.call('ZREVRANGEBYSCORE', KEYS[1], ARGV[1], '-inf', 'WITHSCORES');
            local result = {};
            local index=0;
            local pageSize=ARGV[2]*1;
            local lastScore=ARGV[1]*1;
            for i = 1, #sets, 2 do 
                if index>=pageSize then
                    break;
                end
                if (lastScore>sets[i+1]*1) then
                    table.insert(result, sets[i]);
                    table.insert(result, sets[i+1]);
                    index=index+1;
                end
            end
            return result";
            //ARGV[1]:lastScore ARGV[2]:pageSize
            var list = redis.ExecLuaAsList(luaBody, new string[] { zsetKey }, new string[] { lastScore.ToString(), pageSize.ToString() });
            var result = new Dictionary<string, double>();
            for (var i = 0; i < list.Count; i += 2)
            {
                result.Add(list[i], Convert.ToDouble(list[i + 1]));
            }
            return result;
        }
        /// <summary>
        /// 添加一项到zset缓存中。
        /// </summary>
        /// <param name="item">要添加到zset的数据项</param>
        /// <param name="maxCount">控制zset最大长度,如果为0,则不控制。</param>
        /// <returns></returns>
        public static string AddItemToZset(IRedisClient redis, string zsetKey, KeyValuePair<string, double> item, int maxCount = 0)
        {
            var items = new Dictionary<string, double>() { { item.Key, item.Value } };
            return AddItemsToZset(redis, zsetKey, items);
        }
        /// <summary>
        /// 添加多项到zset缓存中。
        /// </summary>
        /// <param name="items">要添加到zset的数据列表</param>
        /// <param name="hasCacheExpire">缓存zsetKey是否有设置缓存有效期。如果有设置缓存有效期,则当缓存中无数据时,可能是缓存过期;而如果缓存无有效期,缓存中无数据,就是db和缓存都无数据</param>
        /// <param name="isReload">是否是reload情况,true重载情况;false追加</param>
        /// <param name="maxCount">控制zset最大长度,如果为0,则不控制。</param>
        /// <returns></returns>
        public static string AddItemsToZset(IRedisClient redis, string zsetKey, Dictionary<string, double> items, bool hasCacheExpire = true
            , bool isReload = false, int maxCount = 0)
        {
            //!isReload,是因为如果isReload=true情况无数据,则也要进来重载队列为无数据(即,如果之前有数据要重载为无数据)
            if (!isReload && items.Count <= 0) return null;
            var argArr = new List<string>(items.Count * 2 + 2);//lua参数数组
            //var hasCacheExpire = cacheValidTime != null;
            //第一个lua参数是hasCacheExpire
            argArr.Add(hasCacheExpire ? "1" : "0");
            //第二个lua参数是maxCount
            argArr.Add(maxCount.ToString());
            //组合lua其它参数列表:ZADD的参数
            foreach (var item in items)
            {
                //Add score。 //ZADD KEY_NAME SCORE1 VALUE1
                argArr.Add(item.Value.ToString());
                argArr.Add(item.Key);
            }
            #region lua
            /*
            * 以下lua命令说明。
            * 1.ZREVRANGE从大到小取第一笔数据firstMark;
            * 2.缓存有设置有效期时(hasCacheExpire=1),如果第一笔数据firstMark为nil,则说明列表是空(失效key、未生成key),则不做任何处理,直接返回字符串not_exist_key。因为可能是用户失效数据,用户长期未访问,则不添加,后继来访问时重载数据。
            * 3.如果firstMark标识为no_db_data,则是被api标识为db没数据,而此时因要ZADD数据进来,因此要把此标识删除。其中,ZREMRANGEBYRANK从小到大删除,-1是倒数第一笔。
            * 4.ZADD数据进来
            * 5.KeepLength保持队列长度操作。如果队列长度(由ZCARD获取)超过指定的maxCount,则从队列第一笔开始删除多余元素,即score最小开始删除。
            * 6.maxCount为>0才KeepLength。返回数值:curCount - maxCount。(可以用返回值简单算出队列当前长度curCount)。如果返回值小于等于0则说明没有触发删除操作。
            * 7.maxCount为<=0时,直接返回'no_remove'。
            */
            //清空原来,重新加载数据的情况
            const string reloadLua = "redis.call('DEL', KEYS[1]) ";
            //追加数据到zset的情况
            const string addToLua =
            @"local firstMark = redis.call('ZREVRANGE',KEYS[1],0,0);
            local hasCacheExpire=ARGV[1]*1;
            if hasCacheExpire==1 and firstMark and firstMark[1]==nil then
                return 'not_exist_key';
            end
            if firstMark and firstMark[1]=='{0}' then
                redis.call('ZREMRANGEBYRANK', KEYS[1], -1,-1);
            end";
            const string constAllLua =
            @"{0}
            for i=3, #ARGV, 2 
                do redis.call('ZADD', KEYS[1], ARGV[i], ARGV[i+1]);  
            end
            local maxCount=ARGV[2]*1;
            if maxCount>0 then
              local curCount= redis.call('ZCARD', KEYS[1]);
              local removeCount=curCount - maxCount;
              if removeCount>0 then
                redis.call('ZREMRANGEBYRANK', KEYS[1], 0,removeCount-1);    
              end  
              return removeCount;
            end
            return 'no_remove';";
            #endregion
            var luaBody = string.Format(constAllLua, isReload ? reloadLua : string.Format(addToLua, NoDbDataMark));
            var luaResult = redis.ExecLuaAsString(luaBody, new string[] { zsetKey }, argArr.ToArray());
            return luaResult;
        }
        #endregion
    }
复制代码

 

五、用时间做score,同分同秒问题解决

如果是用时间做score,会有同分同秒问题,比如在TbUserArticle里增加了“时间”栏位。解决方法代码只需稍作微改,参数除了lastScore(此时是“时间”),还需要传lastAid(文章编号)。

1. 缓存处理修改,只动了以下红色粗体字。(注:当zset的两笔数据score相同时,是再根据value排序的):

复制代码
   public static Dictionary<string, double> GetPageDataByLastScoreFromRedis(IRedisClient redis, string zsetKey, int pageSize, double lastScore,string lastAid)
        {
            //ZREVRANGEBYSCORE: from lastScore to '-inf'.
            var luaBody = @"local sets = redis.call('ZREVRANGEBYSCORE', KEYS[1], ARGV[1], '-inf', 'WITHSCORES');
            local result = {};
            local index=0;
            local pageSize=ARGV[2]*1;
            local lastScore=ARGV[1]*1;
            local lastAid=ARGV[3];
            for i = 1, #sets, 2 do 
                if index>=pageSize then
                    break;
                end
                if (lastScore>sets[i+1]*1) or (lastScore==sets[i+1]*1 and lastAid>sets[i]) then
                    table.insert(result, sets[i]);
                    table.insert(result, sets[i+1]);
                    index=index+1;
                end
            end
            return result";
            //ARGV[1]:lastScore ARGV[2]:pageSize
            var list = redis.ExecLuaAsList(luaBody, new string[] { zsetKey }, new string[] { lastScore.ToString(), pageSize.ToString(), lastAid });
            var result = new Dictionary<string, double>();
            for (var i = 0; i < list.Count; i += 2)
            {
                result.Add(list[i], Convert.ToDouble(list[i + 1]));
            }
            return result;
        }
复制代码

2.db取数修改

reload SQL

$”select top 1000 时间,aid from TbUserArticle where uid=@uid order by 时间 desc,aid desc;”;

db中取一页的SQL

$”select top {pageSize} 时间,aid from TbUserArticle where uid=@uid and (时间<{lastScore} or (时间={lastScore} and aid<‘{lastAid}’)) order by 时间 desc,aid desc;”;

这样就可以了,中心思想就是:当“时间={lastScore} ”,那么就增加文章编号比较条件。

Redis 分页排序查询_Hello World .-CSDN博客_redis 分页

mikel阅读(458)

来源: Redis 分页排序查询_Hello World .-CSDN博客_redis 分页

edis是一个高效的内存数据库,它支持包括String、List、Set、SortedSet和Hash等数据类型的存储,在Redis中通常根据数据的key查询其value值,Redis没有条件查询,在面对一些需要分页或排序的场景时(如评论,时间线),Redis就不太好不处理了。
前段时间在项目中需要将每个主题下的用户的评论组装好写入Redis中,每个主题会有一个topicId,每一条评论会和topicId关联起来,得到大致的数据模型如下:

{
topicId: ‘xxxxxxxx’,
comments: [
{
username: ‘niuniu’,
createDate: 1447747334791,
content: ‘在Redis中分页’,
commentId: ‘xxxxxxx’,
reply: [
{
content: ‘yyyyyy’
username: ‘niuniu’
},

]
},

]
}
将评论数据从MySQL查询出来组装好存到Redis后,以后每次就可以从Redis获取组装好的评论数据,从上面的数据模型可以看出数据都是key-value型数据,无疑要采用hash进行存储,但是每次拿取评论数据时需要分页而且还要按createDate字段进行排序,hash肯定是不能做到分页和排序的。

那么,就挨个看一下Redis所支持的数据类型:

1、String: 主要用于存储字符串,显然不支持分页和排序。
2、Hash: 主要用于存储key-value型数据,评论模型中全是key-value型数据,所以在这里Hash无疑会用到。
3、List: 主要用于存储一个列表,列表中的每一个元素按元素的插入时的顺序进行保存,如果我们将评论模型按createDate排好序后再插入List中,似乎就能做到排序了,而且再利用List中的LRANGE key start stop指令还能做到分页。嗯,到这里List似乎满足了我们分页和排序的要求,但是评论还会被删除,就需要更新Redis中的数据,如果每次删除评论后都将Redis中的数据全部重新写入一次,显然不够优雅,效率也会大打折扣,如果能删除指定的数据无疑会更好,而List中涉及到删除数据的就只有LPOP和RPOP这两条指令,但LPOP和RPOP只能删除列表头和列表尾的数据,不能删除指定位置的数据,所以List也不太适合(转载的时候看了下,是有 LREM命令可以做到删除,但是LRANGE 似乎是一个耗时命令 O(N) )。
4、Set: 主要存储无序集合,无序!排除。
5、SortedSet: 主要存储有序集合,SortedSet的添加元素指令ZADD key score member [[score,member]…]会给每个添加的元素member绑定一个用于排序的值score,SortedSet就会根据score值的大小对元素进行排序,在这里就可以将createDate当作score用于排序,SortedSet中的指令ZREVRANGE key start stop又可以返回指定区间内的成员,可以用来做分页,SortedSet的指令ZREM key member可以根据key移除指定的成员,能满足删评论的要求,所以,SortedSet在这里是最适合的(时间复杂度O(log(N)))。

所以,我需要用到的数据类型有SortSet和Hash,SortSet用于做分页排序,Hash用于存储具体的键值对数据,我画出了如下的结构图:

 

在上图的SortSet结构中将每个主题的topicId作为set的key,将与该主题关联的评论的createDate和commentId分别作为set的score和member,commentId的顺序就根据createDate的大小进行排列。
当需要查询某个主题某一页的评论时,就可主题的topicId通过指令zrevrange topicId (page-1)×10 (page-1)×10+perPage这样就能找出某个主题下某一页的按时间排好顺序的所有评论的commintId。page为查询第几页的页码,perPage为每页显示的条数。
当找到所有评论的commentId后,就可以把这些commentId作为key去Hash结构中去查询该条评论对应的内容。
这样就利用SortSet和Hash两种结构在Redis中达到了分页和排序的目的。

博主额外添加的实现算法:

@Test
public void sortedSetPagenation(){
for ( int i = 1 ; i <= 100 ; i+=10) {
// 初始化CommentId索引 SortSet
RedisClient.zadd(“topicId”, i, “commentId”+i);
// 初始化Comment数据 Hash
RedisClient.hset(“Comment_Key”,”commentId”+i, “comment content …….”);
}
// 倒序取 从0条开始取 5条 Id 数据
LinkedHashSet<String> sets = RedisClient.zrevrangebyscore(“topicId”, “80”, “1”, 0, 5);
String[] items = new String[]{};
System.out.println(sets.toString());
// 根据id取comment数据
List<String> list = RedisClient.hmget(“Comment_Key”, sets.toArray(items));
for(String str : list){
System.out.println(str);
}
}
工具类:
package com.util;

import java.util.LinkedHashSet;
import java.util.List;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
* Redis 客户端集群版
*
* @author babylon
* 2016-5-10
*/
public class RedisClient{

private static JedisPool jedisPool;

static {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxTotal(Global.MAX_ACTIVE);
config.setMaxIdle(Global.MAX_IDLE);
config.setMaxWaitMillis(-1);
config.setTestOnBorrow(Global.TEST_ON_BORROW);
config.setTestOnReturn(Global.TEST_ON_RETURN);
jedisPool = new JedisPool(“redis://:”+Global.REDIS_SERVER_PASSWORD+”@”+Global.REDIS_SERVER_URL+”:”+Global.REDIS_SERVER_PORT);
// jedisPool = new JedisPool(config, Global.REDIS_SERVER_URL, Integer.parseInt(Global.REDIS_SERVER_PORT), “zjp_Redis_224”);
}

public static String set(String key, String value) {
Jedis jedis = jedisPool.getResource();
String result = jedis.set(key, value);
jedis.close();
return result;
}

public static String get(String key) {
Jedis jedis = jedisPool.getResource();
String result = jedis.get(key);
jedis.close();
return result;
}

public static Long hset(String key, String item, String value) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.hset(key, item, value);
jedis.close();
return result;
}

public static String hget(String key, String item) {
Jedis jedis = jedisPool.getResource();
String result = jedis.hget(key, item);
jedis.close();
return result;
}

/**
* Redis Hmget 命令用于返回哈希表中,一个或多个给定字段的值。
如果指定的字段不存在于哈希表,那么返回一个 nil 值。
* @param key
* @param item
* @return 一个包含多个给定字段关联值的表,表值的排列顺序和指定字段的请求顺序一样。
*/
public static List<String> hmget(String key, String… item) {
Jedis jedis = jedisPool.getResource();
List<String> result = jedis.hmget(key, item);
jedis.close();
return result;
}

public static Long incr(String key) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.incr(key);
jedis.close();
return result;
}

public static Long decr(String key) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.decr(key);
jedis.close();
return result;
}

public static Long expire(String key, int second) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.expire(key, second);
jedis.close();
return result;
}

public static Long ttl(String key) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.ttl(key);
jedis.close();
return result;
}

public static Long hdel(String key, String item) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.hdel(key, item);
jedis.close();
return result;
}

public static Long del(String key) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.del(key);
jedis.close();
return result;
}

public static Long rpush(String key, String… strings) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.rpush(key, strings);
jedis.close();
return result;
}

/**
* Redis Lrange 返回列表中指定区间内的元素,区间以偏移量 START 和 END 指定。 其中 0 表示列表的第一个元素, 1 表示列表的第二个元素,以此类推。
* 你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。
* @param string
* @param start
* @param end
* @return
*/
public static List<String> lrange(String key, int start, int end) {
Jedis jedis = jedisPool.getResource();
List<String> result = jedis.lrange(key, start, end);
jedis.close();
return result;
}

/**
* 从列表中从头部开始移除count个匹配的值。如果count为零,所有匹配的元素都被删除。如果count是负数,内容从尾部开始删除。
* @param string
* @param string2
* @param i
*/
public static Long lrem(String key, Long count, String value) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.lrem(key, count, value);
jedis.close();
return result;
}

/**
* Redis Zadd 命令用于将一个或多个成员元素及其分数值加入到有序集当中。
如果某个成员已经是有序集的成员,那么更新这个成员的分数值,并通过重新插入这个成员元素,来保证该成员在正确的位置上。
分数值可以是整数值或双精度浮点数。
如果有序集合 key 不存在,则创建一个空的有序集并执行 ZADD 操作。
当 key 存在但不是有序集类型时,返回一个错误。
* @param string
* @param i
* @param string2
* @return 被成功添加的新成员的数量,不包括那些被更新的、已经存在的成员。
*/
public static Long zadd(String key, double score, String member) {
Jedis jedis = jedisPool.getResource();
Long result = jedis.zadd(key, score, member);
jedis.close();
return result;
}

/**
* Redis Zrevrangebyscore 返回有序集中指定分数区间内的所有的成员。有序集成员按分数值递减(从大到小)的次序排列。
具有相同分数值的成员按字典序的逆序(reverse lexicographical order )排列。
除了成员按分数值递减的次序排列这一点外, ZREVRANGEBYSCORE 命令的其他方面和 ZRANGEBYSCORE 命令一样。
* @param key
* @param max
* @param min
* @param offset
* @param count
* @return 指定区间内,带有分数值(可选)的有序集成员的列表。
*/
public static LinkedHashSet<String> zrevrangebyscore(String key, String max, String min, int offset, int count){
Jedis jedis = jedisPool.getResource();
LinkedHashSet<String> result = (LinkedHashSet<String>) jedis.zrevrangeByScore(key, max, min, offset, count);
jedis.close();
return result;
}

}

————————————————
版权声明:本文为CSDN博主「LogansCodingLife」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/jack85986370/article/details/51483872

1秒钟复制百度文库中所有内容-小刀娱乐网 - 专注活动,软件,教程分享!总之就是网络那些事。

mikel阅读(572)

来源: 1秒钟复制百度文库中所有内容-小刀娱乐网 – 专注活动,软件,教程分享!总之就是网络那些事。

很多人经常会上百度搜索资料,结果发现在百度文库那边可以找到,兴奋了半天却发现下载时要币的,或者登陆上去麻烦,又或者限制VIP才能复制下载。针对这种情况,今天给大家带来一个破解百度文库下载的方法,其实非常简单,而且不用下载任何软件。

1.jpg

打开要复制的文库内容,在浏览器极速模式下点击F12或右键打开审查元素,点击Console,粘贴以下代码然后回车。

var box = document.getElementsByClassName("ie-fix");for(var i=0;i<box.length;i++){        console.log(box[i].innerText);}

HT_20210815160358.jpg

整篇文档就出现下面粘贴的代码里随便复制了,此方法仅限文字类的文档。

HT_20210815160356.jpg

8月15日置顶:已更新最新可用代码

利用宝塔面板部署Vue项目教程_WEXIA666的博客-CSDN博客

mikel阅读(804)

来源: 利用宝塔面板部署Vue项目教程_WEXIA666的博客-CSDN博客

1 安装 堡塔SSH客户端,进行SSH连接服务器
2 在Ubuntu的系统中,输入命令
wget -O install.sh http://download.bt.cn/install/install-ubuntu_6.0.sh && sudo bash install.sh

1
2
3 安装成功之后,输入服务器ip+8888端口访问宝塔面板
http://1xx.xxx.xx:12580/ //这里我已经将宝塔面板默认的端口改为了 12580
1
4 SSH连接之后,输入 bt 就可以列出宝塔的命令行

5 第一次进入宝塔面板的时候,会提示你安装环境,就照默认的推荐安装即可 LNMP环境
6 进去之后,最好更换宝塔的端口,阿里云的更换教程,添加安全规则,放行相关端口
阿里云开放端口教程

7 安装环境结束之后,再在软件商店搜索安装PM2管理器,这是用来管理node项目的

8 将本地的node_server包打包,上传到wwwroot目录下,可以再新建子目录

​ 由于我用的是 express 托管静态资源,所以我还需要安装 express 依赖,另外上传的node_server包需要在运行过npm install 之后再上传,这样就有了所有的包

​ 补充说明:若后端项目中的启动文件(我的是app.js)是占用8888端口进行数据传输,而你的宝塔面板也是用的8888端口,就会发生端口冲突的错误。所以前面建议你先修改宝塔的端口。

9 在PM2管理器中安装 express 依赖

10 运行后台项目,启动文件这里我的是 app.js 选好后提交即可运行。

11 回到自己的前端项目。
首先先把自己获取后端数据的接口地址换成自己服务器的ip地址和端口号,这样注意要对应,保持一致,否则会访问不到数据,报错的。修改好之后,需要重新打包,再把打包好的dist 文件夹上传到wwwroot中。

12 点击宝塔面板网站,添加站点,输入域名和端口号,没有域名的直接输自己服务器ip 然后加个端口号,不加默认是80端口。

建立站点成功

 

13 建立站点成功之后,在wwwroot文件夹中就会有对应站点名字的文件夹,在这里面上传你打包好的dist文件夹里面的内容进行替换,这样就完成了vue项目的部署。

————————————————
版权声明:本文为CSDN博主「WEXIA666」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/WEXIA666/article/details/118246814

YOLObile:面向移动设备的「实时目标检测」算法(AAAI2021) - 知乎

mikel阅读(1273)

来源: YOLObile:面向移动设备的「实时目标检测」算法(AAAI2021) – 知乎

1. 前言

目标检测之YOLO算法:YOLOv1,YOLOv2,YOLOv3,TinyYOLO,YOLOv4,YOLOv5,YOLObile,YOLOF详解:

论文作者来自于美国东北大学、匹兹堡大学和William & Mary。YOLObile 框架通过「压缩 – 编译」协同设计在手机端实现了高准确率实时目标检测。该框架使用一种新提出的名为「block-punched」的权重剪枝方案,对模型进行有效的压缩。在编译器优化技术的协助下,在手机端实现高准确率的实时目标检测。该研究还提出了一种高效的手机 GPU – 手机 CPU 协同计算优化方案,进一步提高了计算资源的利用率和执行速度。相比 YOLOv4 的原版,加速后的 YOLObile 的运行速度提高了 4 倍,并且维持了 49mAP 的准确率。相比 YOLOv3 完整版,该框架快 7 倍,在手机上实现了 19FPS 的实时高准确率目标检测。同时准确率高于 YOLOv3,并没有用牺牲准确率来提高计算速度。

论文地址(YOLObile: Real-Time Object Detection on Mobile Devices via Compression-Compilation Co-Design):

代码地址:

2. 研究内容

2.1 替换硬件支持性不好的操作符

在原版的 YOLOv4 中,有一些操作符不能够最大化地利用硬件设备的执行效率,比如带有指数运算的激活函数可能会造成运行的延迟增加,成为降低延时提高效率的瓶颈。该研究把这些操作符相应地替换成对硬件更加友好的版本,还有一些操作符是 ONNX 还未支持的(YOLObile 用 ONNX 作为模型的存储方式),研究者把它替换成 ONNX 支持的运算符。

比如,在 YOLOv4 引入的新模块 Spatial Attention Module (SAM)中,用了 sigmoid 作为分支的激活函数,该研究在尝试把它换成 hard-sigmoid 后发现:准确率和直接删除相比几乎一致,增加的模块又会增加计算量,所以研究者将其删除了。

Mish 激活函数也涉及了指数运算,同时在 pytorch 上的支持不太友好,会在训练时占用很多缓存,同时它在 pytorch 上也不能够像 C++ 版本的 YOLOv4 一样带来很大的准确率提升,而且 ONNX 尚未支持。研究者将其换成了 YOLOv3 的 leaky RELU。

2.2 「block-punched」权重剪枝方案

在 YOLObile 优化框架中,作者使用了新提出的名为「block-punched」的权重剪枝 (weight pruning) 方案。这种剪枝方案旨在获得较高剪枝结构自由度的同时,还能使剪枝后的模型结构较好地利用硬件并行计算。这样就从两方面分别保证了剪枝后模型的准确率和较高的运算速度。

在这种剪枝方案中,每层的权重矩阵将被划分为大小相等的多个块(block),因此,每个块中将包含来自 m 个 filter 的 n 个 channel 的权重。在每个块中,被剪枝的位置需要做出如下限定:需要修剪所有 filter 相同位置的一个或多个权重,同时也修剪所有通道相同位置的一个或多个权重。从另一个角度来看,这种剪枝方案将权重的剪枝位置贯穿了整个块中所有的卷积核(kernel)。对于不同的 block,剪枝的位置和剪掉的 weight 数量都是灵活可变的。另外这种剪枝策略可以应用在卷积核大小 3×3,1×1,5×5 等的卷积层上,也适用于 FC 层。

通过划分为固定大小的块来进行剪枝,能够提高编译器的并行度,进而提高在手机上运行的速率。

  1. 在准确率方面,通过划分多个小区块,这种剪枝方法实现了更加细粒度的剪枝。相较于传统的结构化剪枝(剪除整个 filter 或 channel),这种方式具有更高的剪枝结构自由度,从而更好地保持了模型的准确率。
  2. 在硬件表现方面,因为在同一小区块中,所有 filter 修剪被修剪的位置相同,所以在并行计算时,所有 filter 将统一跳过读取相同的输入数据,从而减轻处理这些 filter 的线程之间的内存压力。而限制修剪小区块内各 channel 的相同位置,确保了所有 channel 共享相同的计算模式,从而消除处理每个 channel 的线程之间的计算差异。因此,这种剪枝方案可以大幅度降低在计算过程中处理稀疏结构的额外开销,从而达到更好的加速效果。

Block-punched pruning特点是:block size会极大的影响模型的精度和在硬件上的运行速度

  • block size越小,精度丢失越少,但是推理速度也会变慢。
  • block size越大,精度丢失越严重,但是推理速度变快。

所以选择一个合理的block size非常重要。这里作者给出了两个建议:

  • 对于block中channel的数量:与设备中CPU/GPU的vector registers的长度一致。
  • 对于block中的filter的数量:在保证目标推理速度的前提下,选择最少的filter数量。

2.3 重权正则化剪枝算法(Reweight regularization pruning algorithm

作者采用了 Reweighted Regularization 算法来筛选出剪枝掉的 weight 的位置,reweight 算法依据 weight 的绝对值大小筛选出相对重要的位置。整个剪枝的过程从依据预先训练好的网络模型开始,然后将 Reweighted 算法融合在训练的过程中,在训练每 3 到 4 个 epochs 后就能够得到一组可选的剪枝位置,通过重复训练来调整剪枝位置,最终得到精确的剪枝模型结果。

其基本原理是:减小大权重的惩罚项,增大小权重的惩罚项。

从公式可以看出,利用参数F范数(Lasso一般采用L1范数)的平方的倒数作为加权值,权值越大,惩罚项的加权值越小。最后需要剪去的参数是那些逼近于0 的参数。

2.4 编译器的优化

为了支持「block-punched」剪枝方案,编译器也需要作出相应的调整和优化。为了更好地存储和调用经过「block-punched」剪枝后的 weight index,研究者引入了新的存储方案,将 index 的存储空间进一步压缩。同时,通过对所有的块进行重新排序,编译器能够在更少的内存访问次数下运行,进而获得更快的运算速度。

2.5 手机 GPU 和手机 CPU 协同计算的优化方案

YOLObile 中还使用了手机 GPU 和手机 CPU 协同计算的方式来进一步降低整个网络的运算时间。现在主流的移动端 DNN 推理加速框架,这里提出了一种更高效的计算加速策略,可以综合利用GPU和CPU。目前的一些推理加速框架如TFLite和MNN只能支持在移动GPU或CPU上顺序执行DNN推理,这可能造成计算资源的浪费。

YOLObile 提出针对网络中的分支结构,比如YOLOv4 中大量使用的 Cross Stage Partial (CSP)结构,使用 CPU 来辅助 GPU 同时进行一些相互无依赖关系的分支运算,从而更好地利用计算资源,减少网络的运算时间。YOLObile 框架将待优化的网络分支分为有卷积运算分支和无卷积运算分支,并对这两种情况分别给出了优化方案。

如下图所示,CSP 是一个跨越很多残差 block 的长分支,在手机的执行过程中,单一的 GPU 计算往往是顺序地执行完上面堆叠残差 block 的分支后再执行下面的 CSP 分支,然后拼接在一起作为下一层的输入。研究者将卷积层数更少的 branch2 挪到 CPU 上去,CPU 执行时间少于上面 branch1 在 GPU 上的总运算时间,这个并行操作能够有效减少运算延迟。当然,决定能否将 branch2 转移到 CPU 的因素在于 branch1 的卷积层数多少,通常 CSP block 会跨越 8 个残差 block,也有的时候出现只跨越 4 个残差 block 的情况,还有在前几层只跨一个残差 block 的情况。对于只跨 1 个残差 block 的情况明显还是 GPU 顺序执行更高效,对于跨越多个的就需要用实际测出的延迟来做判断。值得注意的是,转移数据到不同处理设备的时候,需要加入数据传输拷贝的时间。

在YOLOv4 最后输出的位置,3 个YOLOhead 输出部分有很多诸如转置,变形之类的非卷积运算,这些非卷积运算在 CPU 和 GPU 上的运行效率相当,作者同样基于运行时间,考虑将部分运算符转移到 CPU 上去做,选择最高效的执行方式。

定义branch1和branch2在GPU上的耗时为 [公式] 和 [公式] ,在CPU上的耗时分别 [公式] 和 [公式] ,branch1卷积层多适合GPU运算,如果采用GPU和CPU并行运算,那么最终的处理时间取决于最大耗时,定义数据拷贝到CPU上的耗时为 [公式] ,则GPU和CPU并行运算耗时为:

如果只采用GPU进行串行运算,即先计算branch1,再计算branch2,则耗时为两者之和:

通过 [公式] 和 [公式] 可以确定branch2在哪个设备上运行。因为每个branch的执行是独立的,所以可以通过Greedy Algorithm(贪心算法)来确定网络中每一个分支的执行的位置(GPU or CPU).

对于那些低计算密度的操作如pixel-wise add和pixel-wise multiply操作,移动设备上CPU和GPU的运算效率差不多。所以对于non-convolution的分支,在CPU还是在GPU上运算,取决于总耗时。

如上图(b)所示,三个YOLO head的运算都是non-convolution的,所以三个分支运算在哪个分支的可能性有8种,假设前两个运行在CPU上,最后一个分支运行在GPU上,那么总的运行时间为:

采用上述的方案分别确定每个conv branch和non-conv branch运行的位置,最小化总的推理时间。YOLObile提供了每一层的CPU和GPU代码,为实现上述的计算提供了可行性。

3. 消融实验

这篇文章主要的工作是模型裁剪和推理加速策略,所以在介绍这篇文章的工作之前,先介绍目前主流的三种剪枝策略:Unstructured pruning,Structured pruning和Pattern-based pruning。

3.1 Unstructured pruning

所谓非结构性剪枝允许在权重矩阵的任意位置进行裁剪。其优点主要有:

  • 在搜寻最优的剪枝结构上有更好的灵活性
  • 可以达到很高的模型压缩率和极低的精度丢失

如下图所示:

可以看出,Unstructured pruning方法得到的权重分布很不规则,所以在计算前向的时候,通常需要额外的非零权值的索引。这对于那些可以并行运算的设备(GPU)很不友好,所以不太适合用于DNN推理加速,甚至有可能导致速度下降。

3.2 Structured pruning

结构性剪枝如上图(b)所示,主要从卷积核个数(filters)和通道数(channels)上进行剪枝,因而得到的权重矩阵仍然是规则的。这对于支持并行运算的硬件非常友好,有助于提升推理速度。但是由于这种裁剪方式过于粗糙(直接剪掉一个或者多个卷积核或者减去所有卷积核中同一个或多个位置的通道的权重),所以精度丢失非常严重。

3.3 Pattern-based pruning

Pattern-based pruning可以看作是一种fine-grained结构性剪枝,比结构性剪枝更加灵活。如下图所示:

主要包括两个部分:kennel pattern prune和connectivity prune。每个卷积核可以采用不同的裁剪模式(pattern),但是要保证剩余的每个通道的参数数量是固定的,如上图的剩余的红色、黄色、绿色和紫色方块数量都是4。

而connectivity prune指的是直接裁去整个卷积核。这样做的好处是可以保证裁剪后的参数分布都是规则的,也比structured pruning更加灵活,精度丢失的也会相对少一些。

但是kennel pattern prune只针对3×3卷积核,限制了pattern-based pruning的应用场景。

3.4 不同剪枝策略比较

基于目前SOTA的目标检测算法,精度高的,模型比较大,在移动设备上会有很高的时延;而那些在移动设备端可以快速运行的轻量级算法又牺牲了算法精度。

三种主流的剪枝算法Unstructured pruning可以保证精度,但是不能保证速度,Structured pruning可以保证速度,但是无法保证精度;Pattern-based pruning可以一定程度上同时保证速度和精度,但是应用场景有限。

基于此,这篇文章的主要工作可以总结为以下两点:

  • 提出一种剪枝策略,可以同时保证速度和精度,并且可以推广到任意layer(pattern-based pruning只能应用在3×3卷积层)
  • 提出一种更高效的计算加速策略

表格中列出了在相同压缩倍率的情况下,Structured Pruning, Unstructured Pruning, 和文中提出的 Block-Punched Pruning 这三种剪枝策略的准确率和运行效率的比较。可以看到,Unstructured Pruning 在压缩 8 倍的状态下能够维持较高的准确率,但是和前文描述的一样,它的运行效率较低,即使在压缩 8 倍的情况下,运行时间只提高了不到 2 倍。Structured Pruning 后模型能够高效率地在硬件设备上执行,但是准确率相比 Unstructured Pruning 有大幅度的下降。而根据编译器设计的 Block-Punched剪枝策略,能够维持较高的准确率,并且达到和 Structured Pruning 相当的执行效率。

和 Pattern-Based Pruning 的比较

Pattern-Based Pruning 只能用在 3×3 的层,为了达到较高的压缩倍率,就需要对 3×3 的卷积层压缩掉更多的 weight。下图是一个关于不同倍率下 pattern-based pruning 和 block-punched pruning 的比较,可以看出 pattern-based pruning 在较低倍率压缩的情况下,能够拥有较高的准确率和执行效率,但是受限制于只适用于 3×3 卷积层,即使将所有的 3×3 层全部压缩也只能达到 5 倍多的压缩率,但是准确率就非常低了。block-based pruning 由于可以将所有的层都进行压缩,在高倍率的情况下依然能够维持较高的准确率。

Block-Punched剪枝中分块大小的比较

该研究对四种不同的块大小进行实验,为了实现较高的硬件并行度,每个模块中的 channel 数固定为 4,这与 GPU 和 CPU 矢量寄存器的长度相同。在每个块中使用不同数量的 filter 来评估准确性和速度。如图 5.3 所示,与较小的块相比,较大的块可以更好地利用硬件并行性,并可以实现更高的推理速度。但是其粗略的修剪粒度导致准确性下降。较小的块可以获得较高的精度,但会牺牲推理速度。根据结果,研究者将 8×4(8 个连续 filter 的 4 个连续 channel)视为移动设备上的所需块大小,这在精度和速度之间取得了良好的平衡。

各层的剪枝比例:

YOLOv4 在卷积层中仅包含 3×3 和 1×1 的 kernel。该研究提出这两种类型的卷积层在修剪过程中具有不同的敏感度并进行了两组实验。在相同数量的 FLOPs 下,将一组中的所有层平均修剪,另一组中,3×3 卷积层的压缩率比 1×1 卷积层高 1.15 倍。如上表所示,均匀修剪的模型和不均匀修剪的模型相比,准确性和速度都较低。

4. 实验结果

整个训练过程采用 4 块 RTX2080Ti,训练时间为 5 天。使用配备高通骁龙 855 CPU 和 Adreno 650 GPU 的三星 Galaxy S20 作为测试平台。训练的数据集是 MS COCO,和YOLOv4 原版保持一致。实验结果表明,当使用YOLOv4 为基础模型进行优化时,该研究的优化框架可以成功将原模型大小压缩至 1/14,在未使用 GPU-CPU 协同计算优化时,将每秒检测帧数(FPS)提升至 17,且达到 49(mAP)的准确率。

从上图中可以看到,与众多具有代表性的目标检测网络相比,该研究的优化模型在准确率与速度两方面同时具有优异的表现,而不再是简单的牺牲大幅准确率来获取一定程度的速度提升。

下表展示了YOLObile 与其他具有代表性的目标检测网络在准确率与速度方面的具体比较。值得注意的是,作者的 GPU-CPU 协同计算优化方案可以进一步将执行速度提高至 19FPS。

相比于其他 one-stage 目标检测模型和 light-weight 模型,经过剪枝后的YOLObile 更快且更精确,在准确率和速度上实现了帕累托最优。

[1][2]

参考

  1. ^AAAI2021 | 在手机上实现19FPS实时的YOLObile目标检测,准确率超高 https://www.jiqizhixin.com/articles/2020-12-31-2
  2. ^YOLObile:面向移动设备的「实时目标检测」算法 https://mp.weixin.qq.com/s/OP5iLZtIABNcn_LFyBWOeA