公司最近开发一款云WAF产品,需要移植部分阿里云的功能,主要是对设备根据地域划分组,需要 处理 region, deploy, group, device 四者的关系。

使用chen’s SQL图大概是这样的:

sql chen's structure

这里面主要问题是 region , group 表的设计要求: group_id 和 region_id 是自动产生的, 并且要求 unique ,需要按照R1, R2…顺序自增,G1, G2…顺序自增加。这里的问题 是如果接口是批量的化,那么这个自动生成的序列可能会交叉,并发比较大的情况下,失败 的情况会急剧增加,十分影响使用。

另一个问题是 group_id 也是要自动生成,构造方法是G-{region_id}-{id}其中 region_id 是其所属的 region_id,而 id 也要是一个自增加的序列,这一列要求也是 unique 。

这与我们常见的并发读写一行的情况有所不同,平时我们见着比较多的情况是,有5000件商品, 秒杀的时候大家都在抢商品,那么不同用户之间怎么保持数据不发生错乱,这种情况就是最常见 的一种,但是我们的这个其实是发生在同一个用户身上的批量操作,其实不同用户之间也可能发生。 针对常见情况的,我们可以通过乐观锁和尝试的方式解决,但是第二类问题,明显是一类新的问题。

关于自动生成唯一序列

mysql 中其实默认有一个 auto_increament , 但是要求是 int 类型,所以这个地方并不是所有类型 都是可以的,而我们需求是实现R1,R2…那么我们可以把 region_id 就定义成 int 类型,在业务代码 中人为通过字符串格式化成自己需要的方式,这个最佳的方式,直接利用 mysql 自身的保证机制,但是有 时候人就是一根筋,领导来了句,就要存R1,R2…(又不是要来读取数据库,还不是要用api)。

假设有两个用户同时批量添加三个region, 那么第一个用户读取的基础 region_id 可能是 R5 那么 他 生成的序列是R6,R7,R8…同样的另外一个用户在他没有写入的情况下,也是R6,R7,R8…那么他们两个 有一个就会失败,只会有一个成功,实际上我们需要达到的要求是,他们两个的序号是可以交叉的,但是 总体的序号不能重复就行了。

还是那句话,要不是领导要这么干,我就用自带的机制了。

锁表

利用 django 的 raw 方法来锁住表,这个效率很低,我们只需要锁住read方法,但是这个时候获取 region 接口都不可用,如果需要插入的数据很多,那这个设计很糟糕。

先插入后更新

无故新增一个进程,问题是更新时间和频率不能控制,不是一个十分满意的方法,而且可以说很烂。

能不能使用乐观锁的思想

并发写同一列数据的时候,实际上利用乐观锁 ‘锁’ 了行的写功能,而我们这里需要锁住的是数据的行的 读功能,并且为了特定的写业务,所以我们可以尝试一下。

这个问题的根源在于不同的用户请求由不同的进程进行执行,而这两个进程之间没有交互,要想让他们进行 合理高效的插入,则必须统一管理起来,原来由 mysql 管理,现在分别管理,那么就要求同步做的很好, 这个地方同步就只能用互斥的写锁,一般 django 的 web 应用 uwsgi 代理,使用 prefork 相当于 启动四个独立的进程,不同进程内部可以共享变量,但是不同进程之间无法简单共享对象,所以这个锁无法 很简单的设计,但是又不能因为一个简单的锁,增加太多东西,那么我么是不是考虑可以在数据库上面加个 行锁?

考虑常见的空间换时间的做法,在数据表 region 上添加一列数据, version 字段,每次写入的时候 都需要检查所参考的基础 region_id 所在行的 version 是否已经变化,如果变化就重新读取,另外 每次写入都要更新所参照 region_id 所在行的 version 值,考虑到数据的写入可能很快,所以这个 version 字段最好不要使用时间戳,可以考虑 int 类型,自己维护其值。另外如果批量的数据较多,可以 考虑将批量单个插入数据库,锁行,但是效率还是不如直接在数据库里面做。

字段 类型
name varchar(256)
service_id int
region_id varchar(64)
version int
description varchar(2048)

参考代码:

from django.db import transaction
with transaction.atomic():
    ## lock version
    obj = Region.objects().select_for_update().last()
     if obj:
            pattern = obj.group_id
            base = int(pattern.rsplit("-")[-1])
    else:
        base = 0
    objs = Region.objects.bulk_create(
                [
                    Region(region_id="R{0}".format(id), **item)
                    for id, item in zip(range(base+1), attrs)
                ], batch_size=BATCH_INSERT_SIZE
    )
    ## update version
    obj.version = F("verion") + 1
    obj.save()
retValue = [model_to_dict(obj) for obj in objs]

group 跨表引用

与上面的一个业务类似,每次需要有类似的业务的时候,那么需要等待其中一个释放锁了才能进行,同样需要在 group 表添加一个 version, 这样就可以保证 group 表的自曾不会发生问题,那么实际上第二个问题不会 关心region_id的问题,这个跨表只是引用,而不是修改,所以没有什么影响。

可能存在的问题

  1. 多个进程阻塞等待一个进程插入完大量的数据,可能降低 web 的性能
  2. 如果一列被锁定了,会不会影响其他的读业务

针对第一个问题首选基于 libevent 的 gevent, 这可以保证在锁等待的时间段内,可以处理其他 ready 的请求。针对第二个问题是不会的,它会阻塞下一个 select for update 方法,并且是两者 select 的数据有交叉的情况下,另外根据 mysql 官方的文档:

For index records the search encounters, locks the rows and any associated index entries, the same as if you issued an UPDATE statement for those rows.

也就是说如果你是全表扫描的化,那么基本上就锁表了,所以执行前可以通过 explain 优化 django orm 的查询语句,尽量减少 查询涉及的行,如果可以的化,可以使用索引,尽量不要使用范围查询。

参考

  1. mysql 乐观锁实现
  2. mysql lock