Redis数据结构使用场景及优化示例

Redis 学习路径

image

基础数据类型和底层数据结构对应关系

image

一、Hash在什么时候使用压缩列表

Hash 类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?

其实,Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。

这两个阈值分别对应以下两个配置项:

hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。

hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。

// 设置 hash-max-ziplist-entries
config set hash-max-ziplist-entries 512

// 获取 hash-max-ziplist-entries
config get hash-max-ziplist-entries

// 设置 hash-max-ziplist-value
config set hash-max-ziplist-value 64

// 获取 hash-max-ziplist-value
config get hash-max-ziplist-value

如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。

一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。

优化示例,存储格式为 key: 1000000000 value: 2000000000

使用String存储1万个键值对,平均每个键值对占用内存情况如下

数据结构 key类型 value类型 平均占用/B
String String Long 69.1072
String String String 85.1072

使用Hash且底层为压缩列表

在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法。

即将key: 1000000000 拆分为两部分,前7位做为key,后3位做为field

因为field为三位数,范围为0~999共1000个,所以将hash-max-ziplist-entries设置为1000后,按照以上规格进行存储,就可以一直使用Hash且底层为压缩列表来存储数据,存储占用内存情况如下

数据结构 key类型 field类型 value类型 平均占用/B
Hash String String Long 10.2952
Hash String String String 20.5336

综上所述,我们以前总认为 String 是“万金油”,什么场合都适用,但是,在保存的键值对本身占用的内存空间不大时(例如这里的例子),String 类型的元数据开销就占据主导了,这里面包括了 RedisObject 结构、SDS 结构、dictEntry 结构的内存开销。针对这种情况,我们可以使用压缩列表保存数据。当然,使用 Hash 这种集合类型保存单值键值对的数据时,我们需要将单值数据拆分成两部分,分别作为 Hash 集合的键和值,希望你能把这个方法用到自己的场景中。

如果你想知道键值对采用不同类型保存时的内存开销,可以在这个http://www.redis.cn/redis_memory/网址里输入你的键值对长度和使用的数据类型,这样就能知道实际消耗的内存大小了。建议你把这个小工具用起来,它可以帮助你充分地节省内存。

二、Set的聚合统计

现在有一个需求,统计每天的新增用户数和第二天的留存用户数。

我们可以使用Set的聚合统计功能,所谓的聚合统计,就是指统计多个集合元素的聚合结果,包括:

1.统计多个集合的共有元素(交集统计);

2.把两个集合相比,统计其中一个集合独有的元素(差集统计);

3.统计多个集合的所有元素(并集统计)。

Java实现代码

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    void test() {
        // 1. 记录 "2020-08-03登录用户" = [1,2,3,4,5,6,7,8,9,10]
        for (int i = 1; i <= 10; i++) {
            redisTemplate.opsForSet().add("user:id:20200803", i);
        }

        // 2. 2020-08-04 00:00:00的时候,设置 "累计用户" = "2020-08-03登录用户" 和 "累计用户" 的并集
        redisTemplate.opsForSet().unionAndStore("user:id", "user:id:20200803", "user:id");

        // 3. 记录 "2020-08-04登录用户" = [6,7,8,9,10,11,12,13,14,15]
        for (int i = 6; i <= 15; i++) {
            redisTemplate.opsForSet().add("user:id:20200804", i);
        }

        // 4. 2020-08-05 00:00:00的时候,计算并存储 "20200804的新增用户" = [11,12,13,14,15]
        redisTemplate.opsForSet().differenceAndStore("user:id:20200804", "user:id", "user:id:20200804:add");

        // 5. 2020-08-05 00:00:00的时候,计算并存储 "20200804的留存用户" = [6,7,8,9,10]
        redisTemplate.opsForSet().intersectAndStore("user:id:20200804", "user:id:20200803", "user:id:20200804:keep");

    }
}

运行以上代码后,可在可视化工具中看到以下结果

20200804的新增用户


image

20200804的留存用户


image

当你需要对多个集合进行聚合计算时,Set 类型会是一个非常不错的选择。不过,我要提醒你一下,这里有一个潜在的风险。

Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。

所以,我给你分享一个小建议:你可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的风险了。

三、List和ZSet的排序统计

接下来,我们再来聊一聊应对集合元素排序需求的方法。我以在电商网站上提供最新评论列表的场景为例,进行讲解。

最新评论列表包含了所有评论中的最新留言,这就要求集合类型能对元素保序,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。

在 Redis 常用的 4 个集合类型中(List、Hash、Set、Sorted Set),List 和 Sorted Set 就属于有序集合。

List 是按照元素进入 List 的顺序进行排序的,而 Sorted Set 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

Java实现代码

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // list和zset数据初始化
    private static final String key = "commentList";
    private static final String key2 = "commentZset";
    @Test
    void set() {
        for (int i = 1; i <= 1000; i++) {
            // 根据时间戳生成唯一的score,13位时间戳+3位序列,zset的score超过16位会丢失精度
            Double score = ScoreGenerator.nextScore();

            Comment comment = Comment.builder()
                    .score(new BigDecimal(score).toString())
                    .comment(String.valueOf(i))
                    .build();

            redisTemplate.opsForList().leftPush(key, comment);
            redisTemplate.opsForZSet().add(key2, comment, new BigDecimal(score).doubleValue());
        }
    }

    // list分页方法
    private List<Comment> listPage(Long current, Long size) {
        Long start = (current - 1) * size;
        Long end = current * size - 1;
        List<Object> objs = redisTemplate.opsForList().range(key, start, end);
        List<Comment> comments = objs.stream().map(p -> (Comment) p).collect(Collectors.toList());
        return comments;
    }

    // zset分页方法
    private List<Comment> sortedSetPage(Long current, Long size) {
        Long start = (current - 1) * size;
        Set<Object> objects = redisTemplate.opsForZSet().reverseRangeByScore(key2, 0, 9999999999999999.0, start, size);
        List<Comment> comments = objects.stream().map(p -> (Comment) p).collect(Collectors.toList());
        return comments;
    }

    // zset分页方法2
    private List<Comment> sortedSetPage2(Double maxScore, Long size) {
        Set<Object> objects = redisTemplate.opsForZSet().reverseRangeByScore(key2, 0, maxScore, 0, size);
        List<Comment> comments = objects.stream().map(p -> (Comment) p).collect(Collectors.toList());
        return comments;
    }

    // list查询打印
    @Test
    void getListPage(Long current, Long size) {
        long l = System.currentTimeMillis();
        List<Comment> comments = listPage(current, size);
        System.out.println("list查询耗时: " + (System.currentTimeMillis() - l) + "ms");
        System.out.println(JSON.toJSONString(comments));
    }

    // zset查询打印
    @Test
    void getSortedSetPage(Long current, Long size) {
        long l = System.currentTimeMillis();
        List<Comment> comments = sortedSetPage(current, size);
        System.out.println("zset查询耗时: " + (System.currentTimeMillis() - l) + "ms");
        System.out.println(JSON.toJSONString(comments));
    }

    // zset查询打印
    @Test
    double getSortedSetPage2(Double maxScore, Long size) {
        long l = System.currentTimeMillis();
        List<Comment> comments = sortedSetPage2(maxScore, size);
        System.out.println("zset查询耗时: " + (System.currentTimeMillis() - l) + "ms");
        System.out.println(JSON.toJSONString(comments));
        return new BigDecimal(comments.get(comments.size() - 1).getScore()).doubleValue();
    }

    @Test
    void test() {
        // 遍历查询出所有数据,每页10条
        Long size = 10L;
        Double maxScore = 9999999999999999.0;
        for (int i = 1; i <= 100; i++) {
            System.out.println("查询第" + i + "页");
            // 这两种方法在翻页的时候,如果新插入一条数据都会造成,上一页最后一条和下一页第一条数据重复
            getListPage(0L + i, size);
            getSortedSetPage(0L + i, size);

            // 这种根据maxScore 来查询的方法能避免以上数据重复问题
            // 查询第一页,给一个足够大的maxScore,第二页的maxScore等于第一页最后一项的score-1
            maxScore = getSortedSetPage2(maxScore, size) - 1;
            System.out.println();
        }

    }


}


@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
class Comment {
    private String score;
    private String comment;
}


class ScoreGenerator {

    public static void main(String[] args) {
        HashSet<Double> doubles = new HashSet<>();
        long l = System.currentTimeMillis();
        for (int i = 0; i < 1000000; i++) {
            Double aDouble = ScoreGenerator.nextScore();
            // 校验是否会重复
            if (!doubles.contains(aDouble)) {
                doubles.add(aDouble);
            } else {
                throw new RuntimeException("重复");
            }
        }
        System.out.println((System.currentTimeMillis() - l) + "ms");

    }

    private static long lastTimestamp = -1L;
    private static long sequence = 1L;

    public synchronized static Double nextScore() {
        long timestamp = System.currentTimeMillis();

        //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
        if (timestamp < lastTimestamp) {
            System.err.printf("clock is moving backwards.  Rejecting requests until %d.", lastTimestamp);
            throw new RuntimeException(String.format("Clock moved backwards.  Refusing to generate id for %d milliseconds",
                    lastTimestamp - timestamp));
        }

        //获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。
        if (lastTimestamp == timestamp) {
            sequence = sequence + 1;
            if (sequence > 999) {
                timestamp = tilNextMillis(lastTimestamp);
                sequence = 1;
            }
        } else {
            sequence = 1;
        }

        //将上次时间戳值刷新
        lastTimestamp = timestamp;

        Long score = timestamp * 1000 + sequence;

        return new BigDecimal(score).doubleValue();
    }

    //获取时间戳,并与上次时间戳比较
    private static long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

执行List和ZSet初始化方法后,数据如下

image

image

执行分页查询方法后,结果如下

image

image

image

可以看到以上三种查询方式,效率上差别不大。所以,在面对需要展示最新列表、排行榜等场景时,可以采用以上方法。如果数据更新频繁,不能接受翻页时可能出现重复数据的问题,建议你优先考虑使用 Sorted Set。

四、Bitmap的二值状态统计

现在,我们再来分析下第三个场景:二值状态统计。这里的二值状态就是指集合元素的取值就只有 0 和 1 两种。在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

在签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。这个时候,我们就可以选择 Bitmap。这是 Redis 提供的扩展数据类型。我来给你解释一下它的实现原理。

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。你可以把 Bitmap 看作是一个 bit 数组。

Java实现代码

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // 计算用户(1000)在2021年1月签到总天数
    @Test
    void bitCount() {
        String key = "uid:sign:1000:202101";
        // 代表用户1000在2021年1月1日签到了
        redisTemplate.opsForValue().setBit(key, 0, true);

        // 代表用户1000在2021年1月6日签到了
        redisTemplate.opsForValue().setBit(key, 5, true);

        // 查询用户1000在2021年1月6日是否签到了
        Boolean bit = redisTemplate.opsForValue().getBit(key, 5);
        System.out.println(bit); // true

        // 查询用户1000在2021年1月的签到次数
        Long count = redisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
        System.out.println(count); // 2次
    }

    // 计算用户连续签到天数,假设某个7天的活动连续签到7天有奖品
    @Test
    void bitOp() {
        //  假设5个用户参加活动,每一天的每一位是一个用户
        // day1每个用户都签到了
        for (int i = 0; i < 5; i++) {
            redisTemplate.opsForValue().setBit("day1", i, true);
        }

        // day1前4个用户都签到了
        for (int i = 0; i < 4; i++) {
            redisTemplate.opsForValue().setBit("day2", i, true);
        }

        // day1前3个用户都签到了
        for (int i = 0; i < 3; i++) {
            redisTemplate.opsForValue().setBit("day3", i, true);
        }
        // day1前2个用户都签到了
        for (int i = 0; i < 2; i++) {
            redisTemplate.opsForValue().setBit("day4", i, true);
        }

        // 之后只有用户0坚持了下来
        redisTemplate.opsForValue().setBit("day5", 0, true);
        redisTemplate.opsForValue().setBit("day6", 0, true);
        redisTemplate.opsForValue().setBit("day7", 0, true);


        // 对day1~day7 按位与,存储到destination中。RedisStringCommands.BitOperation还包含其他逻辑操作
        Long bitAnd = redisTemplate.execute((RedisCallback<Long>) con ->
                con.bitOp(RedisStringCommands.BitOperation.AND,
                        "destination".getBytes(), "day1".getBytes(),
                        "day2".getBytes(), "day3".getBytes(), "day4".getBytes(),
                        "day5".getBytes(), "day6".getBytes(), "day7".getBytes()));
        System.out.println(bitAnd);

        // 查询结果,对应位数上的用户为ture则代表连续签到了七天
        ArrayList<Boolean> destination = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            destination.add(redisTemplate.opsForValue().getBit("destination", i));
        }
        System.out.println(destination); // [true, false, false, false, false]

    }

    // bitpos 是用来查询一个二进制串里第一个0或者1的位置。可设置查询的位范围
    @Test
    void bitPos() {
        redisTemplate.opsForValue().setBit("bitPos", 5, true);
        Long execute = redisTemplate.execute((RedisCallback<Long>) con -> con.bitPos("bitPos".getBytes(), true));
        System.out.println(execute); // 5
    }

}

如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。在记录海量数据时,Bitmap 能够有效地节省内存空间。

五、HyperLogLog的基数统计

我们再来看一个统计场景:基数统计。基数统计就是指统计一个集合中不重复的元素个数。对应到我们刚才介绍的场景中,就是统计网页的 UV。网页 UV 的统计有个独特的地方,就是需要去重,一个用户一天内的多次访问只能算作一次。

在 Redis 的集合类型中,Set 类型默认支持去重,所以看到有去重需求时,我们可能第一时间就会想到用 Set 类型。但是,如果 UV 达到了千万,这个时候,一个页面的 Set 就要记录千万个用户 ID, 如果每个页面都用这样的一个 Set,就会消耗很大的内存空间。

HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。

Java实现代码

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    void test() {
        // 假设一万个用户访问了page1
        long l = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            redisTemplate.opsForHyperLogLog().add("page1:uv", i);
        }
        System.out.println("添加完成,耗时" + (System.currentTimeMillis() - l) + "ms");

        Long size = redisTemplate.opsForHyperLogLog().size("page1:uv");
        System.out.println(size); // 9987个,存在标准误算 0.81%
    }

}

不过,有一点需要你注意一下,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。

六、GEO 支持 LBS 应用

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。

Java实现代码

这里模拟叫车业务,查询用户附近十公里内的车辆信息

@SpringBootTest
public class RedisTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    void test() {
        String key = "cars:locations";

        // 添加车
        redisTemplate.opsForGeo().add(key, new Point(114.417265, 30.453276), "car1");
        redisTemplate.opsForGeo().add(key, new Point(114.403287, 30.455175), "car2");
        redisTemplate.opsForGeo().add(key, new Point(100, 20), "car3");

        // 人的位置
        Point people = new Point(114.408031, 30.447827);

        // 查10公里内最近的10辆车
        List<Result> nearCars = this.getNearCars(key, people,
                new Distance(10000, RedisGeoCommands.DistanceUnit.METERS), // 10公里内
                10L); // 十条记录

        // 打印查询结果
        nearCars.forEach(p -> System.out.println(p));
    }

    // 获取附近车、车坐标以及人与车的距离
    public List<Result> getNearCars(String key, Point point, Distance distance, Long limit) {
        // 获取附近车的集合
        GeoResults<RedisGeoCommands.GeoLocation<Object>> radiusGeo = redisTemplate.opsForGeo().radius(key,
                new Circle(point, distance),
                RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
                        .includeDistance() // 包含距离
                        .includeCoordinates() // 包含坐标
                        .sortAscending() // 由近到远排序
                        .limit(limit) // 获取记录数
        );

        List<Result> cars = new ArrayList<>();

        // 遍历附近车的集合
        Iterator<GeoResult<RedisGeoCommands.GeoLocation<Object>>> result = radiusGeo.iterator();
        while (result.hasNext()) {
            GeoResult<RedisGeoCommands.GeoLocation<Object>> geoLocation = result.next();
            // 车
            String c = (String) geoLocation.getContent().getName();
            // 车坐标
            Point p = geoLocation.getContent().getPoint();
            // 人与车的距离
            Distance d = geoLocation.getDistance();

            cars.add(Result.builder()
                    .carId(c)
                    .longitude(new BigDecimal(p.getX()).setScale(6, BigDecimal.ROUND_HALF_UP))
                    .latitude(new BigDecimal(p.getY()).setScale(6, BigDecimal.ROUND_HALF_UP))
                    .distance((int) d.getValue())
                    .build()
            );
        }
        return cars;
    }

}

@Data
@Builder
class Result {
    private String carId;
    private BigDecimal longitude;
    private BigDecimal latitude;
    private Integer distance;
}

添加车辆后,可以在下图redis中看到,车辆坐标信息是用ZSET存储的,说明GEO是用ZSET做为底层实现的


image

查询附近车辆的结果如下图,可以看出只有两个距离小于10公里的车car1和car2被查了出来


image

七、List实现消息队列

https://www.jianshu.com/p/bd70702cda00

总结

image
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容