Scan 时进行 Rename 导致结果未定义

我们有一个服务有这种场景:

  • Web 服务提供查询接口返回一个很大的数据集给客户端

这个数据集使用 Sets 结构进行保存,读取这个数据集使用 sscan 命令每次返回 100 条记录,直到读完为止。

  • 后台计算脚本计算并更新数据集

计算结果先放到一个临时 key 中,等到计算完再通过 rename 覆盖正式的 key

最近,客户方反映获取的数据集偏小,正常应该是 20 多万条记录,结果只取到几万条记录。

我们怀疑是 Scan 过程中如果数据集被 rename 覆盖,结果可能是未定义的,见以下测试程序:

redis_rename_when_scan.js

"use strict";

const Redis = require('ioredis');
const async = require('async');
const _ = require('lodash');
const crypto = require('crypto');

const key_ = "test:set_";
const key = "test:set";
const min = 0;
const max = 100000;
const prefix_length = 20;
let scan_round = 0;
let write_round = 0;

function add(client, value, callback) {
    if (value > max) {
        return callback(null);
    }
    crypto.randomBytes(parseInt(prefix_length / 2, 10), function(err, buffer) {
        if (err) {
            return callback(err);
        }
        // Test full random dataset
        //let prefix = buffer.toString('hex');
        // Test fixed dataset
        //let prefix = _.pad('', prefix_length, '0');
        // Test partial random dataset
        let prefix = _.pad('', prefix_length - 1, '0') + buffer.toString('hex')[0];
        return client.sadd(key_, prefix + value, function(err) {
            if (err) {
                return callback(err);
            }
            value += 1;
            return add(client, value, callback);
        });
    });
}

function write(client) {
    if (!client) {
        client = new Redis();
    }
    client.del(key_, function(err) {
        if (err) {
            throw err;
        }

        add(client, min, function(err) {
            if (err) {
                throw err;
            }

            client.rename(key_, key, function(err) {
                if (err) {
                    throw err;
                }
                console.log("write#" + write_round + " DONE");
                ++write_round;
                write(client);
            });
        });
    });
}

function ready(client, callback) {
    if (!client) {
        client = new Redis();
    }
    client.exists(key, function(err, exists) {
        if (err) {
            callback(err);
        } else if (exists) {
            callback(null);
        } else {
            setTimeout(function() {
                ready(client, callback);
            }, 100);
        }
    });
}

function init(callback) {
    let client = new Redis();
    client.del(key_, function(err) {
        if (err) {
            throw err;
        }

        client.del(key, function(err) {
            if (err) {
                throw err;
            }

            callback();
        });
    });
}

function scan(client) {
    if (!client) {
        client = new Redis();
    }
    ready(null, function() {
        let results = [];
        var cursor = 0;
        async.doWhilst(function(done) {
            client.sscan(key, cursor, 'COUNT', 100, function(err, values) {
                if (err) {
                    throw err;
                }

                cursor = +values[0];

                values[1].forEach(function(value) {
                    results.push(value);
                });

                // console.log("scan#" + scan_round + " scan " + results.length + " values");
                setTimeout(done, 5);
            });
        }, function() {
            return cursor > 0;
        }, function(err) {
            if (err) {
                throw err;
            }

            let count_error = false;
            if (results.length != (max - min + 1)) {
                if (results.length < (max - min + 1)) {
                    console.error("scan#" + scan_round + " Expected " + (max - min + 1) + " values, Got " + results.length);
                    count_error = true;
                } else {
                    console.warn("scan#" + scan_round + " Expected " + (max - min + 1) + " values, Got " + results.length);
                }
            }
            if (!count_error) {
                results.sort((a, b) => parseInt(a.substring(prefix_length), 10) - parseInt(b.substring(prefix_length), 10));
                let uniq_results = _.sortedUniq(results);
                if (uniq_results.length != results.length) {
                    console.warn("scan#" + scan_round + " Expected 0 duplicated values, Got " + (results.length - uniq_results.length));
                }
                let result_error = false;
                for (let i = 0; i <= (max - min); ++i) {
                    if (parseInt(results[i].substring(prefix_length), 10) != (min + i)) {
                        //console.log("results: " + results.toString());
                        console.error("scan#" + scan_round + " value#" + i + " Expected " + results[i].substring(0, prefix_length) + (min + i) + ", Got " + results[i]);
                        result_error = true;
                        break;
                    }
                }
                if (!result_error) {
                    console.log("scan#" + scan_round + " OK");
                }
            }

            ++scan_round;
            scan(client);
        });
    });
}

init(function() {
    write(null);
    scan(null);
});

测试表明,取决于数据集变化的幅度,变化越大的数据集,获取到的结果偏差越大。但是获取到的记录数终始相差不大, 10 万条记录偏差 +-200 左右,前面遇到的数据记录数急剧减少可能是其它问题引起,如:计算出的记录数确实是有剧减。

记录数偏差不大可能是因为 Redis 在存储 Sets 数据时是有排序的, scan 使用的 cursor 类似 offset ,所以上一个数据集的 cursor 用于查询下一个数据集时偏差也不会太大。

但是这始终是一个问题,一个稳健的系统不应该有未定义行为。一个可行的解决方案是不要使用 rename ,而是在正式的 key 中保存临时 key ,查询时先从正式的 key 取到真正保存数据的临时 key ,再从临时 key 中取数据,只要确保每次计算都使用不同的临时 key 就可以避免数据访问冲突导致的未定义行为。这样子改动后,Redis 中会保存多份临时数据,需要设置合理的过期的时间,避免占用大量内存。


redis