Skip to content

Commit c32e438

Browse files
committed
renamed to redis-objects and streamlined loading
1 parent bd68475 commit c32e438

20 files changed

+748
-523
lines changed

ATOMICITY.rdoc

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
= Redis Objects - Lightweight object layer around redis-rb you can also use in a model
2+
3+
Redis is great _not_ as a replacement for MySQL, but as a way to perform atomic
4+
operations on _individual_ data structures, like counters, lists, and sets. People
5+
that are wrapping ORM's around Redis are missing the point.
6+
7+
This gem, instead, provides atomic methods that you can use *with* your existing
8+
ActiveRecord/DataMapper/etc models, or in classes that have nothing to do with an
9+
ORM or even a database, but need support for high-performance atomic operations.
10+
11+
The only requirement Redis::Atoms has is that your class must provide an +id+ instance
12+
method which returns the ID for that instance. ActiveRecord, DataMapper, and MongoRecord
13+
all have id methods which are known to be suitable. Since +id+ can be anything as
14+
far as Redis::Atoms is concerned, you can even write an +id+ method of your own that
15+
just returns a string, or an MD5 of the name, or something else unique.
16+
17+
== Installation
18+
19+
gem install gemcutter
20+
gem tumble
21+
gem install redis-atoms
22+
23+
== Example
24+
25+
Somewhere in your app initialization
26+
27+
require 'redis'
28+
require 'redis/atoms'
29+
Redis::Atoms.redis = Redis.new(:host => 127.0.0.1, :port => 6379)
30+
31+
Model class:
32+
33+
class Team < ActiveRecord::Base
34+
include Redis::Atoms
35+
36+
counter :drafted_players
37+
counter :active_players
38+
lock :reorder, :timeout => 5 # seconds
39+
end
40+
41+
Using counters to handle concurrency:
42+
43+
@team = Team.find(1)
44+
if @team.drafted_players.increment <= @team.max_players
45+
# do stuff
46+
@team.team_players.create!(:player_id => 221)
47+
@team.active_players.increment
48+
else
49+
# reset counter state
50+
@team.drafted_players.decrement
51+
end
52+
53+
Atomic block - a cleaner way to do the above. Exceptions or nil results
54+
rewind counter back to previous state:
55+
56+
@team.drafted_players.increment do |val|
57+
raise Team::TeamFullError if val > @team.max_players
58+
@team.team_players.create!(:player_id => 221)
59+
@team.active_players.increment
60+
end
61+
62+
Similar approach, using an if block (failure rewinds counter):
63+
64+
@team.drafted_players.increment do |val|
65+
if val <= @team.max_players
66+
@team.team_players.create!(:player_id => 221)
67+
@team.active_players.increment
68+
end
69+
end
70+
71+
Class methods work too - notice we override ActiveRecord counters:
72+
73+
Team.increment_counter :drafted_players, team_id
74+
Team.decrement_counter :drafted_players, team_id, 2
75+
76+
Class-level atomic block (may save a DB fetch depending on your app):
77+
78+
Team.increment_counter(:drafted_players, team_id) do |val|
79+
TeamPitcher.create!(:team_id => team_id, :pitcher_id => 181)
80+
Team.increment_counter(:active_players, team_id)
81+
end
82+
83+
Locks with Redis. On completion or exception the lock is released:
84+
85+
@team.reorder_lock.lock do
86+
@team.reorder_all_players
87+
end
88+
89+
Class-level lock (same concept)
90+
91+
Team.obtain_lock(:reorder, team_id) do
92+
Team.reorder_all_players(team_id)
93+
end
94+
95+
== You Likely Have Some Huge Bugs
96+
97+
You are probably not handling atomic operations properly in your app, and
98+
probably have some nasty lurking race conditions. The worst part is these
99+
will get worse as your user count increases, are difficult to reproduce,
100+
and usually happen to your most critical pieces of code. (And no, your
101+
rspec tests can't catch them either.)
102+
103+
Let's assume you're writing an app to enable students to enroll in courses.
104+
You need to ensure that no more than 30 students can sign up for a given course.
105+
In your enrollment code, you have something like this:
106+
107+
@course = Course.find(1)
108+
if @course.num_students < 30
109+
@course.course_students.create!(:student_id => 101)
110+
@course.num_students += 1
111+
@course.save!
112+
else
113+
# course is full
114+
end
115+
116+
You're screwed. You now have 32 people in your 30 person class, and you have
117+
no idea what happened.
118+
119+
"Well no duh," you're saying, "even the {ActiveRecord docs mention locking}[http://api.rubyonrails.org/classes/ActiveRecord/Locking/Pessimistic.html],
120+
so I'll just use that."
121+
122+
@course = Course.find(1, :lock => true)
123+
if @course.num_students < 30
124+
# ...
125+
126+
Nice try, but now you've introduced other issues. Any other piece of code
127+
in your entire app that needs to update _anything_ about the course - maybe
128+
the course name, or start date, or location - is now serialized. If you need high
129+
concurrency, you're still screwed.
130+
131+
You think, "ah-ha, the problem is having a separate counter!"
132+
133+
@course = Course.find(1)
134+
if @course.course_students.count < 30
135+
@course.course_students.create!(:student_id => 101)
136+
else
137+
# course is full
138+
end
139+
140+
Nope. Still screwed.
141+
142+
== The Root Down
143+
144+
It's worth understanding the root issue, and how to address it.
145+
146+
Race conditions arise from the difference in time between *evaluating* and *altering*
147+
a value. In our example, we fetched the record, then checked the value, then
148+
changed it. The more lines of code between those operations, and the higher your user
149+
count, the bigger the window of opportunity for other clients to get the data in an
150+
inconsistent state.
151+
152+
Sometimes race conditions don't matter in practice, since often a user is
153+
only operating their own data. This has a race condition, but is probably ok:
154+
155+
@post = Post.create(:user_id => @user.id, :title => "Whattup", ...)
156+
@user.total_posts += 1 # update my post count
157+
158+
But this _would_ be problematic:
159+
160+
@post = Post.create(:user_id => @user.id, :title => "Whattup", ...)
161+
@blog.total_posts += 1 # update post count across all users
162+
163+
As multiple users could be adding posts concurrently.
164+
165+
In a traditional RDBMS, you can increment counters atomically (but not return them)
166+
by firing off an update statement that self-references the column:
167+
168+
update users set total_posts = total_posts + 1 where id = 372
169+
170+
You may have seen {ActiveRecord's increment_counter class method}[http://api.rubyonrails.org/classes/ActiveRecord/Base.html#M002278],
171+
which wraps this functionality. But outside of being cumbersome, this has
172+
the side effect that your object is no longer in sync with the DB, so you
173+
get other issues:
174+
175+
Blog.increment_counter :total_posts, @blog.id
176+
if @blog.total_posts == 1000
177+
# the 1000th poster - award them a gold star!
178+
179+
The DB says 1000, but your @blog object still says 999, and the right person
180+
doesn't get their gold star. Sad faces all around.
181+
182+
== A Better Way
183+
184+
Bottom line: Any operation that could alter a value *must* return that value in
185+
the _same_ _operation_ for it to be atomic. If you do a separate get then set,
186+
or set then get, you're open to a race condition. There are very few systems that
187+
support an "increment and return" type operation, and Redis is one of them
188+
(Oracle sequences are another).
189+
190+
When you think of the specific things that you need to ensure, many of these will
191+
reduce to numeric operations:
192+
193+
* Ensuring there are no more than 30 students in a course
194+
* Getting more than 2 but less than 6 people in a game
195+
* Keeping a chat room to a max of 50 people
196+
* Correctly recording the total number of blog posts
197+
* Only allowing one piece of code to reorder a large dataset at a time
198+
199+
All except the last one can be implemented with counters. The last one
200+
will need a carefully placed lock.
201+
202+
The best way I've found to balance atomicity and concurrency is, for each value,
203+
actually create two counters:
204+
205+
* A counter you base logic on (eg, +slots_taken+)
206+
* A counter users see (eg, +current_students+)
207+
208+
The reason you want two counters is you'll need to change the value of the logic
209+
counter *first*, _before_ checking it, to address any race conditions. This means
210+
the value can get wonky momentarily (eg, there could be 32 +slots_taken+ for a 30-person
211+
course). This doesn't affect its function - indeed, it's part of what makes it work - but
212+
does mean you don't want to display it.
213+
214+
So, taking our +Course+ example:
215+
216+
class Course < ActiveRecord::Base
217+
include Redis::Atoms
218+
219+
counter :slots_taken
220+
counter :current_students
221+
end
222+
223+
Then:
224+
225+
@course = Course.find(1)
226+
@course.slots_taken.increment do |val|
227+
if val <= @course.max_students
228+
@course.course_students.create!(:student_id => 101)
229+
@course.current_students.increment
230+
end
231+
end
232+
233+
Race-condition free. And, with the separate +current_students+ counter, your
234+
views get consistent information about the course, since it will only be
235+
incremented on success. There is still a race condition where +current_students+
236+
could be less than the real number of +CourseStudent+ records, but since you'll be
237+
displaying these values in a view (after that block completes) you shouldn't see
238+
this manifest in real-world usage.
239+
240+
Now you can sleep soundly, without fear of getting fired at 3am via an angry
241+
phone call from your boss. (At least, not about this...)
242+
243+
== Author
244+
245+
Copyright (c) 2009 {Nate Wiger}[http://nate.wiger.org]. All Rights Reserved.
246+
Released under the {Artistic License}[http://www.opensource.org/licenses/artistic-license-2.0.php].

0 commit comments

Comments
 (0)