返回目录

很多人在大学都学过数据库,喜欢用那些第一范式,第二范式 ... 第X范式,来指导表设计。

这些设计思想充满了数学上的优雅和形式上的美感,不过正如一位低调的前辈大牛所说的那样:

学者要干的事情和街上混的程序员们干的事情完全不同,解决方案自然也完全两样。拿学术大师的话去指导产品开发,是要吃大亏的 ...

好吧,先把那些范式忘了吧,欢迎回到现实世界。

数据库的设计,向来是根据应用场景而设计。下面我们假设应用场景为千万级访问量的Web2.0网站,数据库为MySQL。

MySQL是Web2.0网站用的最广泛的数据库了,它在Facebook,Baidu,Google都有使用。想要深入的了解MySQL,可以看看它的官方文档 链接 ,下面提一些注意点。

在设计表的时候,不要显式的声明数据库中的外键关系。数据库中使用外键关系会显著拖累数据库的性能,也制约了数据库表的可拆分性。同样那些触发器,存储过程,也不要使用。

简单地说,只使用MySQL最简单最基本功能,来保证性能的高效,同时将逻辑代码与数据库分隔开,方便以后更换后端的存储方式 -- 比如还没发布但是很有前景的Drizzle ( https://launchpad.net/drizzle ) 。

需要作为where条件查询的字段,要加上索引。多个字段联合索引,比如 boy_id - grid_id , 当where条件中包含 boy_id 和 grid_id ,或只有 boy_id 时会使用该索引,当 where条件中只有 grid_id 时,不会使用该索引 。

用explain语句能查看索引使用的细节,比如

explain select * from friends where grid_id = 987654;

如果字段的名字是sql语句的关键词 -- 比如 key ,你在查询时需要加上反引号,如下:

select * from misc where `key` = "salt";

每一张表中,不要有太多字段,但应该都有一个自增的id作为主键。

更多细节现在就不扯了,以后碰到再慢慢说。

我们开始实战。

首先创建数据库,第一个数据库的名词就取世界树的缩写(World Tree)叫做 wtree 吧。

CREATE DATABASE wtree CHARACTER SET utf8 COLLATE utf8_bin;
use wtree;

创建时指定编码为 utf8_bin ,这样以后建表的默认编码就是 utf8_bin 了。

首先设计四个表 -- 用户的电子邮件表,用户密码表,用户申请注册的表 和 用户的基本信息表。

建表可以使用一些辅助工具,比如客户端的MySQL GUI Tools ( http://dev.mysql.com/downloads/gui-tools/ ) ,或WEB界面的phpmyadmin( http://www.phpmyadmin.net/ )。

用户电子邮件表结构很简单,只有2列。电子邮件表中的id就是用户的id。我们将其自增主键id的起始值设置为一千万,小于一千万的id预留做其他用途。

数值类型,比如 int ,后面圆括号是指出最大的显示尺寸,与实际的存储范围无关。

CREATE TABLE `user_email` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `email` varchar(128) collate utf8_bin default NULL,
  PRIMARY KEY  (`id`),
  UNIQUE KEY `email` (`email`)
) ENGINE=MyISAM AUTO_INCREMENT=10000000 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

密码表,我们不存放密码原文,而存放用 sha256 哈希过的密码。binary表示是二进制数值,存放它比直接存字符串类型(char)更节省空间。

CREATE TABLE `user_password` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `password` binary(32) default NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

用户申请注册的表如下,其中 ck 是 challenge key 的缩写,代表激活码。:

CREATE TABLE `user_apply` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `ck` binary(6) NOT NULL,
  `time` timestamp NOT NULL default CURRENT_TIMESTAMP,
  `name` varchar(8) collate utf8_bin default NULL,
  PRIMARY KEY  (`id`),
  KEY `time` (`time`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

用户表如下,url是让用户可以指定个性化的url,ver是用户头像的版本号,作用和之前介绍过的静态文件的版本号一样。

用户表的是存储引擎是Innodb。

MySQL有两大存储引擎,MyISAM和Innodb。

MyISAM 为读操作优化,速度比较快,但是写使用全局锁,并发性差,并且不支持事务。

InnoDB 是行级锁,写并发性能好,支持事务,但读取相比会慢一些。

注册以后,用户可以自行修改用户名,上传头像时会改动版本号,所以该表可能会有频繁的修改,因此选用 Innodb。

当然,对于小规模的网站,这些细微地性能差异可以忽略。

CREATE TABLE `user` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `name` char(8) collate utf8_bin NOT NULL,
  `url` varchar(32) collate utf8_bin default NULL,
  `ver` tinyint(3) unsigned NOT NULL default '0',
  PRIMARY KEY  (`id`),
  UNIQUE KEY `url` (`url`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

最后新建用户重设密码申请的表,用户登录session的表,用户详细资料的表。

CREATE TABLE `user_reset_password` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `ck` binary(6) NOT NULL,
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `time` (`time`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

CREATE TABLE `user_session` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `ck` binary(6) NOT NULL DEFAULT '\0\0\0\0\0\0',
  `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `time` (`time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

CREATE TABLE `user_profile` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `sex` char(1) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL,
  `birth_age` tinyint(3) unsigned DEFAULT NULL,
  `is_single` tinyint(4) DEFAULT NULL,
  `industry` tinyint(3) unsigned DEFAULT NULL,
  `title` varchar(16) COLLATE utf8_bin DEFAULT NULL,
  `city` smallint(5) unsigned DEFAULT NULL,
  `hometown` smallint(5) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `industry` (`industry`),
  KEY `city_hometown` (`city`,`hometown`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

在数据库中创建上述表之后,修改 myconf/config.py ,配置数据库。

WTREE_HOST = "localhost:3306:wtree:root:111111"

... ... ...

if not DATABASE_CONFIG:
    DATABASE_CONFIG = {
        "wtree": {
            "master": WTREE_HOST,
            "tables": (
                "*",
                "user","user_apply","user_password","user_email",
                "user_profile","user_session","user_reset_password",
            ),
        },
    }

tables是指定表在哪一个数据库中,*是通配符,代表默认数据库。

这样,将来不同的表可以方便的分解到不同机器上的数据库中去,以便实现最简单的分布式。

再到新浪申请一个邮箱,我们先暂时借用新浪邮件的SMTP服务器来发邮件。申请完成后,同样修改一下配置。

#系统发信人的邮箱地址和显示名称
SYS_EMAIL_SENDER = "stdyun@sina.com"
SYS_EMAIL_SENDER_NAME = "stdyun"

#发信的SMTP服务器,用户名,密码
SMTP = "smtp.sina.com"
SMTP_USERNAME = "stdyun"
SMTP_PASSWORD = "xxxxxx"

编辑 mysite/model/user.py 。

#coding:utf-8
from init_db import McModel, Model
from mysite.util.security import random_char
from hashlib import sha256 as password_hash
from base64 import urlsafe_b64encode,urlsafe_b64decode
import binascii
from sendmail import render_email
from struct import pack,unpack

class UserEmail(McModel):
    pass

class UserPassword(Model):
    def verify(self, password):
        p = self.password
        return p == hash_password(self.id, password)

class UserApply(Model):
    def sendemail(self, email):
        ck = urlsafe_b64encode(self.ck)
        render_email("auth/user_apply", email, self.name, ck=ck)

    def verify(self, ck):
        return urlsafe_b64encode(self.ck) == ck

class UserResetPassword(Model):
    def sendemail(self, email,name):
        ck = urlsafe_b64encode(self.ck)
        render_email("auth/user_reset_password", email,name,id=self.id, ck=ck)

    def verify(self, ck):
        return urlsafe_b64encode(self.ck) == ck

class User(McModel):
    pass

class UserSession(McModel):
    @classmethod
    def login(cls,id):
        user_session = cls.mc_get(id)
        if user_session:
            ck = user_session.ck
            if ck == '\x00\x00\x00\x00\x00\x00':
                ck = False
            if not ck:
                ck = random_char()
                user_session.ck = ck
                user_session.save()
        else:
            ck = random_char()
            user_session = cls(id=id,ck=ck)
            user_session.save()
        key = "%s%s"%(pack("I",int(id)),ck)
        return urlsafe_b64encode(key)[:14]

    @classmethod
    def verify(cls, session):
        try:
            cookie = urlsafe_b64decode(session+"==")
        except binascii.Error:
            return
        id = unpack("I",cookie[:4])[0]
        session = cls.mc_get(id)
        if session:
            ck = session.ck
            if ck and ck!='\x00\x00\x00\x00\x00\x00' and ck == cookie[4:]:
                return User.mc_get(id)

    @classmethod
    def logout(cls,id):
        self = cls.mc_get(id)
        if self:
            self.ck = ""
            self.save()

class UserProfile(McModel):
    pass

def hash_password(id, password):
    return password_hash("%s%s"%(password, pack('L', int(id)))).digest()

def reset_password(id,password):
    UserResetPassword(id=id).delete()
    password = hash_password(id,password)
    UserPassword.where(id=id).update(password=password)

def reset_password_apply(id):
    ck = random_char()
    user_reset_password = UserResetPassword.replace_into(id=id,ck=ck)
    return user_reset_password

def apply(email, password, name):
    id = UserEmail.replace_into(email=email).id

    password = hash_password(id, password)
    user_password = UserPassword.replace_into(id=id,password = password)

    apply = UserApply.get(id)
    if apply is None:
        apply = UserApply(id=id, ck=random_char())
    apply.name = name
    apply.save()

    return apply

def is_existed(email):
    e = UserEmail.get(email=email)
    if e:
        return User.mc_get(e.id)

def is_applyed(email):
    user_email = UserEmail.get(email=email)
    if user_email:
        if User.mc_get(user_email.id):
            return False
        elif user_email:
            user_apply = UserApply.get(user_email.id)
            if user_apply:
                return user_apply
            else:
                user_email.delete()

def create_user(id):
    apply = UserApply.get(id)
    if apply:
        user = User(id=id, name=apply.name)
        user.save()
        apply.delete()
        return user

USER_RELATED_BY_ID = (User, UserEmail, UserApply, )

def remove_user_by_email(email):
    UserEmail.begin()
    e = UserEmail.get(email=email)
    if e:
        id = e.id
        for cls in USER_RELATED_BY_ID:
            cls(id=id).delete()
        e.delete()
    UserEmail.commit()

首先可以看到引入了 Model 和 McModel 。

Model 和 McModel 是一种 ORM 的基类。

ORM,全称 Object Relation Mapping , 中文含义是对象和关系的映射。通过ORM,可以简化常见的增删查改,提高开发效率。

继承这个ORM的基类,它会自动的将数据库中表和对象进行映射。

McModel 和 Model 的区别是,McModel附带了memcached的支持。

memcached是一个高性能的基于内存的键值缓存,性能优异,是MySQL的十多倍,使用它可以提高页面的响应速度。

但更重要的是,简单的键值方式存储可以非常方便的实现多机分布式(Facebook用了800台memcached)。这样可以大大降低MySQL的查询压力 -- 通常,MySQL数据库是网站最瓶颈的地方。

我们将在以后的实战中慢慢介绍它。想要更深入的了解memcached可以看看这个手册 链接

windows下,我们只做开发,所以不用启动memcached和修改配置文件,mypy框架会默认使用一个假的(fake)memcached。

接着看代码。

UserPassword中定义了verify方法,用来校验密码是否正确。

hash_password, 是用来对密码进行哈希的单向函数。数据库中是不保存密码明文的,即使数据库被黑客窃取,黑客也无法获知用户的密码。

不少黑客预先将大量常见密码的哈希值计算出来,试图通过查表的方式迅速破解密码。为了防止这种暴力破解,我们把密码与id拼接起来再做哈希。密码学上将这种用于拼接的字符串称为salt,中文含义为盐。

UserApply 的 send_apply_email ,调用了 render_email 函数来发送邮件。第一个参数 "auth/send_apply_email" 指定了邮件的模板,对于模板文件是 mysite/txt/auth/send_apply_email.txt 。模板内容如下,内容第一行是邮件的标题:

欢迎注册 stdyun.com , 请验证您的邮箱

{name},你好:

{email} 在 stdyun.com 申请注册,因此我们发送这封邮件进行确认。

请在七天内点击下面的链接来验证您的邮箱。

http://stdyun.com/auth/active/{email}/{ck}

如果无法点击上面的链接,你可以复制该地址,并粘帖在浏览器的地址栏中访问。

该模板是利用了python2.6字符串自带的format函数进行格式化,不支持mako模板那些复杂的语法,但是简单高效。

render_email接下来的参数是收件人的邮箱(email),收件人的姓名(name), 接下来 id=id, ck=ck 是传给模板的变量。

apply,用于创建申请。其中用ORM来操作数据库,用到了ORM中的几个函数。

replace_into,作用类似MySQL的replace语法,如果用Python和标准SQL来写,类似于下面的代码。

if id:
    cursor.execute("select * from user where id = %s", id)
    result = cursor.fetchone()
    if result is None:
        id = None

if id is None:
    cursor.execute("insert into user (email) values (%s)",email)
else:
    cursor.execute("update user set email='%s' where id=%s",(email, id))

get,获取单条记录。默认查询条件是id,比如

UserApply.get(e.id)

要指定查询,可以用类似下面的写法

UserApply.get(email=email)

新建的行,在ORM,可以在初始化时传入参数赋值;也可以在对象创建后,通过类操作赋值,最后记得要保存。如

apply = UserApply(id=id, ck=random_char())
apply.name = name
apply.save()

UserSession是用来标记用户是否登录,UserSession记录用户id和一段6字节随机字符串,id和随机字符串被用urlsafe_b64encode编码后放在用户浏览器端的cookie中。

网站可以在用户的浏览器中保存一段小的文本,称之为cookie。

每当用户浏览页面时,cookie的内容会随着http请求一起发送到服务器。因而可以用cookie来记录登录状态,跟踪用户的点击流。

值得注意的是,cookie大小被浏览器严格限制(不超过4095个字节),所以cookie不适合用来存储大文本。并且cookie发送到网站服务器也要消耗带宽。因而,cookie越精简越好。

最后需要牢记的是,从客户端发送过来的任何数据,都可能被别有用心的一小撮人篡改,所以cookie中的数据同样需要进行格式校验。

接着往下看代码。

def is_existed(email):
    e = UserEmail.get(email=email)
    if e:
        return User.mc_get(e.id)

is_existed,用到了McModel中的一个函数,mc_get。

mc_get的参数是函数的id,它会先尝试从memcached中获取数据。

如果memcached中不存在该行,它会去数据库中查询。查询有结果,将缓存结果 -- 下一次mc_get就会从memcached中获取数据了。

对于那些可能会频繁读取的数据,我们使用memcached来缓存它,比如这里的user对象。

我们没有使用多表级联来查询结果。

多表级联会限制关联到的表必须在同一台服务器上,不利于以后的扩展。

正如 Dan Pritchard 在谈论eBay构架时那段对白

对其它大型系统的架构,你有什么建议吗?

Dan Pritchard:

最简单的建议就是,给一个为小规模应用而设计的架构增加资源并不能让它变成大规模的架构。你必须打破常规模式,比如ACID和分布式事务。乐于寻找机会放松一些约束,即使传统上认为是不能放松的。

还有两条简单的原则:把每样东西都设计成分离的;考虑BASE、而不是ACID。

是的,把每样东西都设计成分离的,这是一个好习惯。

最后看 remove_user_by_email ,这里的用于删除用户账号的。通常的删除方式是

cls.where(id=id).delete()

但这样做的缺点是需要手工的清空memcached,如果是根据id删除,可以采用下面这种写法,它能自动清空memcached。

cls(id=id).delete()

UserEmail.begin 是对 UserEmail 所在的数据库使用事务,它对这个数据库的所有InnoDB表都会造成影响。

不过启用事务并不是为了保证数据的完整性,而是可以减少InnoDB的自动提交(AUTOCOMMIT)。对于大量的修改操作而言,如果每次改变数据都自动提交,会导致性能急剧下降。

当然,对remove_user_by_email这个函数而言,用不用事务区别不大。

到此为止,用户需要的一些函数都准备好了,我们可以写一个测试来看看它们是否正常工作。

编辑 mysite/modeltest/auth.py 。

#coding:utf-8
import init_path

import unittest
from mysite.model import auth

class TestAuth(unittest.TestCase):
    def setUp(self):
        self.email = "zsp.007@gmail.com"
        self.password = "test123456"
        self.name = "张沈鹏"

    def tearDown(self):
        auth.remove_user_by_email(self.email)

    def test_reg(self):
        email = self.email
        if auth.UserEmail.get(email = email):
            auth.remove_user_by_email(email)

        auth.apply(email, self.password, self.name)

        e = auth.UserEmail.get(email = email)
        assert e is not None

        assert auth.is_applyed(email)

        id = e.id

        assert auth.UserApply.get(id) is not None

        user_password = auth.UserPassword.get(id)
        assert user_password is not None
        assert user_password.verify(self.password)

        user = auth.create_user(id)
        assert user is not None
        assert auth.UserApply.get(id) is None

        user = auth.User.get(id)
        assert user is not None
        assert self.name == user.name

        assert auth.is_existed(email)

if __name__ == '__main__':
    unittest.main()

unittest 是 Python 的测试框架,新建一个以Test开头的类,继承 unittest.TestCase 。

setUp 是初始化函数,tearDown 是结束时的清理函数。

你可以新建一系列的测试函数,只需要函数名以test开头。这里test_reg是用来测试注册的。

test_reg中,模拟了一个用户从申请注册到真正注册的过程,assert用于判断其后条件是否成立。

if __name__ == '__main__':

这种写法是Python中常见的一种写法,表示这段代码在独立运行该脚本时才执行;如果被别的程序导入,这段脚本就不会执行。

好了,到此为止,数据库相关的逻辑就写完了,接下来开始写页面逻辑。

麻油四看的七荤八素,无语凝咽,流泪一公升,吐血十五公升。

早岁哪知世事艰,中原北望气如山。

雄关漫道真如铁,而今迈步从头越。

夜静孤灯,键盘做伴,读书人一声长叹 ...

上一节 返回目录 下一节
京ICP备10008809号